centralu 0.1.0-beta.45 → 0.1.0-beta.46

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.
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * The host `centralu serve` runs: how it is started, and talking to a running one over its own socket
3
- * (a hello with the token, a proof first where the host gives one, `host.stop`).
3
+ * (a proof, then a hello with the token, `host.stop`).
4
4
  */
5
5
  import { createHmac, randomBytes } from 'node:crypto'
6
6
  import { existsSync, readFileSync } from 'node:fs'
7
7
  import { connect } from 'node:net'
8
8
  import { dirname, join } from 'node:path'
9
+ import { LINK_NONCE_BYTES, signedHostKey } from './serve-keys.mjs'
9
10
 
10
11
  /**
11
12
  * The protocol a host folder speaks, from the `bundle-info.json` the bundler writes next to
@@ -34,20 +35,26 @@ export function protocolVersionOf(entry) {
34
35
  * `install` (additive, the line stays `v: 1`) says what runs this command (`installInfo`); `detach`
35
36
  * is added by `--detach` only.
36
37
  */
37
- export function connectionLine({ port, token, credential, limited, others, version, protocolVersion, dataDir, hostRunning, install, autostart, detach, proof, stopped }) {
38
+ export function connectionLine({ port, token, credential, limited, others, version, protocolVersion, dataDir, hostRunning, install, autostart, detach, stopped, hostKey, keyId }) {
38
39
  /*
39
- * The options this serve takes that an older one refuses (#1223): a hub adds `--from <its name>` to `--detach` only
40
- * when the line says so, since an older serve answers an option it does not know with an error. `window`: the
40
+ * The options this serve takes that an older one refuses (#1223): a hub up to 0.1.0-beta.45's adds `--from <its name>`
41
+ * to `--detach` only when the line says so, since an older serve answers an option it does not know with an error. `window`: the
41
42
  * desktop window on Windows uses this serve's host through `--window` only when it is listed (#1223 review)
42
43
  */
43
44
  /*
44
45
  * `pair` (#1405): `--connection --pair <label>` hands out a full credential of its own; without it the line carries the
45
46
  * limited credential only
46
47
  */
47
- const takes = ['from', 'window', 'pair']
48
+ /*
49
+ * `pairKey` (#1711, docs/plans/key-pairing.md §4.2): `--pair-key` made a key pairing, said only on a line that is one
50
+ * (`hostKey` and `keyId`, and no `token`). A line from `--pair-key` without it is a verifier pairing
51
+ */
52
+ const takes = ['from', 'window', 'pair', ...(hostKey && keyId ? ['pairKey'] : [])]
48
53
  return JSON.stringify({
49
54
  v: 1,
50
55
  port,
56
+ // The key the running host signs with, and the hub's key entry it signs for (#1711); never with a credential
57
+ ...(hostKey && keyId ? { hostKey, keyId } : {}),
51
58
  // A full credential only to `--pair` (#1405), with its id for the proof; an older serve always printed its token
52
59
  ...(token ? { token } : {}),
53
60
  ...(credential ? { credential } : {}),
@@ -62,8 +69,8 @@ export function connectionLine({ port, token, credential, limited, others, versi
62
69
  ...(install ? { install } : {}),
63
70
  ...(autostart ? { autostart } : {}),
64
71
  ...(detach ? { detach } : {}),
65
- // The running host proves it holds the token before the hub sends it (#838); absent: the hub sends hello as before
66
- ...(proof ? { proof: true } : {}),
72
+ // The running host proves it holds the token before the hub sends it (#838): every host from 0.1.0-beta.45 on does
73
+ proof: true,
67
74
  // The person stopped the host here, and when (#1143, `readStopped`); absent: an older serve, or no such stop
68
75
  ...(stopped ? { stopped } : {}),
69
76
  takes,
@@ -108,12 +115,10 @@ function parseStartedFrom(raw) {
108
115
  /**
109
116
  * The host's environment.
110
117
  *
111
- * - The token is in neither: arguments are readable by every user on the machine (`ps`), and a
118
+ * - The key is in neither: arguments are readable by every user on the machine (`ps`), and a
112
119
  * process's start environment by every process of its user for as long as it runs (`ps eww`,
113
- * #502). `CC_HOST_TOKEN_ON_STDIN=1` tells the host it comes as the first line on its stdin
114
- * (`tokenLine`, #538), which is a pipe to this launcher anyway (`--watch-parent`). A host bundle
115
- * from before #538 reads only `CC_HOST_TOKEN`: given `legacyToken` (`takesTokenOnStdin` said no),
116
- * it gets the token there, as before.
120
+ * #502). `CC_HOST_TOKEN_ON_STDIN=1` tells the host its key comes as the first line on its stdin
121
+ * (`keyLine`, #538), which is a pipe to this launcher anyway (`--watch-parent`).
117
122
  * - `CC_HOST_SOURCE` is the record `hello_ok.build` is made from (keeper-link.ts); without it a
118
123
  * client could not see which version the host it reached is.
119
124
  * - Headless: no `DISPLAY` / `WAYLAND_DISPLAY`. Nothing the host or an agent starts gets a screen
@@ -123,12 +128,12 @@ function parseStartedFrom(raw) {
123
128
  * `CENTRALU_MANAGED`, which is the launcher's business and would otherwise reach every agent.
124
129
  * - `CC_SERVE=1` lets the host take `host.stop` (`--stop`); a host the app runs refuses it.
125
130
  */
126
- export function hostEnv(env, { dataDir, version, hostDir, legacyToken, from = null, serveState = null }) {
131
+ export function hostEnv(env, { dataDir, version, hostDir, from = null, serveState = null }) {
127
132
  const out = { ...env }
128
133
  for (const k of ['DISPLAY', 'WAYLAND_DISPLAY', 'CC_KEEPER', 'CC_FRONT_DOOR', 'CC_DEV', 'CC_HOST_TOKEN', 'CC_HOST_TOKEN_ON_STDIN', 'CC_HOST_SOURCE', 'CC_SERVE_STATE', 'CENTRALU_MANAGED', 'CENTRALU_STARTED_FROM', 'CC_SERVE_FROM', 'CC_STARTED_BY']) delete out[k]
129
134
  /*
130
- * A host that checks full credentials against serve.json's verifiers (#1405, `takesVerifiers`): the file it reads at
131
- * each hello. A path, not a secret: the file is the user's own, 0600. Its key comes on stdin (`keyLine`)
135
+ * The host checks full credentials against serve.json's verifiers (#1405): the file it reads at each hello. A path,
136
+ * not a secret: the file is the user's own, 0600. Its key comes on stdin (`keyLine`)
132
137
  */
133
138
  if (serveState) out.CC_SERVE_STATE = serveState
134
139
  // Who asked for this host, for its host.lock (#1223): a second host on the folder names it when refused
@@ -136,62 +141,19 @@ export function hostEnv(env, { dataDir, version, hostDir, legacyToken, from = nu
136
141
  out.CC_DATA_DIR = dataDir
137
142
  // The host `host.stop` may end (`centralu serve --stop`): nothing else stops it in order on Windows
138
143
  out.CC_SERVE = '1'
139
- if (legacyToken === undefined) out.CC_HOST_TOKEN_ON_STDIN = '1'
140
- else out.CC_HOST_TOKEN = legacyToken
144
+ out.CC_HOST_TOKEN_ON_STDIN = '1'
141
145
  out.CC_HOST_SOURCE = JSON.stringify({ version, bundlePath: hostDir })
142
146
  return out
143
147
  }
144
148
 
145
149
  /**
146
- * Whether the host at `entry` reads its token from stdin (#538): a bundle says so in `bundle-info.json`
147
- * (`tokenOnStdin`), as the keeper reads it; a host run from source always does. `CENTRALU_HOST_ENTRY` can point at an
148
- * older bundle, which would otherwise get no token and refuse every hello.
149
- */
150
- export function takesTokenOnStdin(entry) {
151
- try {
152
- return JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).tokenOnStdin === 1
153
- } catch {
154
- // not a bundle: the source, which reads it
155
- }
156
- return !existsSync(join(dirname(entry), 'bundle-info.json'))
157
- }
158
-
159
- /** The first line on the host's stdin: the token (#538, the host's `readHandedToken`) */
160
- export function tokenLine(token) {
161
- return `${JSON.stringify({ token })}\n`
162
- }
163
-
164
- /**
165
- * The first line on the stdin of a host that takes verifiers (#1405): its key, from which it derives what it hands out.
166
- * Not a full credential: the host never takes it as one (`CC_SERVE_STATE` says where the full ones' verifiers are)
150
+ * The first line on the host's stdin (#538, #1405; the host's `readHandedToken`): its key, from which it derives what it
151
+ * hands out. Not a full credential: the host never takes it as one (`CC_SERVE_STATE` says where the full ones' verifiers are)
167
152
  */
168
153
  export function keyLine(key) {
169
154
  return `${JSON.stringify({ key })}\n`
170
155
  }
171
156
 
172
- /**
173
- * Whether the host at `entry` checks full credentials against serve.json's verifiers (#1405): a bundle says so in
174
- * `bundle-info.json` (`verifiers`), a host run from source has `lifecycle/serve-credentials.ts`. An older one knows a
175
- * single token, which this launcher then keeps for it (`legacyHostTokenOf`).
176
- */
177
- export function takesVerifiers(entry) {
178
- try {
179
- return JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).verifiers === 1
180
- } catch {
181
- // not a bundle; try the source below
182
- }
183
- return existsSync(join(dirname(entry), 'lifecycle', 'serve-credentials.ts'))
184
- }
185
-
186
- /**
187
- * What this launcher says hello with to its own host, and what that host proves with to it: the limited credential
188
- * and the key for a host that takes verifiers, the one token an older host knows otherwise (#1405).
189
- */
190
- export function ownCredentials(state, entry) {
191
- if (takesVerifiers(entry) || !state.legacyHostToken) return { token: state.token, proofKey: state.proofKey }
192
- return { token: state.legacyHostToken, proofKey: state.legacyHostToken }
193
- }
194
-
195
157
  /**
196
158
  * How to start the host.
197
159
  *
@@ -249,17 +211,82 @@ function mismatchProtocolOf(error) {
249
211
  }
250
212
 
251
213
  /**
252
- * Whether the installed host answers a `prove` (#838): a bundle says so in `bundle-info.json` (`hostProof`), a host run
253
- * from source has `transport/host-proof.ts`. The connection line's `proof` is this, not a live answer: a program another
254
- * account put on the port could otherwise answer the probe so as to make the line say no, and the hub send the token.
214
+ * Whether the host at `entry` takes a hub's key hello (#1711): a bundle says so in `bundle-info.json` (`keyPairing`), a
215
+ * host run from source has `transport/link-proof.ts`. `--pair-key` reports a key derived from serve.json only for a host
216
+ * started next that will sign with it (docs/plans/key-pairing.md §4.2)
255
217
  */
256
- export function hostProvesOf(entry) {
218
+ export function takesKeyHellos(entry) {
257
219
  try {
258
- return JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).hostProof === 1
220
+ return JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).keyPairing === 1
259
221
  } catch {
260
222
  // not a bundle; try the source below
261
223
  }
262
- return existsSync(join(dirname(entry), 'transport', 'host-proof.ts'))
224
+ return existsSync(join(dirname(entry), 'transport', 'link-proof.ts'))
225
+ }
226
+
227
+ /**
228
+ * Asks the host on `port` to sign for the key entry `keyId` (#1711, docs/plans/key-pairing.md §4.2 step 3), as a hub
229
+ * would: a `prove` naming it, with a nonce of this launcher's, and the answer's signature checked against the key it
230
+ * carries, the hub's key `hubKey` and the address `127.0.0.1:<port>`. No hello follows. One of:
231
+ *
232
+ * - `{ outcome: 'signed', hostKey }`: the running host signs, with `hostKey` (after `--rotate-token`, still its old one);
233
+ * - `{ outcome: 'unsigned' }`: a proof with no signature at all, from a host from before key pairing still running;
234
+ * - `{ outcome: 'none' }`: the connection was refused, so nothing listens there;
235
+ * - `{ outcome: 'unknown' }`: anything else (no answer within `timeoutMs`, a socket closed before a proof, a proof that is
236
+ * malformed or does not verify, no WebSocket in this Node), which pairs as a host that does not sign does.
237
+ */
238
+ export async function probeKeyProof({ port, keyId, hubKey, timeoutMs = 2000, WebSocketImpl = globalThis.WebSocket, connectTo = connect }) {
239
+ // Refused at once: nothing listens. Told apart from a socket that opens and says nothing, which is not known to be nothing
240
+ const reach = await new Promise((resolve) => {
241
+ const s = connectTo({ host: '127.0.0.1', port })
242
+ const done = (r) => {
243
+ s.destroy()
244
+ resolve(r)
245
+ }
246
+ s.setTimeout(timeoutMs, () => done('unknown'))
247
+ s.once('connect', () => done('open'))
248
+ s.once('error', (e) => done(e?.code === 'ECONNREFUSED' ? 'none' : 'unknown'))
249
+ })
250
+ if (reach !== 'open') return { outcome: reach }
251
+ if (typeof WebSocketImpl !== 'function') return { outcome: 'unknown' }
252
+ const nonce = randomBytes(LINK_NONCE_BYTES).toString('base64url')
253
+ return new Promise((resolve) => {
254
+ let ws = null
255
+ let settled = false
256
+ const done = (r) => {
257
+ if (settled) return
258
+ settled = true
259
+ clearTimeout(timer)
260
+ try {
261
+ ws?.close()
262
+ } catch {
263
+ // already closed
264
+ }
265
+ resolve(r)
266
+ }
267
+ const timer = setTimeout(() => done({ outcome: 'unknown' }), timeoutMs)
268
+ try {
269
+ ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
270
+ } catch {
271
+ return done({ outcome: 'unknown' })
272
+ }
273
+ ws.onopen = () => ws.send(JSON.stringify({ kind: 'prove', nonce, key: keyId }))
274
+ ws.onmessage = (ev) => {
275
+ let m = null
276
+ try {
277
+ m = JSON.parse(String(ev.data))
278
+ } catch {
279
+ // not a frame
280
+ }
281
+ const hostKey = signedHostKey(m, { hubNonce: nonce, hubKey, keyId, port })
282
+ if (hostKey) return done({ outcome: 'signed', hostKey })
283
+ // A proof without any of the three fields: a host that does not sign. One with some of them and no valid signature is not known
284
+ const unsigned = m?.kind === 'proof' && m.nonce === nonce && m.hostNonce === undefined && m.hostKey === undefined && m.sig === undefined
285
+ done({ outcome: unsigned ? 'unsigned' : 'unknown' })
286
+ }
287
+ ws.onerror = () => done({ outcome: 'unknown' })
288
+ ws.onclose = () => done({ outcome: 'unknown' })
289
+ })
263
290
  }
264
291
 
265
292
  /** The answer a host holding `token` gives to a `prove` of `nonce` (the host's `hostProof`) */
@@ -268,20 +295,19 @@ function proofOf(token, nonce) {
268
295
  }
269
296
 
270
297
  /**
271
- * Opens a socket to the host and says hello with the token, the way `probeHost` and `askHostToStop` do. With `prove`
272
- * (the installed host can, `hostProvesOf`), the host first proves it holds the token (#838), and the token goes out only
273
- * then: whatever else listens on the port (another account's program, while the host is down) never sees it.
298
+ * Opens a socket to the host and says hello with the token, the way `probeHost` and `askHostToStop` do. The host first
299
+ * proves it holds the token (#838), and the token goes out only then: whatever else listens on the port (another
300
+ * account's program, while the host is down) never sees it.
274
301
  * `on.message(m, ws)` gets every frame after the hello; `on.unproven()` runs when the proof did not come, `on.gone()`
275
302
  * when the socket failed or closed.
276
303
  */
277
- function helloSocket({ port, token, proofKey, protocolVersion, prove, WebSocketImpl, on }) {
304
+ function helloSocket({ port, token, proofKey, protocolVersion, WebSocketImpl, on }) {
278
305
  const ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
279
- const nonce = prove ? randomBytes(24).toString('base64url') : null
306
+ const nonce = randomBytes(24).toString('base64url')
280
307
  // Proving only once the socket is open: a port where nothing listens is gone, not unproven
281
308
  let proving = false
282
309
  const hello = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
283
310
  ws.onopen = () => {
284
- if (!prove) return hello()
285
311
  proving = true
286
312
  ws.send(JSON.stringify({ kind: 'prove', nonce }))
287
313
  }
@@ -309,17 +335,17 @@ function helloSocket({ port, token, proofKey, protocolVersion, prove, WebSocketI
309
335
  * Whether a host that holds this token answers on the port: a real hello, not a TCP connect, so a
310
336
  * stranger's program on the same port does not count, and the answer carries the running host's
311
337
  * own protocol and version (which differ from the installed package's after an `npm i -g` the
312
- * host was not restarted for). With `prove` the host proves it holds the token first (#838).
338
+ * host was not restarted for). The host proves it holds the token first (#838).
313
339
  *
314
340
  * `{ running, protocolVersion?, version?, unproven? }`. `unproven`: something answered on the port but did not prove
315
- * it holds the token, so it was not sent: another program, or a host from before #838 still running after an update
341
+ * it holds the token, so it was not sent: another program, or a host still on its old key after `--rotate-token`
316
342
  * (`heldHere` tells those apart by the data folder's lock). Node 22.4+ has a global WebSocket; on an older 22 this falls
317
343
  * back to a TCP connect and reports only `running`.
318
344
  */
319
- export function probeHost({ port, token, proofKey, protocolVersion, prove = false, timeoutMs = 2000, WebSocketImpl = globalThis.WebSocket }) {
345
+ export function probeHost({ port, token, proofKey, protocolVersion, timeoutMs = 2000, WebSocketImpl = globalThis.WebSocket }) {
320
346
  if (typeof WebSocketImpl !== 'function') return tcpProbe(port, timeoutMs)
321
347
  return helloExchange({
322
- port, token, proofKey, protocolVersion, prove, WebSocketImpl, timeoutMs,
348
+ port, token, proofKey, protocolVersion, WebSocketImpl, timeoutMs,
323
349
  timedOut: { running: false },
324
350
  failed: () => ({ running: false }),
325
351
  on: (done) => ({
@@ -391,10 +417,10 @@ const HOST_LOGONS = ['interactive', 'network', 'batch', 'service']
391
417
  * One call to a running host (`method` with `params`), as `askHostToStop` makes it: a hello with the token, then the
392
418
  * call. Its result, or null when it did not answer or refused. For `host.handover` (#1224)
393
419
  */
394
- export function askHostRpc({ port, token, proofKey, protocolVersion, prove = false, method, params = {}, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
420
+ export function askHostRpc({ port, token, proofKey, protocolVersion, method, params = {}, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
395
421
  if (typeof WebSocketImpl !== 'function') return Promise.resolve(null)
396
422
  return helloExchange({
397
- port, token, proofKey, protocolVersion, prove, WebSocketImpl, timeoutMs,
423
+ port, token, proofKey, protocolVersion, WebSocketImpl, timeoutMs,
398
424
  timedOut: null,
399
425
  failed: () => null,
400
426
  on: (done) => ({
@@ -416,11 +442,10 @@ export function askHostRpc({ port, token, proofKey, protocolVersion, prove = fal
416
442
  * `ifIdle` (#1224): only if nothing runs there, checked and acted on by the host in one step; `{ ok: false, busy: true }`
417
443
  * when something does. `forUpdate` (#1618): a hub's update stops it, and the host lists the turns it ends for the next one.
418
444
  */
419
- export function askHostToStop({ port, token, proofKey, protocolVersion, prove = false, ifIdle = false, forUpdate = false, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
445
+ export function askHostToStop({ port, token, proofKey, protocolVersion, ifIdle = false, forUpdate = false, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
420
446
  if (typeof WebSocketImpl !== 'function') return Promise.resolve({ ok: false, message: `Node ${process.version} has no WebSocket to ask with` })
421
- // With `prove`, the token goes out only to a host that proved it holds it (#838)
422
447
  return helloExchange({
423
- port, token, proofKey, protocolVersion, prove, WebSocketImpl, timeoutMs,
448
+ port, token, proofKey, protocolVersion, WebSocketImpl, timeoutMs,
424
449
  timedOut: { ok: false, message: 'no answer in time' },
425
450
  failed: (e) => ({ ok: false, message: e.message }),
426
451
  on: (done) => ({
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The launcher's side of a hub's key pairing (#1711, docs/plans/key-pairing.md §4.1 to §4.3): the check of a hub's
3
+ * public key at `--pair-key`, the public key of the host's signing key derived from serve.json's `key`, and the check of
4
+ * the running host's signed proof. The same rules as the host's `transport/link-keys.ts`, which this standalone launcher
5
+ * cannot import; `tooling/launcher-keys.test.ts` holds the two to the same answers and to the pinned vector.
6
+ */
7
+ import { createHmac, createPrivateKey, createPublicKey, verify } from 'node:crypto'
8
+
9
+ /** Bytes of a nonce, a public key and a signature (`@cc/protocol` `link-wire.ts`) */
10
+ export const LINK_NONCE_BYTES = 24
11
+ export const LINK_PUBLIC_KEY_BYTES = 32
12
+ export const LINK_SIGNATURE_BYTES = 64
13
+
14
+ const CONTEXT_LABEL = 'centralu link v1'
15
+ const HOST_ROLE_LABEL = 'centralu link host v1'
16
+ const HOST_KEY_LABEL = 'centralu host key v1'
17
+ const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
18
+ const PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex')
19
+ const P = 2n ** 255n - 19n
20
+
21
+ /** The bytes of `text` when it is exactly the base64url (no padding) of `bytes` bytes: encoding them again gives the same string */
22
+ export function decodeBase64url(text, bytes) {
23
+ if (typeof text !== 'string') return null
24
+ const raw = Buffer.from(text, 'base64url')
25
+ if (raw.length !== bytes || raw.toString('base64url') !== text) return null
26
+ return raw
27
+ }
28
+
29
+ /** The 32 bytes as a little-endian number with the top bit (the sign of x) cleared */
30
+ function yOf(raw) {
31
+ let y = 0n
32
+ for (let i = raw.length - 1; i >= 0; i--) y = (y << 8n) | BigInt(i === raw.length - 1 ? raw[i] & 0x7f : raw[i])
33
+ return y
34
+ }
35
+
36
+ /** The y of the eight small-order points: 0, 1, p − 1 and the two of order 8 (libsodium's `ge25519_has_small_order`) */
37
+ const SMALL_ORDER_Y = [
38
+ 0n,
39
+ 1n,
40
+ P - 1n,
41
+ yOf(Buffer.from('26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05', 'hex')),
42
+ yOf(Buffer.from('c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a', 'hex')),
43
+ ]
44
+
45
+ /**
46
+ * `{ text, raw, key }` for a public key the host takes (§4.1), or null: exactly 43 canonical base64url characters, a y
47
+ * below p, not the y of a small-order point (either sign bit), and an Ed25519 key `node:crypto` imports. Unchecked, on
48
+ * Node 22 a small-order key verifies a forged signature over every message.
49
+ */
50
+ export function parseLinkPublicKey(text) {
51
+ const raw = decodeBase64url(text, LINK_PUBLIC_KEY_BYTES)
52
+ if (!raw) return null
53
+ const y = yOf(raw)
54
+ if (y >= P || SMALL_ORDER_Y.includes(y)) return null
55
+ let key
56
+ try {
57
+ key = createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: 'der', type: 'spki' })
58
+ } catch {
59
+ return null
60
+ }
61
+ return key.asymmetricKeyType === 'ed25519' ? { text, raw, key } : null
62
+ }
63
+
64
+ /**
65
+ * The public key a host started with `key` signs with (§4.1): `seed = HMAC-SHA256(key, "centralu host key v1")`, as
66
+ * PKCS#8. What `--pair-key` reports when no host runs and none of a keeper's may run with another key (§4.2).
67
+ */
68
+ export function hostLinkPublicKey(key) {
69
+ if (typeof key !== 'string' || key.length === 0) throw new Error('The host key is empty')
70
+ const seed = createHmac('sha256', key).update(HOST_KEY_LABEL).digest()
71
+ const priv = createPrivateKey({ key: Buffer.concat([PKCS8_PREFIX, seed]), format: 'der', type: 'pkcs8' })
72
+ const der = createPublicKey(priv).export({ format: 'der', type: 'spki' })
73
+ const text = der.subarray(SPKI_PREFIX.length).toString('base64url')
74
+ if (!parseLinkPublicKey(text)) throw new Error('The derived host key was refused')
75
+ return text
76
+ }
77
+
78
+ /** `u32be(len) ‖ bytes` for each part; a string as ASCII, and one with another character throws */
79
+ export function encodeFields(parts) {
80
+ const out = []
81
+ for (const part of parts) {
82
+ if (typeof part === 'string' && Array.from(part).some((c) => c.codePointAt(0) > 0x7f)) throw new Error('A field string is ASCII')
83
+ const b = typeof part === 'string' ? Buffer.from(part, 'ascii') : Buffer.from(part)
84
+ const len = Buffer.alloc(4)
85
+ len.writeUInt32BE(b.length)
86
+ out.push(len, b)
87
+ }
88
+ return Buffer.concat(out)
89
+ }
90
+
91
+ /** `127.0.0.1:<port>` for a port 1 to 65535, null otherwise (the host's `linkAddress`) */
92
+ export function linkAddress(port) {
93
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? `127.0.0.1:${port}` : null
94
+ }
95
+
96
+ /** The transcript `T` (§4.3), or null when a value is refused */
97
+ export function linkTranscript({ hubNonce, hostNonce, hostKey, hubKey, keyId, address }) {
98
+ const nh = decodeBase64url(hubNonce, LINK_NONCE_BYTES)
99
+ const ns = decodeBase64url(hostNonce, LINK_NONCE_BYTES)
100
+ const pm = parseLinkPublicKey(hostKey)
101
+ const ph = parseLinkPublicKey(hubKey)
102
+ if (!nh || !ns || !pm || !ph) return null
103
+ if (typeof keyId !== 'string' || !/^[0-9a-f]{8}$/.test(keyId)) return null
104
+ if (typeof address !== 'string' || !/^127\.0\.0\.1:[1-9][0-9]{0,4}$/.test(address) || linkAddress(Number(address.slice(10))) !== address) return null
105
+ return encodeFields([CONTEXT_LABEL, nh, ns, pm.raw, ph.raw, keyId, address])
106
+ }
107
+
108
+ /**
109
+ * The key a running host signs with, when `proof` is its signed answer to a key `prove` of `hubNonce` (§4.2 step 3):
110
+ * `hostNonce`, `hostKey` and `sig` all present, strict, and the host's signature verifying over the transcript built
111
+ * here from the hub's key, the entry's id and the address the hub forwards to. Null for anything else: an answer
112
+ * without a signature (a host from before key pairing), or one that does not verify.
113
+ */
114
+ export function signedHostKey(proof, { hubNonce, hubKey, keyId, port }) {
115
+ if (proof?.kind !== 'proof' || proof.nonce !== hubNonce) return null
116
+ const pm = parseLinkPublicKey(proof.hostKey)
117
+ const sig = decodeBase64url(proof.sig, LINK_SIGNATURE_BYTES)
118
+ if (!pm || !sig) return null
119
+ const t = linkTranscript({ hubNonce, hostNonce: proof.hostNonce, hostKey: proof.hostKey, hubKey, keyId, address: linkAddress(port) })
120
+ if (!t) return null
121
+ try {
122
+ return verify(null, Buffer.concat([encodeFields([HOST_ROLE_LABEL]), t]), pm.key, sig) ? pm.text : null
123
+ } catch {
124
+ return null
125
+ }
126
+ }
package/bin/serve-run.mjs CHANGED
@@ -4,10 +4,11 @@ import { writeFileSync, writeSync } from 'node:fs'
4
4
  import { dirname, join } from 'node:path'
5
5
  import { createInterface } from 'node:readline'
6
6
  import { isAlive, say } from './serve-util.mjs'
7
- import { SERVE_STATE_FILE, SERVE_PID_FILE, readStopped, serveDataDir, ensureServeState, recordServePort, rotateServeToken, heldHere, lockHolder, lockHolderPid, removeServePid, pairServe, revokeServe, legacyHostTokenOf, dropLegacyHostToken } from './serve-files.mjs'
7
+ import { SERVE_STATE_FILE, SERVE_PID_FILE, readStopped, serveDataDir, ensureServeState, recordServePort, rotateServeToken, heldHere, lockHolder, lockHolderPid, holderWho, removeServePid, pairServe, pairKeyServe, dropVerifierUnder, revokeServe } from './serve-files.mjs'
8
8
  import { DEFAULT_SERVE_PORT, SERVE_PORT_TRIES, chooseServePort, listensHere, windowsListening } from './serve-port.mjs'
9
9
  import { keepLauncher, installInfo } from './serve-install.mjs'
10
- import { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, hostProvesOf, probeHost, serveFrom, takesTokenOnStdin, tokenLine, keyLine, takesVerifiers, ownCredentials } from './serve-host.mjs'
10
+ import { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, probeHost, probeKeyProof, serveFrom, keyLine, takesKeyHellos } from './serve-host.mjs'
11
+ import { hostLinkPublicKey } from './serve-keys.mjs'
11
12
  import { autostartInfo } from './serve-autostart.mjs'
12
13
  import { moveIntoSignIn } from './serve-handover.mjs'
13
14
 
@@ -27,29 +28,33 @@ export function serveFolder({ env, home, cliPath, platform = process.platform })
27
28
  *
28
29
  * The credentials (#1405): `paired` (`--pair`, `{ token, id }`) is a full credential just made for whoever asked, and
29
30
  * the only one this ever prints; without it the line carries the limited credential, which reads and does the everyday
30
- * things. An installed host from before verifiers knows one token: `--pair` hands out that one (`legacyHostTokenOf`).
31
+ * things.
31
32
  * `others` names the full credentials the folder holds besides the one just paired, never their values, each marked
32
33
  * `replaced` when its pairing took the place of an earlier one of its label.
33
34
  */
34
- export async function describe({ env, dataDir, state, entry, version, paired = null }) {
35
+ export async function describe({ env, dataDir, state, entry, version, paired = null, keyPaired = null }) {
35
36
  const port = state.port ?? DEFAULT_SERVE_PORT
36
37
  const installed = protocolVersionOf(entry)
37
- // Whether the installed host proves it holds the token (#838): asked of the running one before its hello, and said in the line
38
- const proof = hostProvesOf(entry)
39
- const own = ownCredentials(state, entry)
40
- const probe = await probeHost({ port, ...own, protocolVersion: installed, prove: proof })
38
+ const probe = await probeHost({ port, token: state.token, proofKey: state.proofKey, protocolVersion: installed })
41
39
  const protocolVersion = (probe.running ? probe.protocolVersion : undefined) ?? installed
42
40
  if (protocolVersion === null) return null
43
- const hostRunning = heldHere(probe, dataDir)
41
+ const hostRunning = heldHere(probe, dataDir, undefined, state.port != null)
44
42
  // The person stopped it here (#1143): said while no host runs, so a hub starts none by itself
45
43
  const stopped = hostRunning ? null : readStopped(dataDir)
46
- const verifiers = takesVerifiers(entry)
47
44
  return {
48
- proof,
49
45
  port,
50
- ...(paired ? { token: paired.token, ...(paired.id ? { credential: paired.id } : {}) } : {}),
51
- ...(verifiers ? { limited: state.token } : {}),
52
- others: state.full.filter((e) => e.id !== paired?.id).map(({ id, label, created, replaced }) => ({ id, label, created, ...(replaced ? { replaced: true } : {}) })),
46
+ ...(paired ? { token: paired.token, credential: paired.id } : {}),
47
+ // A key pairing (#1711): the key the running host signs with and the entry it signs for, never a credential
48
+ ...(keyPaired ? { hostKey: keyPaired.hostKey, keyId: keyPaired.keyId } : {}),
49
+ limited: state.token,
50
+ /*
51
+ * The hubs' key entries are listed too, with their public keys (#1711, §4.4), which a hub compares with its own; the
52
+ * one this line pairs is left out, as the full credential just paired is
53
+ */
54
+ others: [
55
+ ...state.full.filter((e) => e.id !== paired?.id).map(({ id, label, created, replaced }) => ({ id, label, created, ...(replaced ? { replaced: true } : {}) })),
56
+ ...(state.keys ?? []).filter((k) => k.id !== keyPaired?.keyId).map(({ id, label, created, replaced, publicKey }) => ({ id, label, created, publicKey, ...(replaced ? { replaced: true } : {}) })),
57
+ ],
53
58
  version: (probe.running ? probe.version : undefined) ?? version,
54
59
  protocolVersion,
55
60
  dataDir,
@@ -64,23 +69,35 @@ export async function describe({ env, dataDir, state, entry, version, paired = n
64
69
  * `centralu serve --connection`. stdout carries exactly one line, the JSON; everything else goes to
65
70
  * stderr. Exit 1 when there is no host to describe.
66
71
  */
67
- export async function printConnection({ env, home, entry, version, cliPath, pair = null }) {
72
+ export async function printConnection({ env, home, entry, version, cliPath, pair = null, pairKey = null, keyPaired = false, probeKey = probeKeyProof }) {
68
73
  const folder = serveFolder({ env, home, cliPath })
69
74
  const { dataDir } = folder
70
75
  let { state } = folder
71
76
  // `--pair <label>` (#1405): a full credential for whoever asked, printed once and written down as its verifier only
72
77
  let paired = null
73
- if (pair !== null) {
74
- if (takesVerifiers(entry)) {
75
- const made = pairServe(dataDir, pair)
76
- state = made.state
77
- paired = { token: made.token, id: made.id }
78
- say(`made a full credential for ${JSON.stringify(pair)} (${made.id}); serve --revoke ${made.id} withdraws it`)
78
+ // `--pair-key <label> <key>` (#1711): a key pairing when the host that will check it signs; else a verifier pairing as `--pair`
79
+ let keyLine = null
80
+ if (pairKey !== null) {
81
+ const made = pairKeyServe(dataDir, pairKey.label, pairKey.publicKey)
82
+ state = made.state
83
+ const hostKey = await signingHostKey({ dataDir, state, entry, keyId: made.entry.id, hubKey: pairKey.publicKey, probeKey })
84
+ if (hostKey) {
85
+ keyLine = { hostKey, keyId: made.entry.id }
86
+ // The hub paired by key here before (§4.2): its verifier entry under the label is no longer its own, and goes now
87
+ if (keyPaired) state = dropVerifierUnder(dataDir, pairKey.label)
88
+ say(`paired the key of ${JSON.stringify(pairKey.label)} (${made.entry.id}); serve --revoke ${made.entry.id} withdraws it`)
79
89
  } else {
80
- paired = { token: legacyHostTokenOf(dataDir), id: null }
90
+ // The key entry stays: it is public, and a host that signs uses it once one runs here
91
+ pair = pairKey.label
81
92
  }
82
93
  }
83
- const d = await describe({ env, dataDir, state, entry, version, paired })
94
+ if (pair !== null) {
95
+ const made = pairServe(dataDir, pair)
96
+ state = made.state
97
+ paired = { token: made.token, id: made.id }
98
+ say(`made a full credential for ${JSON.stringify(pair)} (${made.id}); serve --revoke ${made.id} withdraws it`)
99
+ }
100
+ const d = await describe({ env, dataDir, state, entry, version, paired, keyPaired: keyLine })
84
101
  if (!d) {
85
102
  say(`cannot read the protocol version of the host at ${entry}`)
86
103
  return 1
@@ -89,6 +106,28 @@ export async function printConnection({ env, home, entry, version, cliPath, pair
89
106
  return 0
90
107
  }
91
108
 
109
+ /**
110
+ * The key the host that checks a key hello signs with (#1711, docs/plans/key-pairing.md §4.2), or null when a key
111
+ * pairing cannot be made now and the line pairs by verifier:
112
+ *
113
+ * - the running host signs a `prove` for the entry: the key it signs with, which after `--rotate-token` is still its old
114
+ * one until it restarts;
115
+ * - nothing listens on the port (the connection refused), nothing holds the data folder (`host.lock`: no keeper's host,
116
+ * no host starting) and the installed host takes key hellos: the key derived from serve.json's `key`, which the host
117
+ * started next is handed;
118
+ * - otherwise (a host that does not sign, no answer in time, a proof that does not verify, a holder of the folder):
119
+ * null. A slow old host never gets a key line, and no line reports a key the running host does not sign with.
120
+ */
121
+ async function signingHostKey({ dataDir, state, entry, keyId, hubKey, probeKey }) {
122
+ const port = state.port ?? DEFAULT_SERVE_PORT
123
+ const probe = await probeKey({ port, keyId, hubKey })
124
+ if (probe.outcome === 'signed') return probe.hostKey
125
+ if (probe.outcome !== 'none' || !takesKeyHellos(entry)) return null
126
+ const holder = lockHolder(dataDir)
127
+ if (holder && isAlive(holder.pid)) return null
128
+ return hostLinkPublicKey(state.key)
129
+ }
130
+
92
131
  /**
93
132
  * `centralu serve`. Resolves with the exit code once the host has exited.
94
133
  *
@@ -108,9 +147,9 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
108
147
  // A serve that is already up answers on the port it recorded; say so before starting a host that
109
148
  // would only fail on the data folder's lock with a less specific message.
110
149
  const recorded = state.port ?? DEFAULT_SERVE_PORT
111
- const existing = await probeHost({ port: recorded, ...ownCredentials(state, entry), protocolVersion, prove: hostProvesOf(entry) })
150
+ const existing = await probeHost({ port: recorded, token: state.token, proofKey: state.proofKey, protocolVersion })
112
151
  // A host the hub's ssh started before anyone signed in moves into this sign-in, once idle (#1224, serve-handover.mjs)
113
- if (heldHere(existing, dataDir) && !(await handOver({ dataDir, entry, env, existing, platform }))) {
152
+ if (heldHere(existing, dataDir, undefined, state.port != null) && !(await handOver({ dataDir, entry, env, existing, platform }))) {
114
153
  const pid = lockHolderPid(dataDir)
115
154
  say(`centralu serve is already running for ${dataDir} on 127.0.0.1:${recorded}${pid ? ` (pid ${pid})` : ''}. Not starting a second one.`)
116
155
  return 1
@@ -129,14 +168,10 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
129
168
  }
130
169
 
131
170
  const plan = hostCommand(execPath, entry, { port, dataDir, platform })
132
- const onStdin = takesTokenOnStdin(entry)
133
171
  /*
134
- * A host that takes verifiers (#1405) gets the key on stdin and the path of serve.json, whose full credentials it
135
- * checks at each hello; nothing it gets opens it whole. An older host gets the one token it knows, kept for it
172
+ * The host gets the key on stdin and the path of serve.json, whose full credentials it checks at each hello (#1405);
173
+ * nothing it gets opens it whole
136
174
  */
137
- const verifiers = takesVerifiers(entry)
138
- if (verifiers) dropLegacyHostToken(dataDir, platform)
139
- const legacy = verifiers ? null : legacyHostTokenOf(dataDir, platform)
140
175
  const child = spawn(plan.command, plan.args, {
141
176
  stdio: ['pipe', 'pipe', logFd === null ? 'inherit' : 'pipe'],
142
177
  env: hostEnv(env, {
@@ -144,14 +179,13 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
144
179
  version,
145
180
  hostDir: dirname(entry),
146
181
  from: serveFrom(env, from),
147
- ...(verifiers ? { serveState: join(dataDir, SERVE_STATE_FILE) } : {}),
148
- ...(onStdin ? {} : { legacyToken: legacy ?? state.token }),
182
+ serveState: join(dataDir, SERVE_STATE_FILE),
149
183
  }),
150
184
  detached: plan.detached,
151
185
  windowsHide: true,
152
186
  })
153
187
  // What the host holds, before anything else on the pipe it watches (#538); stdin stays open for --watch-parent
154
- if (onStdin) child.stdin.write(verifiers ? keyLine(state.key) : tokenLine(legacy))
188
+ child.stdin.write(keyLine(state.key))
155
189
 
156
190
  let ready = false
157
191
  if (logFd !== null) {
@@ -217,8 +251,7 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
217
251
  const holder = lockHolder(dataDir)
218
252
  if (holder && holder.pid !== child.pid && isAlive(holder.pid)) {
219
253
  // Who it is, when its host.lock says (#1223)
220
- const who = { app: 'the Centralu window on this machine', keeper: "Centralu's keeper on this machine", serve: 'another centralu serve' }[holder.by] ?? 'probably the Centralu app on this machine'
221
- say(`the host did not start. ${dataDir} belongs to another Centralu host (pid ${holder.pid}), ${who}. Quit it, or point CC_DATA_DIR at another folder.`)
254
+ say(`the host did not start. ${dataDir} belongs to another Centralu host (pid ${holder.pid}), ${holderWho(holder.by)}. Quit it, or point CC_DATA_DIR at another folder.`)
222
255
  }
223
256
  }
224
257
  if (logFd !== null) say(`the host exited (${signal ?? code})`)
@@ -244,9 +277,9 @@ export function revokeCredential({ env, home, id, write = (line) => process.stdo
244
277
  write(`${JSON.stringify({ v: 1, revoked, id })}
245
278
  `)
246
279
  if (!revoked) {
247
- say(`no full credential ${JSON.stringify(id)} in ${join(dataDir, SERVE_STATE_FILE)}; --connection lists them under others.`)
280
+ say(`no credential ${id} in ${join(dataDir, SERVE_STATE_FILE)}; --connection lists them under others.`)
248
281
  return 1
249
282
  }
250
- say(`withdrew the full credential ${id}. A running host refuses its next hello and closes a connection already open with it within a second.`)
283
+ say(`withdrew ${id}. A running host refuses its next hello with it and closes a connection already open with it within a second.`)
251
284
  return 0
252
285
  }