@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.
- package/CHANGELOG.md +57 -0
- package/PROTOCOL.md +122 -0
- package/docs/P2P-CRYPTO.md +225 -0
- package/lib/client.js +513 -0
- package/package.json +7 -3
- package/src/agent/agent.mjs +199 -5
- package/src/agent/crypto.mjs +1618 -0
- package/src/agent/p2p-node.mjs +276 -5
- package/src/agent/p2p.mjs +667 -40
- package/src/plugin/fleet-target.mjs +603 -0
- package/src/plugin/tools.mjs +348 -39
- package/src/relay/peers.mjs +56 -0
|
@@ -0,0 +1,1618 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The peer-to-peer data plane's cryptography: a device keypair, a two-message handshake and the
|
|
3
|
+
* transport keys that come out of it. Stage 1 of `docs/P2P-CRYPTO.md`, which is the contract this
|
|
4
|
+
* file implements and the document that has to be updated if this file changes.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS IS A SEPARATE MODULE
|
|
7
|
+
*
|
|
8
|
+
* `p2p.mjs` owns punching and the reliable channel; it deliberately knows nothing about identity.
|
|
9
|
+
* Everything here is about *identity and keys*, and keeping it apart is what lets the handshake be
|
|
10
|
+
* tested without a socket and the channel be tested without a keypair. Nothing in this file opens a
|
|
11
|
+
* socket, and only {@link DeviceKey} touches the disk.
|
|
12
|
+
*
|
|
13
|
+
* WHAT IS HERE, IN THE DOCUMENT'S OWN TERMS (§4)
|
|
14
|
+
*
|
|
15
|
+
* * **Identity** —an X25519 keypair generated the first time a machine pairs, stored as
|
|
16
|
+
* `device.key` beside `device.json`. See {@link DeviceKey} for what "mode 0600" honestly means
|
|
17
|
+
* on Windows.
|
|
18
|
+
* * **Handshake** —two messages modelled on Noise IK: `HELLO` (e_i, mac) and `HELLO_ACK`
|
|
19
|
+
* (e_r, mac'). The session key is the triple DH `ECDH(e_i,e_r) —ECDH(s_i,e_r) —ECDH(e_i,s_r)`,
|
|
20
|
+
* which gives mutual authentication and forward secrecy; `k0 = ECDH(s_i,s_r)` is what proves
|
|
21
|
+
* possession of the static key before any of that is trusted.
|
|
22
|
+
* * **Transport** —ChaCha20-Poly1305 per frame, nonce = four zero bytes followed by a 64-bit
|
|
23
|
+
* little-endian counter, a 64-message sliding replay window, and rekey after 2^20 messages or
|
|
24
|
+
* 120 seconds with the previous epoch key retained briefly.
|
|
25
|
+
*
|
|
26
|
+
* TWO PLACES THIS FILE DEPARTS FROM §4, BOTH REPORTED
|
|
27
|
+
*
|
|
28
|
+
* 1. **`mac1`/`mac2` are not implemented.** They are keyed BLAKE2s, which `node:crypto` does not
|
|
29
|
+
* expose (OpenSSL has it; the binding does not). They mitigate *CPU-exhaustion denial of
|
|
30
|
+
* service* —they let a responder drop forged handshake messages before doing any asymmetric
|
|
31
|
+
* operation —and they contribute nothing to confidentiality or authenticity. Stage 1 accepts
|
|
32
|
+
* that exposure and says so in {@link HANDSHAKE_MAC_FIELDS_OMITTED} rather than shipping a
|
|
33
|
+
* half-built lookalike.
|
|
34
|
+
* 2. **The handshake MACs are bound to the session id and the handshake version**, which §4's
|
|
35
|
+
* sketch does not show. Without that binding, every input to §4's `mac` is identical for every
|
|
36
|
+
* punch between the same pair of machines, so a `HELLO` captured from one punch would verify
|
|
37
|
+
* in the next one. The binding is strictly stronger than what §4 writes and adds no field it
|
|
38
|
+
* names; {@link sessionBinding} records it.
|
|
39
|
+
*
|
|
40
|
+
* Everything is big-endian in the handshake messages, matching `p2p.mjs`'s framing.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
createCipheriv,
|
|
45
|
+
createDecipheriv,
|
|
46
|
+
createHash,
|
|
47
|
+
createPrivateKey,
|
|
48
|
+
createPublicKey,
|
|
49
|
+
diffieHellman,
|
|
50
|
+
generateKeyPairSync,
|
|
51
|
+
hkdfSync,
|
|
52
|
+
randomBytes,
|
|
53
|
+
timingSafeEqual,
|
|
54
|
+
} from 'node:crypto';
|
|
55
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
56
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
57
|
+
|
|
58
|
+
/* -------------------------------------------------------------------------- */
|
|
59
|
+
/* Names that travel on the wire, or into a report */
|
|
60
|
+
/* -------------------------------------------------------------------------- */
|
|
61
|
+
|
|
62
|
+
/** The key file's name, beside `device.json`. §4 names it exactly. */
|
|
63
|
+
export const DEVICE_KEY_FILE = 'device.key';
|
|
64
|
+
|
|
65
|
+
/** Contract-mandated permission bits for {@link DEVICE_KEY_FILE}. See {@link DeviceKey}. */
|
|
66
|
+
export const DEVICE_KEY_MODE = 0o600;
|
|
67
|
+
|
|
68
|
+
/** Schema marker of the key file's contents, so a future format is detectable rather than misread. */
|
|
69
|
+
export const DEVICE_KEY_FORMAT = 'w2m-device-key/1';
|
|
70
|
+
|
|
71
|
+
/** The key algorithm, spelled the way `node:crypto` spells it. */
|
|
72
|
+
export const DEVICE_KEY_ALGO = 'x25519';
|
|
73
|
+
|
|
74
|
+
/** Bytes in an X25519 public key, or in any derived key. */
|
|
75
|
+
export const KEY_BYTES = 32;
|
|
76
|
+
|
|
77
|
+
/** Handshake version, carried in every handshake body. */
|
|
78
|
+
export const CRYPTO_VERSION = 1;
|
|
79
|
+
|
|
80
|
+
/** First byte of every handshake/encrypted *body*: what tells it from a plain punch or a plain frame. */
|
|
81
|
+
export const FRAME_MAGIC = 0xa5;
|
|
82
|
+
|
|
83
|
+
/** Bytes of a ChaCha20-Poly1305 authentication tag. */
|
|
84
|
+
export const TAG_BYTES = 16;
|
|
85
|
+
|
|
86
|
+
/** Bytes of `p2p.mjs`'s frame header: kind u8 + session u32. Authenticated, never encrypted. */
|
|
87
|
+
export const FRAME_HEADER_BYTES = 5;
|
|
88
|
+
|
|
89
|
+
/** Bytes at the head of an encrypted frame's body: magic + epoch + 64-bit counter. */
|
|
90
|
+
export const CIPHER_HEADER_BYTES = 1 + 1 + 8;
|
|
91
|
+
|
|
92
|
+
/** Bytes of the AEAD-produced MAC: 32 bytes of ciphertext plus the 16-byte tag. */
|
|
93
|
+
export const MAC_BYTES = KEY_BYTES + TAG_BYTES;
|
|
94
|
+
|
|
95
|
+
/** Bytes of a handshake body up to and including the machine-id length byte. */
|
|
96
|
+
export const HANDSHAKE_PREFIX_BYTES = 1 + 1 + 4 + KEY_BYTES + 1;
|
|
97
|
+
|
|
98
|
+
/** Smallest possible handshake body: the prefix, no machine id, and the MAC. */
|
|
99
|
+
export const HANDSHAKE_MIN_BYTES = HANDSHAKE_PREFIX_BYTES + MAC_BYTES;
|
|
100
|
+
|
|
101
|
+
/** Longest machine id a handshake carries, so a hostile one cannot make the responder hash a novel. */
|
|
102
|
+
export const MAX_HANDSHAKE_MACHINE_ID = 128;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Bytes an encrypted frame adds, in total.
|
|
106
|
+
*
|
|
107
|
+
* Constrained by `MAX_FRAGMENT_PAYLOAD`, and the arithmetic is worth stating because it is the one
|
|
108
|
+
* place a "few bytes more" silently stops being true: a DATA frame is 5 header + 12 fields + 1160
|
|
109
|
+
* payload = 1177 bytes, and this overhead makes it 1208 —8 bytes over the 1200-byte budget the
|
|
110
|
+
* fragment size was chosen for, and 72 under a 1280-byte IPv6 minimum MTU either way. The tightened
|
|
111
|
+
* budget is asserted in `test/p2p-crypto.test.mjs` against `MAX_FRAGMENT_PAYLOAD` itself rather than
|
|
112
|
+
* assumed here.
|
|
113
|
+
*/
|
|
114
|
+
export const CIPHER_OVERHEAD_BYTES = CIPHER_HEADER_BYTES + TAG_BYTES;
|
|
115
|
+
|
|
116
|
+
/** `k = HKDF(...)` of §4: the triple DH, expanded into four purpose-bound sub-keys. */
|
|
117
|
+
export const SESSION_KEY_LABEL = 'w2m-p2p-crypto v1 session';
|
|
118
|
+
|
|
119
|
+
/** `k0 = HKDF(ECDH(s_i,s_r))` of §4: proves possession of the static key. */
|
|
120
|
+
export const STATIC_KEY_LABEL = 'w2m-p2p-crypto v1 static';
|
|
121
|
+
|
|
122
|
+
/** The literal string §4's `hash(prologue ——` starts with. */
|
|
123
|
+
export const PROLOGUE = 'w2m-p2p-crypto v1 prologue';
|
|
124
|
+
|
|
125
|
+
/** Sub-key labels. Distinct per purpose, mixed in through HKDF's `info`. */
|
|
126
|
+
export const INITIATOR_MAC_LABEL = 'w2m-p2p-crypto v1 mac-initiator';
|
|
127
|
+
export const RESPONDER_MAC_LABEL = 'w2m-p2p-crypto v1 mac-responder';
|
|
128
|
+
export const INITIATOR_TO_RESPONDER_LABEL = 'w2m-p2p-crypto v1 i2r';
|
|
129
|
+
export const RESPONDER_TO_INITIATOR_LABEL = 'w2m-p2p-crypto v1 r2i';
|
|
130
|
+
|
|
131
|
+
/** Rekey thresholds, from §4: 2^20 messages or 120 seconds, whichever comes first. */
|
|
132
|
+
export const REKEY_AFTER_MESSAGES = 1 << 20;
|
|
133
|
+
export const REKEY_AFTER_MS = 120_000;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* How long a superseded epoch key is kept.
|
|
137
|
+
*
|
|
138
|
+
* This is §4's "the old key is retained briefly so frames in flight are not lost", and it is not a
|
|
139
|
+
* nicety: after a rekey the peer may have frames on the wire that were encrypted under the previous
|
|
140
|
+
* key, and a receiver that dropped the key the instant it derived the next one would lose them
|
|
141
|
+
* silently. 15 seconds is orders of magnitude above any plausible flight time on a path whose RTO is
|
|
142
|
+
* 300 ms, and short enough that a long-lived session holds two epochs, never a growing set.
|
|
143
|
+
*/
|
|
144
|
+
export const EPOCH_RETENTION_MS = 15_000;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* How far ahead of what this end has already derived a single frame may push the ratchet.
|
|
148
|
+
*
|
|
149
|
+
* The epoch rides in the clear in every frame, so it is attacker-chosen input: without this bound one
|
|
150
|
+
* forged frame could ask for two billion HKDF steps. It is measured **from the highest epoch already
|
|
151
|
+
* derived**, not as an absolute ceiling — a session that sends steadily advances its own epoch, and its
|
|
152
|
+
* own defence must not become the thing that stops it (see {@link CryptoSession#epochKeysFor}).
|
|
153
|
+
*/
|
|
154
|
+
export const MAX_EPOCH_LOOKAHEAD = 8;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `mac1`/`mac2`, as a value rather than a comment somebody can miss (see this file's header).
|
|
158
|
+
*
|
|
159
|
+
* Exported so a reader of `w2m_status`, a test, or a stage-2 implementation sees the omission as a
|
|
160
|
+
* decision with a stated consequence: these fields mitigate CPU-exhaustion denial of service on the
|
|
161
|
+
* handshake path, and their absence does **not** weaken confidentiality or authenticity.
|
|
162
|
+
*/
|
|
163
|
+
export const HANDSHAKE_MAC_FIELDS_OMITTED = Object.freeze({
|
|
164
|
+
fields: ['mac1', 'mac2'],
|
|
165
|
+
reason: 'keyed BLAKE2s is not exposed by node:crypto',
|
|
166
|
+
consequence:
|
|
167
|
+
'no CPU-exhaustion DoS mitigation on the handshake path; confidentiality and authenticity are unaffected',
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The named refusal reasons. Every one of these is *reported*; none is a silent drop.
|
|
172
|
+
*
|
|
173
|
+
* These strings are the contract's "refuse with a distinct reason code" (§4) and they are what the
|
|
174
|
+
* agent writes into the ledger, so they are stable identifiers rather than prose.
|
|
175
|
+
*/
|
|
176
|
+
export const CRYPTO_REASON = Object.freeze({
|
|
177
|
+
/** This machine has no keypair, or none for that peer, so no handshake is attempted. */
|
|
178
|
+
NO_KEY: 'P2P_CRYPTO_NO_KEY',
|
|
179
|
+
/** A handshake named a machine whose static key we do not hold. */
|
|
180
|
+
UNKNOWN_PEER: 'P2P_CRYPTO_UNKNOWN_PEER',
|
|
181
|
+
/** The peer is known, and the MAC over its static key did not verify. */
|
|
182
|
+
BAD_MAC: 'P2P_CRYPTO_BAD_MAC',
|
|
183
|
+
/** The same initiator ephemeral arrived twice: a replayed HELLO. */
|
|
184
|
+
REPLAY: 'P2P_CRYPTO_REPLAY',
|
|
185
|
+
/** A data frame's authentication tag did not verify. Dropped, never processed. */
|
|
186
|
+
AUTH_FAILED: 'P2P_CRYPTO_AUTH_FAILED',
|
|
187
|
+
/** An authenticated frame whose counter was already inside the replay window. */
|
|
188
|
+
REPLAYED_FRAME: 'P2P_CRYPTO_REPLAYED_FRAME',
|
|
189
|
+
/** An authenticated frame from an epoch whose key has been dropped. */
|
|
190
|
+
STALE_EPOCH: 'P2P_CRYPTO_STALE_EPOCH',
|
|
191
|
+
/** A frame claimed an epoch we will not derive. */
|
|
192
|
+
EPOCH_TOO_FAR: 'P2P_CRYPTO_EPOCH_TOO_FAR',
|
|
193
|
+
/** The peer answered the handshake with an explicit refusal. */
|
|
194
|
+
REFUSED_BY_PEER: 'P2P_CRYPTO_REFUSED_BY_PEER',
|
|
195
|
+
/** The handshake did not complete inside its window. */
|
|
196
|
+
HANDSHAKE_TIMEOUT: 'P2P_CRYPTO_HANDSHAKE_TIMEOUT',
|
|
197
|
+
/** The peer speaks a handshake version this build does not. */
|
|
198
|
+
VERSION_MISMATCH: 'P2P_CRYPTO_VERSION_MISMATCH',
|
|
199
|
+
/** A handshake message whose shape is unusable. */
|
|
200
|
+
MALFORMED: 'P2P_CRYPTO_MALFORMED',
|
|
201
|
+
/** The peer never learned a key (an older build), so a direct channel with it is plaintext. */
|
|
202
|
+
PLAINTEXT_PEER: 'P2P_CRYPTO_PLAINTEXT_PEER',
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/* -------------------------------------------------------------------------- */
|
|
206
|
+
/* Keys */
|
|
207
|
+
/* -------------------------------------------------------------------------- */
|
|
208
|
+
|
|
209
|
+
/** @param {Buffer} buffer @returns {string} standard base64: the one spelling this project uses. */
|
|
210
|
+
export function encodeKey(buffer) {
|
|
211
|
+
return Buffer.from(buffer).toString('base64');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Decode a 32-byte key from its base64 spelling, strictly.
|
|
216
|
+
*
|
|
217
|
+
* Strict because the alternative is a key that *almost* decodes: a trailing space, a URL-safe `-`, a
|
|
218
|
+
* truncated string. `Buffer.from(x, 'base64')` ignores characters it does not recognise, so without
|
|
219
|
+
* this check a mangled key would decode to something and fail much further away, as a MAC mismatch
|
|
220
|
+
* that looks exactly like an attack.
|
|
221
|
+
*
|
|
222
|
+
* @param {unknown} value
|
|
223
|
+
* @returns {Buffer|null} The key, or null when the value is not exactly a 32-byte base64 key.
|
|
224
|
+
*/
|
|
225
|
+
export function decodeKey(value) {
|
|
226
|
+
if (typeof value !== 'string' || value === '') return null;
|
|
227
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
|
|
228
|
+
const decoded = Buffer.from(value, 'base64');
|
|
229
|
+
if (decoded.length !== KEY_BYTES) return null;
|
|
230
|
+
// A re-encode comparison catches padding or trailing characters the decoder ignored.
|
|
231
|
+
if (decoded.toString('base64') !== value) return null;
|
|
232
|
+
return decoded;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** @param {unknown} value @returns {boolean} Whether `value` is a usable base64 X25519 public key. */
|
|
236
|
+
export function isPublicKey(value) {
|
|
237
|
+
return decodeKey(value) !== null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** @param {Buffer} raw 32 bytes. @returns {import('node:crypto').KeyObject} */
|
|
241
|
+
export function publicKeyFromRaw(raw) {
|
|
242
|
+
return createPublicKey({
|
|
243
|
+
key: Buffer.concat([Buffer.from('302a300506032b656e032100', 'hex'), raw]),
|
|
244
|
+
format: 'der',
|
|
245
|
+
type: 'spki',
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* @param {Buffer} raw 32-byte little-endian private scalar.
|
|
251
|
+
* @returns {import('node:crypto').KeyObject}
|
|
252
|
+
*/
|
|
253
|
+
export function privateKeyFromRaw(raw) {
|
|
254
|
+
return createPrivateKey({
|
|
255
|
+
key: Buffer.concat([Buffer.from('302e020100300506032b656e04220420', 'hex'), raw]),
|
|
256
|
+
format: 'der',
|
|
257
|
+
type: 'pkcs8',
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** @param {import('node:crypto').KeyObject} publicKey @returns {Buffer} The raw 32 bytes. */
|
|
262
|
+
export function rawPublicOf(publicKey) {
|
|
263
|
+
return publicKey.export({ type: 'spki', format: 'der' }).subarray(-KEY_BYTES);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** @param {import('node:crypto').KeyObject} privateKey @returns {Buffer} The raw 32-byte scalar. */
|
|
267
|
+
export function rawPrivateOf(privateKey) {
|
|
268
|
+
return privateKey.export({ type: 'pkcs8', format: 'der' }).subarray(-KEY_BYTES);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* One machine's static X25519 keypair: its identity on the direct path.
|
|
273
|
+
*
|
|
274
|
+
* WHERE IT LIVES, AND WHAT "0600" HONESTLY MEANS
|
|
275
|
+
*
|
|
276
|
+
* The private key is written beside `device.json` (the state directory) with mode `0o600` on the
|
|
277
|
+
* file and `0o700` on the directory, exactly as `identity.mjs` does for the device token. **On
|
|
278
|
+
* Windows those bits do nothing.** NTFS carries no POSIX modes: `chmodSync` there is best-effort and
|
|
279
|
+
* cannot fail the write, and the file's real protection is the ACL it inherits from the per-user
|
|
280
|
+
* profile directory plus the absence of any "Everyone" grant —the platform's default profile
|
|
281
|
+
* protection, not something this code establishes. A machine where another local user can read the
|
|
282
|
+
* profile directory can read this key. Saying that here is the point; the alternative is a comment
|
|
283
|
+
* claiming a guarantee the platform does not provide.
|
|
284
|
+
*
|
|
285
|
+
* The file is JSON rather than 32 raw bytes so its format is self-describing and a future change is
|
|
286
|
+
* detectable rather than misparsed, and it is written atomically (temp file in the same directory,
|
|
287
|
+
* then rename) so a crash mid-write cannot leave a half-written key. Only the private scalar is
|
|
288
|
+
* stored: the public half is derived, which is what makes a hand-edited file unable to publish a
|
|
289
|
+
* public key that does not match its private key.
|
|
290
|
+
*/
|
|
291
|
+
export class DeviceKey {
|
|
292
|
+
/**
|
|
293
|
+
* @param {object} options
|
|
294
|
+
* @param {import('node:crypto').KeyObject} options.privateKey
|
|
295
|
+
* @param {string|null} [options.machineId] Metadata for diagnostics; never an input to a derivation.
|
|
296
|
+
* @param {string|null} [options.path] Where it came from, or null for a generated one.
|
|
297
|
+
*/
|
|
298
|
+
constructor({ privateKey, machineId = null, path = null }) {
|
|
299
|
+
this.privateKey = privateKey;
|
|
300
|
+
this.publicKey = derivePublicKey(privateKey);
|
|
301
|
+
/** @type {string|null} */
|
|
302
|
+
this.machineId = typeof machineId === 'string' && machineId !== '' ? machineId : null;
|
|
303
|
+
/** @type {string|null} */
|
|
304
|
+
this.path = path;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** This machine's static public key, base64. The value that goes into an offer. */
|
|
308
|
+
get publicKeyBase64() {
|
|
309
|
+
return encodeKey(rawPublicOf(this.publicKey));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The private scalar, base64. Only ever written to the key file. */
|
|
313
|
+
get privateKeyBase64() {
|
|
314
|
+
return encodeKey(rawPrivateOf(this.privateKey));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** @param {DeviceKey|string} other The other end's static public key, or a `DeviceKey`. */
|
|
318
|
+
equals(other) {
|
|
319
|
+
const value = other instanceof DeviceKey ? other.publicKeyBase64 : other;
|
|
320
|
+
return value === this.publicKeyBase64;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Load the keypair from `device.key` beside `device.json`, generating it on first use.
|
|
325
|
+
*
|
|
326
|
+
* @param {object} options
|
|
327
|
+
* @param {string} [options.dir] Directory that holds (or will hold) `device.key`.
|
|
328
|
+
* @param {string} [options.file] Explicit file path; wins over `dir`.
|
|
329
|
+
* @param {string|null} [options.machineId] Recorded in the file's metadata.
|
|
330
|
+
* @returns {{key: DeviceKey, path: string, created: boolean}}
|
|
331
|
+
* @throws {Error} `DEVICE_KEY_CORRUPT` / `DEVICE_KEY_UNREADABLE` when the file exists but cannot be
|
|
332
|
+
* trusted. Deliberately a hard error, for the same reason `identity.mjs` refuses to mint a new
|
|
333
|
+
* `machine_id`: silently generating a replacement key would make every peer that knows the old
|
|
334
|
+
* one refuse this machine, and "my peers stopped talking to me" would have no visible cause.
|
|
335
|
+
*/
|
|
336
|
+
static load(options = {}) {
|
|
337
|
+
const path = options.file
|
|
338
|
+
? isAbsolute(options.file)
|
|
339
|
+
? options.file
|
|
340
|
+
: resolve(options.file)
|
|
341
|
+
: join(requireDir(options.dir), DEVICE_KEY_FILE);
|
|
342
|
+
|
|
343
|
+
let text = null;
|
|
344
|
+
try {
|
|
345
|
+
text = readFileSync(path, 'utf8');
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (error?.code !== 'ENOENT') {
|
|
348
|
+
const failure = new Error(`could not read ${path}: ${error?.code ?? 'UNKNOWN'}`);
|
|
349
|
+
failure.code = 'DEVICE_KEY_UNREADABLE';
|
|
350
|
+
throw failure;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (text !== null) {
|
|
355
|
+
let parsed;
|
|
356
|
+
try {
|
|
357
|
+
parsed = JSON.parse(text);
|
|
358
|
+
} catch {
|
|
359
|
+
const failure = new Error(`${path} is not valid JSON; refusing to mint a replacement key`);
|
|
360
|
+
failure.code = 'DEVICE_KEY_CORRUPT';
|
|
361
|
+
throw failure;
|
|
362
|
+
}
|
|
363
|
+
if (parsed?.format !== DEVICE_KEY_FORMAT || parsed?.algo !== DEVICE_KEY_ALGO) {
|
|
364
|
+
const failure = new Error(`${path} is not a ${DEVICE_KEY_FORMAT} ${DEVICE_KEY_ALGO} key`);
|
|
365
|
+
failure.code = 'DEVICE_KEY_CORRUPT';
|
|
366
|
+
throw failure;
|
|
367
|
+
}
|
|
368
|
+
const raw = decodeKey(parsed.private_key);
|
|
369
|
+
if (raw === null) {
|
|
370
|
+
const failure = new Error(`${path} has no usable private_key; refusing to mint a replacement`);
|
|
371
|
+
failure.code = 'DEVICE_KEY_CORRUPT';
|
|
372
|
+
throw failure;
|
|
373
|
+
}
|
|
374
|
+
const key = new DeviceKey({
|
|
375
|
+
privateKey: privateKeyFromRaw(raw),
|
|
376
|
+
machineId: parsed.machine_id ?? options.machineId ?? null,
|
|
377
|
+
path,
|
|
378
|
+
});
|
|
379
|
+
return { key, path, created: false };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const key = new DeviceKey({
|
|
383
|
+
privateKey: generateKeyPairSync(DEVICE_KEY_ALGO).privateKey,
|
|
384
|
+
machineId: options.machineId ?? null,
|
|
385
|
+
path,
|
|
386
|
+
});
|
|
387
|
+
key.save();
|
|
388
|
+
return { key, path, created: true };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Build a keypair from an existing private scalar, without touching the disk.
|
|
393
|
+
*
|
|
394
|
+
* The test seam, and the shape a caller uses when the key arrived from somewhere other than a
|
|
395
|
+
* file. `null` disables the direct path's cryptography entirely, which is how a test —or a
|
|
396
|
+
* machine that has deliberately opted out —produces the "older peer" case.
|
|
397
|
+
*
|
|
398
|
+
* @param {Buffer|string} raw 32 raw bytes, or their base64 spelling.
|
|
399
|
+
* @param {string|null} [machineId]
|
|
400
|
+
* @returns {DeviceKey}
|
|
401
|
+
*/
|
|
402
|
+
static fromPrivate(raw, machineId = null) {
|
|
403
|
+
const bytes = Buffer.isBuffer(raw) ? raw : decodeKey(raw);
|
|
404
|
+
if (bytes === null || bytes.length !== KEY_BYTES) {
|
|
405
|
+
throw new TypeError('DeviceKey.fromPrivate: the private key must be 32 bytes');
|
|
406
|
+
}
|
|
407
|
+
return new DeviceKey({ privateKey: privateKeyFromRaw(bytes), machineId });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Generate a fresh keypair in memory. See {@link DeviceKey.load} for the on-disk path. */
|
|
411
|
+
static generate(machineId = null) {
|
|
412
|
+
return new DeviceKey({ privateKey: generateKeyPairSync(DEVICE_KEY_ALGO).privateKey, machineId });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Write the keypair to {@link DeviceKey#path}, atomically, with the modes this platform honours. */
|
|
416
|
+
save() {
|
|
417
|
+
if (this.path === null) throw new Error('DeviceKey.save: this key has no path');
|
|
418
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
419
|
+
const body = {
|
|
420
|
+
format: DEVICE_KEY_FORMAT,
|
|
421
|
+
algo: DEVICE_KEY_ALGO,
|
|
422
|
+
machine_id: this.machineId,
|
|
423
|
+
private_key: this.privateKeyBase64,
|
|
424
|
+
};
|
|
425
|
+
const tmp = `${this.path}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
426
|
+
writeFileSync(tmp, `${JSON.stringify(body, null, 2)}\n`, { encoding: 'utf8', mode: DEVICE_KEY_MODE });
|
|
427
|
+
renameSync(tmp, this.path);
|
|
428
|
+
try {
|
|
429
|
+
chmodSync(this.path, DEVICE_KEY_MODE);
|
|
430
|
+
} catch {
|
|
431
|
+
// Windows/NTFS carries no POSIX bits. See this class's comment: the key file's protection
|
|
432
|
+
// there is the profile directory's inherited ACL, not this call.
|
|
433
|
+
}
|
|
434
|
+
return this.path;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The raw ECDH output for one peer public key.
|
|
439
|
+
*
|
|
440
|
+
* @param {Buffer|string} peerPublicKey 32 raw bytes or their base64 spelling.
|
|
441
|
+
* @returns {Buffer}
|
|
442
|
+
*/
|
|
443
|
+
sharedSecret(peerPublicKey) {
|
|
444
|
+
const raw = Buffer.isBuffer(peerPublicKey) ? peerPublicKey : decodeKey(peerPublicKey);
|
|
445
|
+
if (raw === null || raw.length !== KEY_BYTES) {
|
|
446
|
+
throw new TypeError('DeviceKey.sharedSecret: the peer public key must be 32 bytes');
|
|
447
|
+
}
|
|
448
|
+
return diffieHellman({ privateKey: this.privateKey, publicKey: publicKeyFromRaw(raw) });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** @param {unknown} dir @returns {string} */
|
|
453
|
+
function requireDir(dir) {
|
|
454
|
+
if (typeof dir !== 'string' || dir.trim() === '') {
|
|
455
|
+
throw new TypeError('DeviceKey: a state directory (or an explicit file) is required to locate device.key');
|
|
456
|
+
}
|
|
457
|
+
return isAbsolute(dir) ? dir : resolve(dir);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* The public half of a private key.
|
|
462
|
+
*
|
|
463
|
+
* Node can do this directly for a KeyObject, and the fallback is the RFC 7748 base-point
|
|
464
|
+
* multiplication in plain JavaScript below. The fallback exists because a *private scalar* —the
|
|
465
|
+
* shape the key file stores and a test may hand over —is not a KeyObject until
|
|
466
|
+
* {@link privateKeyFromRaw} has built one, and `createPublicKey` on a PKCS#8 X25519 blob is exactly
|
|
467
|
+
* the call that the raw-scalar route cannot make. Both paths are used, and both are exercised.
|
|
468
|
+
*
|
|
469
|
+
* @param {import('node:crypto').KeyObject} privateKey
|
|
470
|
+
* @returns {import('node:crypto').KeyObject}
|
|
471
|
+
*/
|
|
472
|
+
function derivePublicKey(privateKey) {
|
|
473
|
+
try {
|
|
474
|
+
return createPublicKey(privateKey);
|
|
475
|
+
} catch {
|
|
476
|
+
return publicKeyFromRaw(x25519BasePoint(rawPrivateOf(privateKey)));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/* -- X25519 base-point multiplication, in plain JavaScript ------------------ */
|
|
481
|
+
/*
|
|
482
|
+
* RFC 7748 §5, and it is here rather than in a dependency because the project has none. Constant-time
|
|
483
|
+
* discipline is not attempted and the reason is stated rather than implied: this runs at most once
|
|
484
|
+
* per key, on this machine's *own* key, with no attacker-chosen input and no secret-dependent branch
|
|
485
|
+
* a remote party can time. A protocol implementation that multiplied attacker-supplied points with a
|
|
486
|
+
* long-lived secret would need the constant-time form; this is not that.
|
|
487
|
+
*/
|
|
488
|
+
|
|
489
|
+
/** @param {bigint} value @param {bigint} modulus @returns {bigint} Non-negative remainder. */
|
|
490
|
+
function mod(value, modulus) {
|
|
491
|
+
const result = value % modulus;
|
|
492
|
+
return result < 0n ? result + modulus : result;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** @param {bigint} base @param {bigint} exponent @param {bigint} modulus @returns {bigint} */
|
|
496
|
+
function modPow(base, exponent, modulus) {
|
|
497
|
+
let result = 1n;
|
|
498
|
+
let b = mod(base, modulus);
|
|
499
|
+
let e = exponent;
|
|
500
|
+
while (e > 0n) {
|
|
501
|
+
if (e & 1n) result = mod(result * b, modulus);
|
|
502
|
+
b = mod(b * b, modulus);
|
|
503
|
+
e >>= 1n;
|
|
504
|
+
}
|
|
505
|
+
return result;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* The u-coordinate of `scalar · B`, little-endian in and out.
|
|
510
|
+
*
|
|
511
|
+
* @param {Buffer} scalarBytes 32-byte little-endian private scalar.
|
|
512
|
+
* @returns {Buffer} 32 bytes.
|
|
513
|
+
*/
|
|
514
|
+
export function x25519BasePoint(scalarBytes) {
|
|
515
|
+
const P = (1n << 255n) - 19n;
|
|
516
|
+
const A24 = 121665n;
|
|
517
|
+
|
|
518
|
+
let scalar = 0n;
|
|
519
|
+
for (let i = scalarBytes.length - 1; i >= 0; i -= 1) scalar = (scalar << 8n) | BigInt(scalarBytes[i]);
|
|
520
|
+
// Clamp, per RFC 7748: clear the three low bits, clear the top bit, set the second-highest bit.
|
|
521
|
+
scalar &= (1n << 255n) - 1n;
|
|
522
|
+
scalar &= ~7n;
|
|
523
|
+
scalar &= ~(1n << 254n);
|
|
524
|
+
scalar |= 1n << 254n;
|
|
525
|
+
|
|
526
|
+
const x1 = 9n;
|
|
527
|
+
let x2 = 1n;
|
|
528
|
+
let z2 = 0n;
|
|
529
|
+
let x3 = 9n;
|
|
530
|
+
let z3 = 1n;
|
|
531
|
+
let swap = 0n;
|
|
532
|
+
|
|
533
|
+
for (let t = 254; t >= 0; t -= 1) {
|
|
534
|
+
const bit = (scalar >> BigInt(t)) & 1n;
|
|
535
|
+
swap ^= bit;
|
|
536
|
+
if (swap === 1n) {
|
|
537
|
+
[x2, x3] = [x3, x2];
|
|
538
|
+
[z2, z3] = [z3, z2];
|
|
539
|
+
}
|
|
540
|
+
swap = bit;
|
|
541
|
+
|
|
542
|
+
const a = mod(x2 + z2, P);
|
|
543
|
+
const aa = mod(a * a, P);
|
|
544
|
+
const b = mod(x2 - z2, P);
|
|
545
|
+
const bb = mod(b * b, P);
|
|
546
|
+
const e = mod(aa - bb, P);
|
|
547
|
+
const c = mod(x3 + z3, P);
|
|
548
|
+
const d = mod(x3 - z3, P);
|
|
549
|
+
const da = mod(d * a, P);
|
|
550
|
+
const cb = mod(c * b, P);
|
|
551
|
+
const t1 = mod(da + cb, P);
|
|
552
|
+
const t2 = mod(da - cb, P);
|
|
553
|
+
x3 = mod(t1 * t1, P);
|
|
554
|
+
z3 = mod(x1 * mod(t2 * t2, P), P);
|
|
555
|
+
x2 = mod(aa * bb, P);
|
|
556
|
+
z2 = mod(e * mod(aa + mod(A24 * e, P), P), P);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (swap === 1n) {
|
|
560
|
+
[x2, x3] = [x3, x2];
|
|
561
|
+
[z2, z3] = [z3, z2];
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const value = mod(x2 * modPow(z2, P - 2n, P), P);
|
|
565
|
+
const out = Buffer.alloc(KEY_BYTES);
|
|
566
|
+
let rest = value;
|
|
567
|
+
for (let i = 0; i < KEY_BYTES; i += 1) {
|
|
568
|
+
out[i] = Number(rest & 0xffn);
|
|
569
|
+
rest >>= 8n;
|
|
570
|
+
}
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* A peer's static key, learned from an offer or from the relay, with its provenance.
|
|
576
|
+
*
|
|
577
|
+
* Provenance is the answer to "why did this channel not encrypt?": a key from the relay and a key
|
|
578
|
+
* from the dispatcher's own offer are equally usable, but a *missing* one can only be diagnosed if
|
|
579
|
+
* the reader can see that nothing ever supplied it.
|
|
580
|
+
*/
|
|
581
|
+
export class PeerKeyStore {
|
|
582
|
+
constructor() {
|
|
583
|
+
/** @type {Map<string, {key: string, source: string, at: number}>} */
|
|
584
|
+
this.keys = new Map();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* @param {string} machineId
|
|
589
|
+
* @param {unknown} key
|
|
590
|
+
* @param {string} [source]
|
|
591
|
+
* @param {number} [now]
|
|
592
|
+
* @returns {boolean} Whether the key was usable and stored.
|
|
593
|
+
*/
|
|
594
|
+
set(machineId, key, source = 'unknown', now = Date.now()) {
|
|
595
|
+
if (typeof machineId !== 'string' || machineId === '') return false;
|
|
596
|
+
if (!isPublicKey(key)) return false;
|
|
597
|
+
this.keys.set(machineId, { key, source, at: now });
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** @param {string} machineId @returns {string|null} */
|
|
602
|
+
get(machineId) {
|
|
603
|
+
const entry = this.keys.get(machineId);
|
|
604
|
+
return entry === undefined ? null : entry.key;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** @param {string} machineId @returns {{key: string, source: string, at: number}|null} */
|
|
608
|
+
entry(machineId) {
|
|
609
|
+
const found = this.keys.get(machineId);
|
|
610
|
+
return found === undefined ? null : { ...found };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** @param {string} machineId @returns {boolean} */
|
|
614
|
+
has(machineId) {
|
|
615
|
+
return this.keys.has(machineId);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Merge a `static_keys` map as it arrives on an offer.
|
|
620
|
+
*
|
|
621
|
+
* A malformed entry is *skipped*, never fatal: one broken key from a dispatcher must not cost the
|
|
622
|
+
* whole fleet its ability to punch. The returned count is what the caller reports.
|
|
623
|
+
*
|
|
624
|
+
* @param {unknown} map
|
|
625
|
+
* @param {string} [source]
|
|
626
|
+
* @returns {number} How many keys were accepted.
|
|
627
|
+
*/
|
|
628
|
+
merge(map, source = 'offer') {
|
|
629
|
+
if (map === null || typeof map !== 'object' || Array.isArray(map)) return 0;
|
|
630
|
+
let accepted = 0;
|
|
631
|
+
for (const [machineId, key] of Object.entries(map)) {
|
|
632
|
+
if (this.set(machineId, key, source)) accepted += 1;
|
|
633
|
+
}
|
|
634
|
+
return accepted;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** @returns {number} */
|
|
638
|
+
get size() {
|
|
639
|
+
return this.keys.size;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/* -------------------------------------------------------------------------- */
|
|
644
|
+
/* Primitives */
|
|
645
|
+
/* -------------------------------------------------------------------------- */
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* HKDF-SHA256, §4's `HKDF(...)`.
|
|
649
|
+
*
|
|
650
|
+
* @param {Buffer} ikm
|
|
651
|
+
* @param {string} label Bound into `info`, so two derivations over the same input key material can
|
|
652
|
+
* never produce the same output.
|
|
653
|
+
* @param {number} [length]
|
|
654
|
+
* @returns {Buffer}
|
|
655
|
+
*/
|
|
656
|
+
export function derive(ikm, label, length = KEY_BYTES) {
|
|
657
|
+
return Buffer.from(hkdfSync('sha256', ikm, Buffer.alloc(0), Buffer.from(label, 'utf8'), length));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* BLAKE2s-256, §3's handshake hash.
|
|
662
|
+
*
|
|
663
|
+
* @param {...(Buffer|string)} parts
|
|
664
|
+
* @returns {Buffer} 32 bytes.
|
|
665
|
+
*/
|
|
666
|
+
export function handshakeHash(...parts) {
|
|
667
|
+
const hash = createHash('blake2s256');
|
|
668
|
+
for (const part of parts) hash.update(part);
|
|
669
|
+
return hash.digest();
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* The prologue hash of §4: `hash(prologue —s_i.pub —s_r.pub)`.
|
|
674
|
+
*
|
|
675
|
+
* @param {string} initiatorPublic Base64.
|
|
676
|
+
* @param {string} responderPublic Base64.
|
|
677
|
+
* @returns {Buffer}
|
|
678
|
+
*/
|
|
679
|
+
export function prologueHash(initiatorPublic, responderPublic) {
|
|
680
|
+
return handshakeHash(
|
|
681
|
+
PROLOGUE,
|
|
682
|
+
decodeKey(initiatorPublic) ?? Buffer.alloc(0),
|
|
683
|
+
decodeKey(responderPublic) ?? Buffer.alloc(0),
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* The 5 bytes of session binding mixed into every handshake MAC.
|
|
689
|
+
*
|
|
690
|
+
* §4's `mac` is `AEAD(k0, nonce=0, hash(prologue —s_i.pub —s_r.pub))`, and every one of those
|
|
691
|
+
* inputs is identical for every punch between the same pair of machines: a `HELLO` captured from one
|
|
692
|
+
* punch would verify perfectly in the next. Binding the version and the session id closes that at the
|
|
693
|
+
* source, at the cost of one more `hash.update`. Deliberately *not* a field on the wire —it is
|
|
694
|
+
* recomputed by the receiver from the session it is already filtering on.
|
|
695
|
+
*
|
|
696
|
+
* @param {number} session
|
|
697
|
+
* @returns {Buffer} 5 bytes.
|
|
698
|
+
*/
|
|
699
|
+
export function sessionBinding(session) {
|
|
700
|
+
const out = Buffer.alloc(5);
|
|
701
|
+
out.writeUInt8(CRYPTO_VERSION, 0);
|
|
702
|
+
out.writeUInt32BE(session >>> 0, 1);
|
|
703
|
+
return out;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* The four keys a completed handshake yields.
|
|
708
|
+
*
|
|
709
|
+
* §4 writes `k = HKDF(ECDH(e_i,e_r) —ECDH(s_i,e_r) —ECDH(e_i,s_r))` and uses that one value for
|
|
710
|
+
* both the handshake's second MAC and the transport. One 128-byte expansion supplies four *distinct*
|
|
711
|
+
* keys instead of reusing one key for four purposes, because key reuse is the failure mode that
|
|
712
|
+
* passes every test and is found in a review years later. The `info` labels make the split explicit
|
|
713
|
+
* rather than positional, so an implementation that reordered them would produce different keys
|
|
714
|
+
* rather than silently swapped ones.
|
|
715
|
+
*
|
|
716
|
+
* @param {object} input
|
|
717
|
+
* @param {Buffer} input.ephemeralShared `ECDH(e_i, e_r)`.
|
|
718
|
+
* @param {Buffer} input.initiatorStaticShared `ECDH(s_i, e_r)`.
|
|
719
|
+
* @param {Buffer} input.responderStaticShared `ECDH(e_i, s_r)`.
|
|
720
|
+
* @returns {{macInitiator:Buffer, macResponder:Buffer, sendInitiator:Buffer, sendResponder:Buffer}}
|
|
721
|
+
*/
|
|
722
|
+
export function sessionKeys({ ephemeralShared, initiatorStaticShared, responderStaticShared }) {
|
|
723
|
+
const triple = Buffer.concat([ephemeralShared, initiatorStaticShared, responderStaticShared]);
|
|
724
|
+
const material = derive(triple, SESSION_KEY_LABEL, 4 * KEY_BYTES);
|
|
725
|
+
return {
|
|
726
|
+
macInitiator: material.subarray(0, KEY_BYTES),
|
|
727
|
+
macResponder: material.subarray(KEY_BYTES, 2 * KEY_BYTES),
|
|
728
|
+
sendInitiator: material.subarray(2 * KEY_BYTES, 3 * KEY_BYTES),
|
|
729
|
+
sendResponder: material.subarray(3 * KEY_BYTES, 4 * KEY_BYTES),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* `AEAD(key, nonce=0, message)` —the "MAC" of §4, which is really a sealed 32-byte hash.
|
|
735
|
+
*
|
|
736
|
+
* ChaCha20-Poly1305 in one shot: `key` is the key, the nonce is twelve zero bytes (as §4 writes it),
|
|
737
|
+
* and the message is the hash. The return value is the ciphertext *and* the 16-byte tag, because the
|
|
738
|
+
* ciphertext is what carries the hash across and the tag is what proves the key —there is no
|
|
739
|
+
* separate plaintext hash on the wire.
|
|
740
|
+
*
|
|
741
|
+
* @param {Buffer} key 32 bytes.
|
|
742
|
+
* @param {Buffer} message
|
|
743
|
+
* @returns {Buffer} Ciphertext followed by the tag: 48 bytes for a 32-byte message.
|
|
744
|
+
*/
|
|
745
|
+
export function sealMac(key, message) {
|
|
746
|
+
const cipher = createCipheriv('chacha20-poly1305', key, Buffer.alloc(12), { authTagLength: TAG_BYTES });
|
|
747
|
+
const ciphertext = Buffer.concat([cipher.update(message), cipher.final()]);
|
|
748
|
+
return Buffer.concat([ciphertext, cipher.getAuthTag()]);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Open one of those MACs. Returns null on any failure and never throws.
|
|
753
|
+
*
|
|
754
|
+
* @param {Buffer} key
|
|
755
|
+
* @param {Buffer} sealed Ciphertext followed by the tag.
|
|
756
|
+
* @returns {Buffer|null} The authenticated message.
|
|
757
|
+
*/
|
|
758
|
+
export function openMac(key, sealed) {
|
|
759
|
+
if (!Buffer.isBuffer(sealed) || sealed.length <= TAG_BYTES) return null;
|
|
760
|
+
try {
|
|
761
|
+
const decipher = createDecipheriv('chacha20-poly1305', key, Buffer.alloc(12), { authTagLength: TAG_BYTES });
|
|
762
|
+
decipher.setAuthTag(sealed.subarray(sealed.length - TAG_BYTES));
|
|
763
|
+
return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - TAG_BYTES)), decipher.final()]);
|
|
764
|
+
} catch {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** @param {Buffer} a @param {Buffer} b @returns {boolean} Constant-time comparison. */
|
|
770
|
+
export function bytesEqual(a, b) {
|
|
771
|
+
if (!Buffer.isBuffer(a) || !Buffer.isBuffer(b) || a.length !== b.length) return false;
|
|
772
|
+
return timingSafeEqual(a, b);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/* -------------------------------------------------------------------------- */
|
|
776
|
+
/* Handshake messages */
|
|
777
|
+
/* -------------------------------------------------------------------------- */
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* The two handshake bodies of §4, and the one refusal body, byte for byte.
|
|
781
|
+
*
|
|
782
|
+
* They ride **inside** the existing `p2p.mjs` frame kinds. A `HELLO`/`HELLO_ACK` whose body begins
|
|
783
|
+
* with {@link FRAME_MAGIC} is a handshake message; a `HELLO`/`HELLO_ACK` whose body is empty is the
|
|
784
|
+
* plain punch v0.5.x sends. That is what keeps the wire compatible in both directions without a
|
|
785
|
+
* version bump: a v0.5.x peer answers a handshake `HELLO` with its own plain `HELLO_ACK` (it never
|
|
786
|
+
* looks at the body), and this build answers a v0.5.x `HELLO` with a plain `HELLO_ACK` when it holds
|
|
787
|
+
* no key for that machine. Neither end has to be told which kind of peer it is meeting.
|
|
788
|
+
*
|
|
789
|
+
* HELLO magic|version|session u32|e[32]|idLen u8|machineId|mac[48]
|
|
790
|
+
* HELLO_ACK magic|version|session u32|e[32]|idLen u8|machineId|mac[48]
|
|
791
|
+
* REFUSE magic|version|session u32|code u8|idLen u8|machineId
|
|
792
|
+
*
|
|
793
|
+
* `mac` is 32 bytes of ciphertext plus 16 of tag (§4's AEAD-produced MAC). `REFUSE` is deliberately
|
|
794
|
+
* unauthenticated: it is sent precisely when we cannot authenticate the peer, so it can carry no
|
|
795
|
+
* proof —and it is what turns "the handshake did not complete" into a named reason on *both* ends
|
|
796
|
+
* instead of a timeout on one of them.
|
|
797
|
+
*/
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Refusal codes carried in a `REFUSE` body: one byte each, so a reason survives a round trip.
|
|
801
|
+
*
|
|
802
|
+
* `NO_PEER_KEY` and `BAD_MAC` are deliberately separate, and the separation is what makes §4's fallback
|
|
803
|
+
* clause implementable without a silent downgrade:
|
|
804
|
+
*
|
|
805
|
+
* * `NO_PEER_KEY` — we hold no key for that machine, so nothing it sent could have been verified
|
|
806
|
+
* either way. The peer learns that the handshake is impossible *for a stated reason* and may fall
|
|
807
|
+
* back to the plaintext channel it would have had without this work. That is the v0.5.x case, and
|
|
808
|
+
* it is not a downgrade: the peer never offered a key to downgrade from.
|
|
809
|
+
* * `BAD_MAC` — the peer *is* known and its MAC over its static key did not verify. That is either a
|
|
810
|
+
* stale key or an impersonation attempt, and the initiator treats it as terminal.
|
|
811
|
+
*/
|
|
812
|
+
export const REFUSE = Object.freeze({
|
|
813
|
+
UNKNOWN_PEER: 1,
|
|
814
|
+
BAD_MAC: 2,
|
|
815
|
+
REPLAY: 3,
|
|
816
|
+
NO_KEY: 4,
|
|
817
|
+
VERSION: 5,
|
|
818
|
+
/** v0.6.0: "I have a keypair, and none for you." The one refusal a peer may fall back from. */
|
|
819
|
+
NO_PEER_KEY: 6,
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
/** @type {Record<number, string>} */
|
|
823
|
+
export const REFUSE_REASON = Object.freeze({
|
|
824
|
+
[REFUSE.UNKNOWN_PEER]: CRYPTO_REASON.UNKNOWN_PEER,
|
|
825
|
+
[REFUSE.BAD_MAC]: CRYPTO_REASON.BAD_MAC,
|
|
826
|
+
[REFUSE.REPLAY]: CRYPTO_REASON.REPLAY,
|
|
827
|
+
[REFUSE.NO_KEY]: CRYPTO_REASON.NO_KEY,
|
|
828
|
+
[REFUSE.VERSION]: CRYPTO_REASON.VERSION_MISMATCH,
|
|
829
|
+
[REFUSE.NO_PEER_KEY]: CRYPTO_REASON.NO_KEY,
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Build a `HELLO` or `HELLO_ACK` body.
|
|
834
|
+
*
|
|
835
|
+
* @param {number} version
|
|
836
|
+
* @param {number} session
|
|
837
|
+
* @param {Buffer} ephemeralPublic 32 bytes.
|
|
838
|
+
* @param {string|null} machineId
|
|
839
|
+
* @param {Buffer} mac 48 bytes.
|
|
840
|
+
* @returns {Buffer}
|
|
841
|
+
*/
|
|
842
|
+
export function encodeHello(version, session, ephemeralPublic, machineId, mac) {
|
|
843
|
+
const id = machineId === null ? Buffer.alloc(0) : Buffer.from(machineId, 'utf8');
|
|
844
|
+
if (id.length > MAX_HANDSHAKE_MACHINE_ID) throw new TypeError('encodeHello: the machine id is too long');
|
|
845
|
+
const out = Buffer.alloc(HANDSHAKE_PREFIX_BYTES + id.length + MAC_BYTES);
|
|
846
|
+
out.writeUInt8(FRAME_MAGIC, 0);
|
|
847
|
+
out.writeUInt8(version, 1);
|
|
848
|
+
out.writeUInt32BE(session >>> 0, 2);
|
|
849
|
+
ephemeralPublic.copy(out, 6);
|
|
850
|
+
out.writeUInt8(id.length, 38);
|
|
851
|
+
id.copy(out, 39);
|
|
852
|
+
mac.copy(out, 39 + id.length);
|
|
853
|
+
return out;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Parse a handshake body. Null for anything that is not one, so a caller can treat "a plain punch"
|
|
858
|
+
* and "a malformed handshake" as the two different things they are.
|
|
859
|
+
*
|
|
860
|
+
* @param {Buffer} body
|
|
861
|
+
* @returns {{version:number, session:number, ephemeral:string, machineId:string|null, mac:Buffer,
|
|
862
|
+
* bytes:Buffer, headerBytes:number}|null}
|
|
863
|
+
*/
|
|
864
|
+
export function decodeHello(body) {
|
|
865
|
+
if (!Buffer.isBuffer(body) || body.length < HANDSHAKE_MIN_BYTES) return null;
|
|
866
|
+
if (body.readUInt8(0) !== FRAME_MAGIC) return null;
|
|
867
|
+
const version = body.readUInt8(1);
|
|
868
|
+
const session = body.readUInt32BE(2);
|
|
869
|
+
const ephemeral = body.subarray(6, 6 + KEY_BYTES);
|
|
870
|
+
const idLength = body.readUInt8(38);
|
|
871
|
+
if (idLength > MAX_HANDSHAKE_MACHINE_ID) return null;
|
|
872
|
+
// The length is checked rather than tolerated: a body with trailing bytes is not a handshake any
|
|
873
|
+
// build produces, and accepting one would let a peer append data the MAC does not cover.
|
|
874
|
+
if (body.length !== HANDSHAKE_PREFIX_BYTES + idLength + MAC_BYTES) return null;
|
|
875
|
+
const idEnd = 39 + idLength;
|
|
876
|
+
let machineId = null;
|
|
877
|
+
if (idLength > 0) {
|
|
878
|
+
machineId = body.subarray(39, idEnd).toString('utf8');
|
|
879
|
+
// A control byte in a machine id is not something any build produces, and letting one through
|
|
880
|
+
// would put a delimiter into a log line.
|
|
881
|
+
// eslint-disable-next-line no-control-regex -- rejecting control bytes is the point
|
|
882
|
+
if (/[\u0000-\u001f\u007f]/.test(machineId)) return null;
|
|
883
|
+
}
|
|
884
|
+
const mac = body.subarray(idEnd, idEnd + MAC_BYTES);
|
|
885
|
+
if (mac.length !== MAC_BYTES) return null;
|
|
886
|
+
return {
|
|
887
|
+
version,
|
|
888
|
+
session,
|
|
889
|
+
ephemeral: encodeKey(ephemeral),
|
|
890
|
+
machineId,
|
|
891
|
+
mac: Buffer.from(mac),
|
|
892
|
+
bytes: Buffer.from(body),
|
|
893
|
+
headerBytes: idEnd,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** @param {number} session @param {number} code @param {string|null} [machineId] @returns {Buffer} */
|
|
898
|
+
export function encodeRefuse(session, code, machineId = null) {
|
|
899
|
+
const id = (machineId === null ? Buffer.alloc(0) : Buffer.from(machineId, 'utf8')).subarray(
|
|
900
|
+
0,
|
|
901
|
+
MAX_HANDSHAKE_MACHINE_ID,
|
|
902
|
+
);
|
|
903
|
+
const out = Buffer.alloc(8 + id.length);
|
|
904
|
+
out.writeUInt8(FRAME_MAGIC, 0);
|
|
905
|
+
out.writeUInt8(CRYPTO_VERSION, 1);
|
|
906
|
+
out.writeUInt32BE(session >>> 0, 2);
|
|
907
|
+
out.writeUInt8(code, 6);
|
|
908
|
+
out.writeUInt8(id.length, 7);
|
|
909
|
+
id.copy(out, 8);
|
|
910
|
+
return out;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** @param {Buffer} body @returns {{session:number, code:number, machineId:string|null}|null} */
|
|
914
|
+
export function decodeRefuse(body) {
|
|
915
|
+
if (!Buffer.isBuffer(body) || body.length < 8) return null;
|
|
916
|
+
if (body.readUInt8(0) !== FRAME_MAGIC) return null;
|
|
917
|
+
const session = body.readUInt32BE(2);
|
|
918
|
+
const code = body.readUInt8(6);
|
|
919
|
+
const idLength = body.readUInt8(7);
|
|
920
|
+
if (body.length !== 8 + idLength) return null;
|
|
921
|
+
const machineId = idLength > 0 ? body.subarray(8, 8 + idLength).toString('utf8') : null;
|
|
922
|
+
return { session, code, machineId };
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/* -------------------------------------------------------------------------- */
|
|
926
|
+
/* Transport */
|
|
927
|
+
/* -------------------------------------------------------------------------- */
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* The 64-message sliding replay window of §4, in the shape WireGuard uses.
|
|
931
|
+
*
|
|
932
|
+
* "Highest counter seen" plus a 64-bit map of which of the 64 counters below it have arrived. A
|
|
933
|
+
* frame below `highest - 64` is too old to place and is refused; a frame above `highest` slides the
|
|
934
|
+
* window up and clears the bits it skipped; a frame inside the window is refused when its bit is
|
|
935
|
+
* already set.
|
|
936
|
+
*
|
|
937
|
+
* Bit numbering, stated once because this is where an off-by-one hides: bit `width - 1` is the counter
|
|
938
|
+
* `highest`, and a counter `d = highest - counter` behind it sits at bit `width - 1 - d`. The window
|
|
939
|
+
* therefore spans `highest - (width - 1)` … `highest` — 64 counters in a 64-bit word, the old and the
|
|
940
|
+
* new together — and the refusal test is `counter < highest - (width - 1)`, strictly below, because
|
|
941
|
+
* the counter exactly `width - 1` behind is still represented and must be accepted once.
|
|
942
|
+
*
|
|
943
|
+
* The bits are indexed from the *top* while the counters run from the bottom, so an advance slides them
|
|
944
|
+
* **right**. An earlier version slid them left, which moved every seen bit one position per frame and
|
|
945
|
+
* eventually off the end: an in-order stream decrypted happily and a replay of frame 2 was accepted as
|
|
946
|
+
* new — the exact failure this class exists to prevent, and invisible to any test that does not both
|
|
947
|
+
* replay a frame *and* keep sending in order.
|
|
948
|
+
*/
|
|
949
|
+
export class ReplayWindow {
|
|
950
|
+
/** @param {number} [size] Window width, in messages. */
|
|
951
|
+
constructor(size = 64) {
|
|
952
|
+
/** @type {number} */
|
|
953
|
+
this.size = size;
|
|
954
|
+
/** @type {bigint} Highest counter accepted, or -1n before the first frame. */
|
|
955
|
+
this.highest = -1n;
|
|
956
|
+
/**
|
|
957
|
+
* One bit per counter in the window, bit `size - 1` being `highest`.
|
|
958
|
+
*
|
|
959
|
+
* A BigInt rather than an array of booleans so the slide is a shift, which is shorter and harder
|
|
960
|
+
* to get subtly wrong.
|
|
961
|
+
*
|
|
962
|
+
* @type {bigint}
|
|
963
|
+
*/
|
|
964
|
+
this.bits = 0n;
|
|
965
|
+
/** @type {number} Counters refused as replays or as too old. */
|
|
966
|
+
this.refused = 0;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Record `counter` as seen.
|
|
971
|
+
*
|
|
972
|
+
* @param {bigint} counter
|
|
973
|
+
* @returns {boolean} False when it is a replay, or so old it cannot be placed.
|
|
974
|
+
*/
|
|
975
|
+
accept(counter) {
|
|
976
|
+
const width = BigInt(this.size);
|
|
977
|
+
const oldest = this.highest - (width - 1n);
|
|
978
|
+
if (counter < oldest) {
|
|
979
|
+
this.refused += 1;
|
|
980
|
+
return false;
|
|
981
|
+
}
|
|
982
|
+
if (counter > this.highest) {
|
|
983
|
+
// A counter above the window is new by definition — nothing above `highest` was ever recorded —
|
|
984
|
+
// and the slide carries every bit that is still inside the window. Testing a bit here instead
|
|
985
|
+
// was the bug that made an *in-order* frame look like a replay.
|
|
986
|
+
const shift = counter - this.highest;
|
|
987
|
+
this.bits = shift >= width ? 0n : this.bits >> shift;
|
|
988
|
+
this.highest = counter;
|
|
989
|
+
}
|
|
990
|
+
const bit = 1n << (width - 1n - (this.highest - counter));
|
|
991
|
+
if ((this.bits & bit) !== 0n) {
|
|
992
|
+
this.refused += 1;
|
|
993
|
+
return false;
|
|
994
|
+
}
|
|
995
|
+
this.bits |= bit;
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* One peer pair's transport keys, epochs, counters and replay window.
|
|
1002
|
+
*
|
|
1003
|
+
* DIRECTION, NOT ROLE —and this is the design decision in this file that matters most
|
|
1004
|
+
*
|
|
1005
|
+
* The four keys are derived once, per handshake, and are *directional*: `sendInitiator` encrypts
|
|
1006
|
+
* initiator→responder and `sendResponder` the other way. A channel has to know which of the two is
|
|
1007
|
+
* its own send key, and that depends on which end it was. But the node **replaces** a channel with
|
|
1008
|
+
* the better of two simultaneous punches (`dial()` keeps the observed-address one; `adoptChannel`
|
|
1009
|
+
* replaces a duplicate), and that replacement can swap which end initiated on the surviving channel.
|
|
1010
|
+
* A session that had recorded "I am the initiator" would then encrypt with the wrong direction's key
|
|
1011
|
+
* and every frame would fail its tag —a failure indistinguishable from an attack. So the session
|
|
1012
|
+
* holds **both** directional keys, and each channel carries a one-line view of which direction it
|
|
1013
|
+
* is. Replacing the channel keeps the session.
|
|
1014
|
+
*
|
|
1015
|
+
* EPOCHS, AND WHY THE REKEY IS A RATCHET
|
|
1016
|
+
*
|
|
1017
|
+
* §4 says rekey after 2^20 messages or 120 seconds "by running the handshake again". Re-running it
|
|
1018
|
+
* inside the data path would cost a round trip and a state machine. Instead the session *ratchets*:
|
|
1019
|
+
* the epoch number travels in the clear in every frame (one byte, see {@link CIPHER_HEADER_BYTES})
|
|
1020
|
+
* and both ends derive epoch `n+1` from epoch `n` with HKDF. A rekey therefore costs no round trip
|
|
1021
|
+
* and no message; the receiver derives the next key the moment it sees a frame from the next epoch.
|
|
1022
|
+
* The previous epoch's key is retained for {@link EPOCH_RETENTION_MS} so a frame in flight across the
|
|
1023
|
+
* boundary is not lost —§7's eighth acceptance test.
|
|
1024
|
+
*
|
|
1025
|
+
* The 64-bit counter is deliberately **not** reset on rekey. The nonce is
|
|
1026
|
+
* `0x00000000 —counter64le` and the replay window is over that counter, so a monotonic counter is
|
|
1027
|
+
* what keeps both correct across an epoch boundary; resetting it would make two frames in one
|
|
1028
|
+
* session share a nonce, which is the one mistake a stream cipher never recovers from.
|
|
1029
|
+
*/
|
|
1030
|
+
export class CryptoSession {
|
|
1031
|
+
/**
|
|
1032
|
+
* @param {object} options
|
|
1033
|
+
* @param {number} options.session The punch session this handshake belongs to.
|
|
1034
|
+
* @param {string|null} [options.peerMachineId] The peer, when the handshake named one.
|
|
1035
|
+
* @param {Buffer} options.sendInitiator The initiator→responder key.
|
|
1036
|
+
* @param {Buffer} options.sendResponder The responder→initiator key.
|
|
1037
|
+
* @param {number} [options.nowMs]
|
|
1038
|
+
* @param {number} [options.rekeyMessages]
|
|
1039
|
+
* @param {number} [options.rekeyMs]
|
|
1040
|
+
* @param {number} [options.retentionMs]
|
|
1041
|
+
*/
|
|
1042
|
+
constructor(options) {
|
|
1043
|
+
this.session = options.session >>> 0;
|
|
1044
|
+
/** @type {string|null} */
|
|
1045
|
+
this.peerMachineId = options.peerMachineId ?? null;
|
|
1046
|
+
this.sendInitiator = options.sendInitiator;
|
|
1047
|
+
this.sendResponder = options.sendResponder;
|
|
1048
|
+
this.nowMs = options.nowMs ?? (() => Date.now());
|
|
1049
|
+
this.rekeyMessages = options.rekeyMessages ?? REKEY_AFTER_MESSAGES;
|
|
1050
|
+
this.rekeyMs = options.rekeyMs ?? REKEY_AFTER_MS;
|
|
1051
|
+
this.retentionMs = options.retentionMs ?? EPOCH_RETENTION_MS;
|
|
1052
|
+
|
|
1053
|
+
/** @type {number} The epoch this end encrypts under. */
|
|
1054
|
+
this.sendEpoch = 0;
|
|
1055
|
+
/** @type {bigint} Monotonic across epochs; never reset. */
|
|
1056
|
+
this.sendCounter = 0n;
|
|
1057
|
+
/** @type {bigint} Messages sent in the current epoch, for the rekey threshold. */
|
|
1058
|
+
this.epochMessages = 0n;
|
|
1059
|
+
/** @type {number} When the current epoch began, for the time threshold. */
|
|
1060
|
+
this.epochStartedAt = this.nowMs();
|
|
1061
|
+
/** @type {number} Highest epoch this end has derived keys for, in either direction. */
|
|
1062
|
+
this.highestEpoch = 0;
|
|
1063
|
+
/** @type {Map<number, {initiator: Buffer, responder: Buffer, expiresAt: number}>} */
|
|
1064
|
+
this.epochs = new Map();
|
|
1065
|
+
this.epochs.set(0, {
|
|
1066
|
+
initiator: this.sendInitiator,
|
|
1067
|
+
responder: this.sendResponder,
|
|
1068
|
+
expiresAt: Number.POSITIVE_INFINITY,
|
|
1069
|
+
});
|
|
1070
|
+
/** @type {ReplayWindow} */
|
|
1071
|
+
this.replay = new ReplayWindow(64);
|
|
1072
|
+
/** @type {boolean} */
|
|
1073
|
+
this.closed = false;
|
|
1074
|
+
/** @type {{rekeys:number, sent:number, received:number, dropped:number, replay_refusals:number}} */
|
|
1075
|
+
this.stats = { rekeys: 0, sent: 0, received: 0, dropped: 0, replay_refusals: 0 };
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* The key pair for one epoch, ratcheted forward from epoch 0.
|
|
1080
|
+
*
|
|
1081
|
+
* One chain, two users: the epoch this end is *sending* under and the epoch the peer has moved to
|
|
1082
|
+
* are the same chain, which is what makes the ratchet work without a message.
|
|
1083
|
+
*
|
|
1084
|
+
* Two invariants, and both were learned from a failure that looked like an attack:
|
|
1085
|
+
*
|
|
1086
|
+
* * **The chain is never cut.** A key is usable only if every key before it in the chain is still
|
|
1087
|
+
* held, so {@link CryptoSession#retireBelow} purges a *prefix* and stops. An earlier version
|
|
1088
|
+
* deleted epoch 0 a fixed time after the first rekey and could then no longer derive epoch 2 --
|
|
1089
|
+
* `EPOCH_TOO_FAR` on a frame from an honest peer, with nothing in the reason to say that the
|
|
1090
|
+
* receiver had thrown away its own chain.
|
|
1091
|
+
* * **`highestEpoch` only ever rises.** Derivation is monotonic, so the loop continues from where
|
|
1092
|
+
* it stopped rather than walking back over keys that are gone.
|
|
1093
|
+
*
|
|
1094
|
+
* @param {number} epoch
|
|
1095
|
+
* @returns {{initiator: Buffer, responder: Buffer}|null} Null for an epoch we will not derive.
|
|
1096
|
+
*/
|
|
1097
|
+
epochKeysFor(epoch) {
|
|
1098
|
+
if (!Number.isInteger(epoch) || epoch < 0) return null;
|
|
1099
|
+
// The bound applies to how far *ahead of what this end has already derived* a request may reach,
|
|
1100
|
+
// and it is a denial-of-service bound: the epoch rides in the clear, so a forged frame could
|
|
1101
|
+
// otherwise ask for two billion HKDF steps. It must not cap this end's own send epoch — a session
|
|
1102
|
+
// that sends steadily rekeys on the message count and would otherwise stop being able to encrypt
|
|
1103
|
+
// after `MAX_EPOCH_LOOKAHEAD` epochs, which is a self-inflicted outage rather than a defence.
|
|
1104
|
+
if (epoch > this.highestEpoch + MAX_EPOCH_LOOKAHEAD) return null;
|
|
1105
|
+
if (epoch <= this.highestEpoch) return this.epochs.get(epoch) ?? null;
|
|
1106
|
+
for (let next = this.highestEpoch + 1; next <= epoch; next += 1) {
|
|
1107
|
+
const previous = this.epochs.get(next - 1);
|
|
1108
|
+
if (previous === undefined) return null;
|
|
1109
|
+
this.epochs.set(next, {
|
|
1110
|
+
initiator: derive(previous.initiator, `${SESSION_KEY_LABEL} epoch ${next}`),
|
|
1111
|
+
responder: derive(previous.responder, `${SESSION_KEY_LABEL} epoch ${next}`),
|
|
1112
|
+
expiresAt: Number.POSITIVE_INFINITY,
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
this.highestEpoch = epoch;
|
|
1116
|
+
return this.epochs.get(epoch) ?? null;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** The key this end encrypts with, given which end this channel is. */
|
|
1120
|
+
sendKey(isInitiator, epoch) {
|
|
1121
|
+
const keys = this.epochKeysFor(epoch);
|
|
1122
|
+
if (keys === null) return null;
|
|
1123
|
+
return isInitiator ? keys.initiator : keys.responder;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** The key this end decrypts the peer's frames with: the other direction's. */
|
|
1127
|
+
receiveKey(isInitiator, epoch) {
|
|
1128
|
+
const keys = this.epochKeysFor(epoch);
|
|
1129
|
+
if (keys === null) return null;
|
|
1130
|
+
return isInitiator ? keys.responder : keys.initiator;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Advance the send epoch when either threshold has passed.
|
|
1135
|
+
*
|
|
1136
|
+
* Checked on send rather than on a timer: a session that is idle for an hour and then sends one
|
|
1137
|
+
* frame does not need a rekey at all, and a timer would have performed thirty of them.
|
|
1138
|
+
*
|
|
1139
|
+
* @returns {boolean} Whether the epoch advanced.
|
|
1140
|
+
*/
|
|
1141
|
+
maybeRekey() {
|
|
1142
|
+
const due =
|
|
1143
|
+
this.epochMessages >= BigInt(this.rekeyMessages) || this.nowMs() - this.epochStartedAt >= this.rekeyMs;
|
|
1144
|
+
if (!due) return false;
|
|
1145
|
+
this.sendEpoch += 1;
|
|
1146
|
+
this.epochMessages = 0n;
|
|
1147
|
+
this.epochStartedAt = this.nowMs();
|
|
1148
|
+
this.stats.rekeys += 1;
|
|
1149
|
+
// Make sure the epoch we are about to send under exists here, and start the retention clock on
|
|
1150
|
+
// the one we have stopped sending under. See `retireBelow` for why the run stays contiguous.
|
|
1151
|
+
this.epochKeysFor(this.sendEpoch);
|
|
1152
|
+
this.retireBelow(this.sendEpoch);
|
|
1153
|
+
return true;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Give the superseded epochs a finite lifetime, and drop the ones whose time is up.
|
|
1158
|
+
*
|
|
1159
|
+
* This is §4's "the old key is retained briefly so frames in flight are not lost", and its shape is
|
|
1160
|
+
* forced by the ratchet rather than chosen:
|
|
1161
|
+
*
|
|
1162
|
+
* * **The previous epoch is always kept.** A frame in flight across a rekey carries epoch `n - 1`
|
|
1163
|
+
* while this end is at `n`, and it is exactly one behind because epochs advance one at a time.
|
|
1164
|
+
* * **No gap is ever left below a retained epoch.** Every key is derived from the one before it,
|
|
1165
|
+
* so deleting epoch 3 while keeping 4 would make epoch 5 underivable -- a permanent, silent
|
|
1166
|
+
* outage for that session. Purging therefore removes the longest expired run at the *bottom* and
|
|
1167
|
+
* stops at the first key that has to stay.
|
|
1168
|
+
*
|
|
1169
|
+
* @param {number} epoch The epoch this end now sends under.
|
|
1170
|
+
*/
|
|
1171
|
+
retireBelow(epoch) {
|
|
1172
|
+
const now = this.nowMs();
|
|
1173
|
+
for (const [number, entry] of this.epochs) {
|
|
1174
|
+
if (number >= epoch) continue;
|
|
1175
|
+
if (entry.expiresAt === Number.POSITIVE_INFINITY) entry.expiresAt = now + this.retentionMs;
|
|
1176
|
+
}
|
|
1177
|
+
// Never below the previous epoch, and never below the newest epoch the peer has reached: a peer
|
|
1178
|
+
// that rekeyed ahead of us must still be able to derive its next key here.
|
|
1179
|
+
const floor = Math.max(epoch - 1, this.highestEpoch - 1, 0);
|
|
1180
|
+
for (let number = 0; number < floor; number += 1) {
|
|
1181
|
+
const entry = this.epochs.get(number);
|
|
1182
|
+
if (entry === undefined) break;
|
|
1183
|
+
if (Number.isFinite(entry.expiresAt) && entry.expiresAt <= now) this.epochs.delete(number);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Encrypt one complete frame.
|
|
1189
|
+
*
|
|
1190
|
+
* **The frame, header included, is the unit of encryption** —and that is a correction to how this
|
|
1191
|
+
* first looked. An earlier version took the frame apart and used `plaintext.subarray(0, 5)` as the
|
|
1192
|
+
* additional data while the caller handed the *ciphertext's* header to {@link CryptoSession#decrypt};
|
|
1193
|
+
* the two never matched, so every frame failed its tag and the failure was indistinguishable from an
|
|
1194
|
+
* attack. Taking and returning a whole frame makes the AAD one value that cannot drift: both methods
|
|
1195
|
+
* are handed the same bytes, and `test/p2p-crypto.test.mjs` pins that a frame encrypted here is
|
|
1196
|
+
* decrypted by a peer that only ever sees the frame.
|
|
1197
|
+
*
|
|
1198
|
+
* @param {boolean} isInitiator Whether this end initiated the handshake.
|
|
1199
|
+
* @param {Buffer} frame The complete frame —`p2p.mjs`'s header plus body.
|
|
1200
|
+
* @returns {Buffer} The frame with its body replaced: header —cipher header —ciphertext —tag.
|
|
1201
|
+
*/
|
|
1202
|
+
encrypt(isInitiator, frame) {
|
|
1203
|
+
this.maybeRekey();
|
|
1204
|
+
const key = this.sendKey(isInitiator, this.sendEpoch);
|
|
1205
|
+
if (key === null) throw new Error('CryptoSession.encrypt: no send key for the current epoch');
|
|
1206
|
+
const counter = this.sendCounter;
|
|
1207
|
+
this.sendCounter += 1n;
|
|
1208
|
+
this.epochMessages += 1n;
|
|
1209
|
+
this.stats.sent += 1;
|
|
1210
|
+
|
|
1211
|
+
const header = frame.subarray(0, FRAME_HEADER_BYTES);
|
|
1212
|
+
const cipherHeader = Buffer.alloc(CIPHER_HEADER_BYTES);
|
|
1213
|
+
cipherHeader.writeUInt8(FRAME_MAGIC, 0);
|
|
1214
|
+
cipherHeader.writeUInt8(this.sendEpoch & 0xff, 1);
|
|
1215
|
+
cipherHeader.writeBigUInt64LE(counter, 2);
|
|
1216
|
+
|
|
1217
|
+
const cipher = createCipheriv('chacha20-poly1305', key, nonceFor(counter), { authTagLength: TAG_BYTES });
|
|
1218
|
+
// The frame's own header —kind and session —rides as additional authenticated data. That is
|
|
1219
|
+
// what stops an attacker retagging a DATA frame as a BYE, or moving a frame into another session:
|
|
1220
|
+
// the tag covers the fields the receiving channel routes on.
|
|
1221
|
+
cipher.setAAD(header);
|
|
1222
|
+
const ciphertext = Buffer.concat([cipher.update(frame.subarray(FRAME_HEADER_BYTES)), cipher.final()]);
|
|
1223
|
+
return Buffer.concat([header, cipherHeader, ciphertext, cipher.getAuthTag()]);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* Decrypt one complete frame.
|
|
1228
|
+
*
|
|
1229
|
+
* Never throws, and never returns plaintext that did not authenticate. Every refusal is named, so a
|
|
1230
|
+
* caller records *why* a frame was dropped rather than only that something was.
|
|
1231
|
+
*
|
|
1232
|
+
* @param {boolean} isInitiator Whether this end initiated the handshake.
|
|
1233
|
+
* @param {Buffer} frame The complete received frame, header included.
|
|
1234
|
+
* @returns {{ok: true, frame: Buffer, plaintext: Buffer, epoch: number}|{ok: false, reason: string}}
|
|
1235
|
+
* On success `plaintext` is the *body* that was encrypted and `frame` is the reconstructed frame.
|
|
1236
|
+
*/
|
|
1237
|
+
decrypt(isInitiator, frame) {
|
|
1238
|
+
const fail = (reason) => {
|
|
1239
|
+
this.stats.dropped += 1;
|
|
1240
|
+
return { ok: false, reason };
|
|
1241
|
+
};
|
|
1242
|
+
if (!Buffer.isBuffer(frame) || frame.length < FRAME_HEADER_BYTES + CIPHER_HEADER_BYTES + TAG_BYTES) {
|
|
1243
|
+
return fail(CRYPTO_REASON.AUTH_FAILED);
|
|
1244
|
+
}
|
|
1245
|
+
const header = frame.subarray(0, FRAME_HEADER_BYTES);
|
|
1246
|
+
const body = frame.subarray(FRAME_HEADER_BYTES);
|
|
1247
|
+
if (body.readUInt8(0) !== FRAME_MAGIC) return fail(CRYPTO_REASON.AUTH_FAILED);
|
|
1248
|
+
const epoch = body.readUInt8(1);
|
|
1249
|
+
const counter = body.readBigUInt64LE(2);
|
|
1250
|
+
|
|
1251
|
+
const known = this.epochs.get(epoch);
|
|
1252
|
+
if (known === undefined) {
|
|
1253
|
+
// A frame from an epoch we have not reached is the peer's rekey notice: derive up to it and
|
|
1254
|
+
// carry on. The ratchet is what makes that possible without a round trip, and *not* doing it
|
|
1255
|
+
// here was a permanent outage for every session that ever rekeyed -- the peer moved to epoch 1
|
|
1256
|
+
// and this end answered `EPOCH_TOO_FAR` forever.
|
|
1257
|
+
const derived = this.epochKeysFor(epoch);
|
|
1258
|
+
if (derived === null) {
|
|
1259
|
+
// "An epoch we cannot reach" and "an epoch whose key we dropped" are different facts: the
|
|
1260
|
+
// first is a frame from far in the future, the second one that arrived too late.
|
|
1261
|
+
return fail(epoch > this.highestEpoch ? CRYPTO_REASON.EPOCH_TOO_FAR : CRYPTO_REASON.STALE_EPOCH);
|
|
1262
|
+
}
|
|
1263
|
+
} else if (known.expiresAt <= this.nowMs()) {
|
|
1264
|
+
this.epochs.delete(epoch);
|
|
1265
|
+
return fail(CRYPTO_REASON.STALE_EPOCH);
|
|
1266
|
+
}
|
|
1267
|
+
const key = this.receiveKey(isInitiator, epoch);
|
|
1268
|
+
if (key === null) return fail(CRYPTO_REASON.EPOCH_TOO_FAR);
|
|
1269
|
+
|
|
1270
|
+
let plaintext;
|
|
1271
|
+
try {
|
|
1272
|
+
const decipher = createDecipheriv('chacha20-poly1305', key, nonceFor(counter), { authTagLength: TAG_BYTES });
|
|
1273
|
+
decipher.setAAD(header);
|
|
1274
|
+
decipher.setAuthTag(body.subarray(body.length - TAG_BYTES));
|
|
1275
|
+
plaintext = Buffer.concat([
|
|
1276
|
+
decipher.update(body.subarray(CIPHER_HEADER_BYTES, body.length - TAG_BYTES)),
|
|
1277
|
+
decipher.final(),
|
|
1278
|
+
]);
|
|
1279
|
+
} catch {
|
|
1280
|
+
// Authentication failed. The frame is dropped and never processed, and the tag is *not* retried
|
|
1281
|
+
// against the other epochs: an attacker who could make us try keys until one verified would
|
|
1282
|
+
// have turned a 128-bit tag into a search.
|
|
1283
|
+
return fail(CRYPTO_REASON.AUTH_FAILED);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// The replay check comes *after* authentication, and that order is deliberate: checking first
|
|
1287
|
+
// would let an unauthenticated counter fill the window and lock out genuine frames.
|
|
1288
|
+
if (!this.replay.accept(counter)) {
|
|
1289
|
+
this.stats.replay_refusals += 1;
|
|
1290
|
+
return fail(CRYPTO_REASON.REPLAYED_FRAME);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Adopt the peer's rekey without a round trip of our own: seeing a frame from epoch n+1 is the
|
|
1294
|
+
// notice that epoch n+1 exists, and the ratchet means it can be derived locally.
|
|
1295
|
+
this.epochKeysFor(epoch + 1);
|
|
1296
|
+
|
|
1297
|
+
this.stats.received += 1;
|
|
1298
|
+
return { ok: true, plaintext, frame: Buffer.concat([header, plaintext]), epoch };
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/** The nonce of §4: four zero bytes, then the 64-bit little-endian counter. */
|
|
1303
|
+
function nonceFor(counter) {
|
|
1304
|
+
const nonce = Buffer.alloc(12);
|
|
1305
|
+
nonce.writeBigUInt64LE(counter, 4);
|
|
1306
|
+
return nonce;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/* -------------------------------------------------------------------------- */
|
|
1310
|
+
/* The handshake itself */
|
|
1311
|
+
/* -------------------------------------------------------------------------- */
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* `hash(HELLO —e_r)` —the transcript `mac'` is taken over, built in exactly one place.
|
|
1315
|
+
*
|
|
1316
|
+
* §4 writes `mac' = AEAD(k0', nonce=0, hash(HELLO))`. What goes into that hash has to be identical on
|
|
1317
|
+
* both ends or the handshake fails with a MAC error that looks like an attack, so the *construction*
|
|
1318
|
+
* lives here and both callers pass raw bytes: the initiator's own `HELLO` (which it already holds)
|
|
1319
|
+
* and the responder's ephemeral public key as **32 raw bytes**, never as its base64 spelling. That
|
|
1320
|
+
* distinction is not cosmetic —an earlier draft hashed one form on one end and the other form on the
|
|
1321
|
+
* other, and the only symptom was `P2P_CRYPTO_BAD_MAC` on a handshake between two honest peers.
|
|
1322
|
+
*
|
|
1323
|
+
* @param {Buffer} helloBytes The complete `HELLO` body as sent.
|
|
1324
|
+
* @param {Buffer} responderEphemeralRaw 32 raw bytes of `e_r`.
|
|
1325
|
+
* @returns {Buffer} 32 bytes.
|
|
1326
|
+
*/
|
|
1327
|
+
export function ackTranscript(helloBytes, responderEphemeralRaw) {
|
|
1328
|
+
return handshakeHash(helloBytes, responderEphemeralRaw);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* The initiator's half of §4's handshake —one message out, one in.
|
|
1333
|
+
*
|
|
1334
|
+
* A function rather than a state machine, because the whole exchange is `hello()` then
|
|
1335
|
+
* `finish(ack)`; four states to express two steps would be ceremony. The ephemeral pair is generated
|
|
1336
|
+
* inside `hello()` and kept in the closure, so a caller that calls `hello()` again gets a *different*
|
|
1337
|
+
* `HELLO` —which is what keeps a retransmission distinguishable from a replay.
|
|
1338
|
+
*
|
|
1339
|
+
* @param {object} options
|
|
1340
|
+
* @param {DeviceKey} options.deviceKey This machine's static key.
|
|
1341
|
+
* @param {string} options.peerMachineId The machine being dialled.
|
|
1342
|
+
* @param {string} options.peerPublicKey Its static public key, from the offer or from the relay.
|
|
1343
|
+
* @param {number} options.session The punch session.
|
|
1344
|
+
* @returns {{body: Buffer, ephemeral: string, session: number, peerMachineId: string, finish: Function}}
|
|
1345
|
+
*/
|
|
1346
|
+
export function startHandshake({ deviceKey, peerMachineId, peerPublicKey, session }) {
|
|
1347
|
+
const peerRaw = decodeKey(peerPublicKey);
|
|
1348
|
+
if (peerRaw === null) {
|
|
1349
|
+
throw new TypeError('startHandshake: the peer public key must be a 32-byte base64 key');
|
|
1350
|
+
}
|
|
1351
|
+
const ephemeral = generateKeyPairSync(DEVICE_KEY_ALGO);
|
|
1352
|
+
const ephemeralPublic = rawPublicOf(ephemeral.publicKey);
|
|
1353
|
+
|
|
1354
|
+
// k0 proves possession of the static key before anything else is trusted: only a holder of s_i can
|
|
1355
|
+
// compute ECDH(s_i, s_r), and only a holder of s_r can check it.
|
|
1356
|
+
const k0 = derive(deviceKey.sharedSecret(peerRaw), STATIC_KEY_LABEL);
|
|
1357
|
+
const prologue = prologueHash(deviceKey.publicKeyBase64, peerPublicKey);
|
|
1358
|
+
const mac = sealMac(k0, handshakeHash(prologue, sessionBinding(session)));
|
|
1359
|
+
const body = encodeHello(CRYPTO_VERSION, session, ephemeralPublic, deviceKey.machineId, mac);
|
|
1360
|
+
|
|
1361
|
+
let finished = false;
|
|
1362
|
+
return {
|
|
1363
|
+
body,
|
|
1364
|
+
ephemeral: encodeKey(ephemeralPublic),
|
|
1365
|
+
session,
|
|
1366
|
+
peerMachineId,
|
|
1367
|
+
/**
|
|
1368
|
+
* Verify `HELLO_ACK` and hand back the transport session.
|
|
1369
|
+
*
|
|
1370
|
+
* @param {Buffer|null} ackBody
|
|
1371
|
+
* @returns {{ok: true, session: CryptoSession, amInitiator: true}|{ok: false, reason: string}}
|
|
1372
|
+
*/
|
|
1373
|
+
finish(ackBody) {
|
|
1374
|
+
if (finished) return { ok: false, reason: CRYPTO_REASON.MALFORMED };
|
|
1375
|
+
finished = true;
|
|
1376
|
+
const ack = decodeHello(ackBody ?? Buffer.alloc(0));
|
|
1377
|
+
if (ack === null) return { ok: false, reason: CRYPTO_REASON.MALFORMED };
|
|
1378
|
+
if (ack.version !== CRYPTO_VERSION) return { ok: false, reason: CRYPTO_REASON.VERSION_MISMATCH };
|
|
1379
|
+
if (ack.session !== (session >>> 0)) return { ok: false, reason: CRYPTO_REASON.MALFORMED };
|
|
1380
|
+
if (ack.machineId !== null && ack.machineId !== peerMachineId) {
|
|
1381
|
+
// The responder named somebody else: not a wrong key, a peer that is not the one the offer
|
|
1382
|
+
// introduced us to. Saying that is more useful than "bad mac".
|
|
1383
|
+
return { ok: false, reason: CRYPTO_REASON.UNKNOWN_PEER };
|
|
1384
|
+
}
|
|
1385
|
+
const peerEphemeral = decodeKey(ack.ephemeral);
|
|
1386
|
+
if (peerEphemeral === null) return { ok: false, reason: CRYPTO_REASON.MALFORMED };
|
|
1387
|
+
|
|
1388
|
+
// mac' = AEAD(k0, nonce=0, hash(HELLO —e_r)): the same k0, over the transcript so far.
|
|
1389
|
+
const expected = sealMac(k0, ackTranscript(body, peerEphemeral));
|
|
1390
|
+
if (!bytesEqual(ack.mac, expected)) {
|
|
1391
|
+
// Either the peer does not hold s_r (so it is not who the offer said) or the transcript was
|
|
1392
|
+
// tampered with. One reason code, named, and no fallback to plaintext.
|
|
1393
|
+
return { ok: false, reason: CRYPTO_REASON.BAD_MAC };
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
const keys = sessionKeys({
|
|
1397
|
+
ephemeralShared: diffieHellman({
|
|
1398
|
+
privateKey: ephemeral.privateKey,
|
|
1399
|
+
publicKey: publicKeyFromRaw(peerEphemeral),
|
|
1400
|
+
}),
|
|
1401
|
+
initiatorStaticShared: deviceKey.sharedSecret(peerEphemeral),
|
|
1402
|
+
responderStaticShared: diffieHellman({
|
|
1403
|
+
privateKey: ephemeral.privateKey,
|
|
1404
|
+
publicKey: publicKeyFromRaw(peerRaw),
|
|
1405
|
+
}),
|
|
1406
|
+
});
|
|
1407
|
+
return {
|
|
1408
|
+
ok: true,
|
|
1409
|
+
session: new CryptoSession({
|
|
1410
|
+
session,
|
|
1411
|
+
peerMachineId,
|
|
1412
|
+
sendInitiator: keys.sendInitiator,
|
|
1413
|
+
sendResponder: keys.sendResponder,
|
|
1414
|
+
}),
|
|
1415
|
+
amInitiator: true,
|
|
1416
|
+
};
|
|
1417
|
+
},
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* The responder's half: verify a `HELLO`, answer it, and derive the same session keys.
|
|
1423
|
+
*
|
|
1424
|
+
* @param {object} options
|
|
1425
|
+
* @param {DeviceKey} options.deviceKey This machine's static key.
|
|
1426
|
+
* @param {Buffer} options.body The received handshake body.
|
|
1427
|
+
* @param {Map<string,string>|PeerKeyStore} options.peerKeys Machine id ?static public key.
|
|
1428
|
+
* @param {NonceCache|null} [options.seen] Replay tracking, owned by the caller because it has to
|
|
1429
|
+
* outlive one handshake.
|
|
1430
|
+
* @returns {{ok:true, body:Buffer, session:number, machineId:string|null, crypto:CryptoSession,
|
|
1431
|
+
* amInitiator:false}|{ok:false, reason:string, refuse:Buffer|null}}
|
|
1432
|
+
*/
|
|
1433
|
+
export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
|
|
1434
|
+
const hello = decodeHello(body);
|
|
1435
|
+
if (hello === null) return { ok: false, reason: CRYPTO_REASON.MALFORMED, refuse: null };
|
|
1436
|
+
if (hello.version !== CRYPTO_VERSION) {
|
|
1437
|
+
return {
|
|
1438
|
+
ok: false,
|
|
1439
|
+
reason: CRYPTO_REASON.VERSION_MISMATCH,
|
|
1440
|
+
refuse: encodeRefuse(hello.session, REFUSE.VERSION, deviceKey.machineId),
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
const initiatorKey =
|
|
1445
|
+
hello.machineId === null
|
|
1446
|
+
? null
|
|
1447
|
+
: peerKeys instanceof PeerKeyStore
|
|
1448
|
+
? peerKeys.get(hello.machineId)
|
|
1449
|
+
: (peerKeys.get(hello.machineId) ?? null);
|
|
1450
|
+
|
|
1451
|
+
if (initiatorKey === null) {
|
|
1452
|
+
// Nothing this peer says can be verified, and we will not guess. The refusal is named and
|
|
1453
|
+
// unauthenticated — it proves nothing, and it is the only honest answer available. It says
|
|
1454
|
+
// `NO_PEER_KEY` rather than `UNKNOWN_PEER` because that is what the initiator needs to know: a
|
|
1455
|
+
// handshake is impossible here, so it may use the plaintext channel it would have had before this
|
|
1456
|
+
// work rather than waiting out a timeout.
|
|
1457
|
+
//
|
|
1458
|
+
// This branch has a second, ordinary caller: a handshake from *our own* machine id, which is what
|
|
1459
|
+
// a task the relay leased back to the dispatching machine produces. We hold no peer key for
|
|
1460
|
+
// ourselves, so it lands here, and the peer — us — falls back to the plaintext channel a
|
|
1461
|
+
// self-dial has always used. That is the right answer rather than an oversight: a self-connection
|
|
1462
|
+
// has no path to protect and must not fail, or a machine would lose the task it dispatched to
|
|
1463
|
+
// itself.
|
|
1464
|
+
return {
|
|
1465
|
+
ok: false,
|
|
1466
|
+
reason: CRYPTO_REASON.UNKNOWN_PEER,
|
|
1467
|
+
refuse: encodeRefuse(hello.session, REFUSE.NO_PEER_KEY, deviceKey.machineId),
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
const initiatorRaw = decodeKey(initiatorKey);
|
|
1471
|
+
if (initiatorRaw === null) {
|
|
1472
|
+
return {
|
|
1473
|
+
ok: false,
|
|
1474
|
+
reason: CRYPTO_REASON.UNKNOWN_PEER,
|
|
1475
|
+
refuse: encodeRefuse(hello.session, REFUSE.UNKNOWN_PEER, deviceKey.machineId),
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
const k0 = derive(deviceKey.sharedSecret(initiatorRaw), STATIC_KEY_LABEL);
|
|
1480
|
+
const expectedMac = sealMac(
|
|
1481
|
+
k0,
|
|
1482
|
+
handshakeHash(prologueHash(initiatorKey, deviceKey.publicKeyBase64), sessionBinding(hello.session)),
|
|
1483
|
+
);
|
|
1484
|
+
if (!bytesEqual(hello.mac, expectedMac)) {
|
|
1485
|
+
// The peer is known, and its mac is wrong: it does not hold the private key we have on file for
|
|
1486
|
+
// it. A distinct code from UNKNOWN_PEER because the responses differ —one means "refresh that
|
|
1487
|
+
// machine's key", the other means "somebody is guessing at names".
|
|
1488
|
+
return {
|
|
1489
|
+
ok: false,
|
|
1490
|
+
reason: CRYPTO_REASON.BAD_MAC,
|
|
1491
|
+
refuse: encodeRefuse(hello.session, REFUSE.BAD_MAC, deviceKey.machineId),
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const nonceKey = `${hello.machineId}#${hello.session}#${hello.ephemeral}`;
|
|
1496
|
+
if (seen !== null && !seen.accept(nonceKey)) {
|
|
1497
|
+
// A replayed HELLO. What it *cannot* do is produce this session's key: the answer below carries a
|
|
1498
|
+
// fresh responder ephemeral, so the session key differs from the one the captured HELLO would
|
|
1499
|
+
// have produced, and the original initiator (which holds e_i's private half) completes a *second*
|
|
1500
|
+
// session rather than the attacker learning anything. The refusal is recorded, and the identical
|
|
1501
|
+
// answer is re-sent when there was one —so a retransmission whose ACK was lost still gets its
|
|
1502
|
+
// ACK, and no legitimate peer is failed for our own lost datagram.
|
|
1503
|
+
const cached = seen.replayBytes(nonceKey);
|
|
1504
|
+
return {
|
|
1505
|
+
ok: false,
|
|
1506
|
+
reason: CRYPTO_REASON.REPLAY,
|
|
1507
|
+
refuse: cached.length > 0 ? cached : null,
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
const ephemeral = generateKeyPairSync(DEVICE_KEY_ALGO);
|
|
1512
|
+
const ephemeralPublic = rawPublicOf(ephemeral.publicKey);
|
|
1513
|
+
const ackBody = encodeHello(
|
|
1514
|
+
CRYPTO_VERSION,
|
|
1515
|
+
hello.session,
|
|
1516
|
+
ephemeralPublic,
|
|
1517
|
+
deviceKey.machineId,
|
|
1518
|
+
// mac' = AEAD(k0, nonce=0, hash(HELLO —e_r)), the transcript so far.
|
|
1519
|
+
sealMac(k0, ackTranscript(hello.bytes, ephemeralPublic)),
|
|
1520
|
+
);
|
|
1521
|
+
|
|
1522
|
+
const keys = sessionKeys({
|
|
1523
|
+
ephemeralShared: diffieHellman({
|
|
1524
|
+
privateKey: ephemeral.privateKey,
|
|
1525
|
+
publicKey: publicKeyFromRaw(decodeKey(hello.ephemeral) ?? Buffer.alloc(KEY_BYTES)),
|
|
1526
|
+
}),
|
|
1527
|
+
initiatorStaticShared: diffieHellman({
|
|
1528
|
+
privateKey: ephemeral.privateKey,
|
|
1529
|
+
publicKey: publicKeyFromRaw(initiatorRaw),
|
|
1530
|
+
}),
|
|
1531
|
+
responderStaticShared: deviceKey.sharedSecret(decodeKey(hello.ephemeral) ?? Buffer.alloc(KEY_BYTES)),
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
if (seen !== null) seen.note(nonceKey, ackBody);
|
|
1535
|
+
|
|
1536
|
+
return {
|
|
1537
|
+
ok: true,
|
|
1538
|
+
body: ackBody,
|
|
1539
|
+
session: hello.session,
|
|
1540
|
+
machineId: hello.machineId,
|
|
1541
|
+
amInitiator: false,
|
|
1542
|
+
crypto: new CryptoSession({
|
|
1543
|
+
session: hello.session,
|
|
1544
|
+
peerMachineId: hello.machineId,
|
|
1545
|
+
sendInitiator: keys.sendInitiator,
|
|
1546
|
+
sendResponder: keys.sendResponder,
|
|
1547
|
+
}),
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/**
|
|
1552
|
+
* Replay tracking for handshake messages, owned by whoever runs the accept loop.
|
|
1553
|
+
*
|
|
1554
|
+
* A HELLO is identified by `(machine id, session, initiator ephemeral)`. Bounded twice over —by TTL
|
|
1555
|
+
* and by count —because a peer that can make the responder's memory grow by sending handshakes is
|
|
1556
|
+
* the same denial of service in a new costume. This is the one place where `mac1`'s absence is felt
|
|
1557
|
+
* (see {@link HANDSHAKE_MAC_FIELDS_OMITTED}); the bound is what stands in for it.
|
|
1558
|
+
*/
|
|
1559
|
+
export class NonceCache {
|
|
1560
|
+
/** @param {{ttlMs?: number, max?: number, nowMs?: () => number}} [options] */
|
|
1561
|
+
constructor(options = {}) {
|
|
1562
|
+
this.ttlMs = options.ttlMs ?? 60_000;
|
|
1563
|
+
this.max = options.max ?? 512;
|
|
1564
|
+
this.nowMs = options.nowMs ?? (() => Date.now());
|
|
1565
|
+
/** @type {Map<string, {at: number, bytes: Buffer|null}>} */
|
|
1566
|
+
this.entries = new Map();
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* Record `key`. False when it was already present and still fresh.
|
|
1571
|
+
*
|
|
1572
|
+
* @param {string} key
|
|
1573
|
+
* @returns {boolean}
|
|
1574
|
+
*/
|
|
1575
|
+
accept(key) {
|
|
1576
|
+
this.sweep();
|
|
1577
|
+
if (this.entries.has(key)) return false;
|
|
1578
|
+
this.entries.set(key, { at: this.nowMs(), bytes: null });
|
|
1579
|
+
return true;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* Remember the answer that was sent for `key`.
|
|
1584
|
+
*
|
|
1585
|
+
* The timestamp is deliberately *not* refreshed: the entry's lifetime is measured from the first
|
|
1586
|
+
* sighting, so re-noting cannot keep a replay entry alive forever.
|
|
1587
|
+
*
|
|
1588
|
+
* @param {string} key
|
|
1589
|
+
* @param {Buffer} bytes
|
|
1590
|
+
*/
|
|
1591
|
+
note(key, bytes) {
|
|
1592
|
+
const existing = this.entries.get(key);
|
|
1593
|
+
const at = existing === undefined ? this.nowMs() : existing.at;
|
|
1594
|
+
this.entries.set(key, { at, bytes: Buffer.from(bytes) });
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/** @param {string} key @returns {Buffer} The answer already sent for `key`, or an empty buffer. */
|
|
1598
|
+
replayBytes(key) {
|
|
1599
|
+
const entry = this.entries.get(key);
|
|
1600
|
+
return entry?.bytes ?? Buffer.alloc(0);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
sweep() {
|
|
1604
|
+
const now = this.nowMs();
|
|
1605
|
+
for (const [key, entry] of [...this.entries]) {
|
|
1606
|
+
if (now - entry.at > this.ttlMs) this.entries.delete(key);
|
|
1607
|
+
}
|
|
1608
|
+
while (this.entries.size > this.max) {
|
|
1609
|
+
const oldest = this.entries.keys().next().value;
|
|
1610
|
+
this.entries.delete(oldest);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/** @returns {number} */
|
|
1615
|
+
get size() {
|
|
1616
|
+
return this.entries.size;
|
|
1617
|
+
}
|
|
1618
|
+
}
|