centralu 0.1.0-beta.45 → 0.1.0-beta.47

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.
@@ -3,9 +3,9 @@
3
3
  * Windows sign-in (#1224). One module so the foreground `serve` (serve-run.mjs) and `--stop` (serve-detach.mjs) share
4
4
  * the stop without importing each other.
5
5
  */
6
- import { readServeState, heldHere, lockHolderPid, readServePid, pairServe, revokeServe } from './serve-files.mjs'
7
- import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
8
- import { protocolVersionOf, hostProvesOf, probeHost, askHostToStop, askHostRpc, ownCredentials, takesVerifiers } from './serve-host.mjs'
6
+ import { readServeState, heldHere, startedByServe, foreignHolder, holderWho, lockHolderIsAlive, lockHolderPid, readServePid } from './serve-files.mjs'
7
+ import { DEFAULT_SERVE_PORT, listensHere } from './serve-port.mjs'
8
+ import { protocolVersionOf, probeHost, askHostToStop, askHostRpc } from './serve-host.mjs'
9
9
  import { ownLogon } from './serve-windows.mjs'
10
10
  import { isAlive, say, sleep, waitUntil } from './serve-util.mjs'
11
11
 
@@ -20,7 +20,11 @@ import { isAlive, say, sleep, waitUntil } from './serve-util.mjs'
20
20
  * one still there afterwards is ended.
21
21
  *
22
22
  * Only the processes serve.pid and host.lock name, and only while they agree with each other and the host answered
23
- * with this folder's token: a stale pid is never signalled.
23
+ * with this folder's token, or nothing listens on the port while the process host.lock names is the one that wrote it
24
+ * (`heldSilently`, #1903: a host that took the lock and hung before it listened; never under `ifIdle`): a stale pid is
25
+ * never signalled.
26
+ * And only a host `serve` started (`startedByServe`, #1722): a lock held by the app's or the keeper's host is theirs,
27
+ * whatever answers on the port, so `--stop` says whose it is and leaves it.
24
28
  *
25
29
  * `{ ok, wasRunning, how?, message? }`, the `--stop` line's `stop`. With `ifIdle` (#1224, the hand-over) the host stops only
26
30
  * when nothing runs there, and `{ ok: false, busy: true }` says it did not. `forUpdate` (#1618): a hub's update stops it, and
@@ -32,28 +36,42 @@ export async function stopServedHost({ dataDir, entry, platform = process.platfo
32
36
  if (!state) return { ok: true, wasRunning: false, message: `no centralu serve has run for ${dataDir}` }
33
37
  const port = state.port ?? DEFAULT_SERVE_PORT
34
38
  const installed = protocolVersionOf(entry) ?? 0
35
- const prove = hostProvesOf(entry)
36
39
  // The limited credential is enough to probe and to stop it (#1405, `host.stop` is everyday)
37
- const own = ownCredentials(state, entry)
38
- const probe = () => probeHost({ port, ...own, protocolVersion: installed, prove })
40
+ const own = { token: state.token, proofKey: state.proofKey }
41
+ const probe = () => probeHost({ port, ...own, protocolVersion: installed })
39
42
  const first = await probe()
40
- if (!heldHere(first, dataDir)) return { ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}` }
43
+ const silent = !first.running && !first.unproven && (await heldSilently(dataDir, port))
44
+ if (!silent && !heldHere(first, dataDir, undefined, state.port != null)) {
45
+ // The folder is held, but not by a serve (#1722): the app's or the keeper's host, whatever answered on the port
46
+ const other = foreignHolder(dataDir)
47
+ const whose = other ? `. It belongs to another Centralu host (pid ${other.pid}), ${holderWho(other.by)}; not stopping it` : ''
48
+ return { ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}${whose}` }
49
+ }
41
50
 
42
51
  const hostPid = lockHolderPid(dataDir)
52
+ // Only a host `serve` started is ever signalled, whoever answered with this folder's token
53
+ const mayKill = startedByServe(dataDir)
43
54
  const launcher = readServePid(dataDir)
44
55
  // serve.pid is only believed when it names the host that holds the folder now
45
- const launcherPid = launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
56
+ const launcherPid = mayKill && launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
46
57
  const down = async () => !(await probe()).running && !(hostPid && isAlive(hostPid))
47
58
 
48
- let asked = await askHostToStop({ port, ...own, protocolVersion: first.protocolVersion ?? installed, prove, ifIdle, forUpdate })
49
- if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, ...own, protocolVersion: asked.protocolVersion, prove, ifIdle, forUpdate })
50
- // `ifIdle` and it has work running (#1224): it was left as it is
51
- if (asked.busy) return { ok: false, wasRunning: true, busy: true, message: asked.message }
52
59
  let how = 'asked'
53
- let stopped = asked.ok && (await waitUntil(down, timeoutMs))
54
- let note = asked.ok ? null : `the host did not take the request to stop (${asked.message})`
60
+ let stopped = false
61
+ let note = silent ? 'nothing listens on its port' : null
62
+ // The hand-over stops only a host that said it is idle: one that cannot say so is left
63
+ if (silent && ifIdle) return { ok: false, wasRunning: true, message: `the host holds the folder but ${note}` }
64
+ if (!silent) {
65
+ let asked = await askHostToStop({ port, ...own, protocolVersion: first.protocolVersion ?? installed, ifIdle, forUpdate })
66
+ if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, ...own, protocolVersion: asked.protocolVersion, ifIdle, forUpdate })
67
+ // `ifIdle` and it has work running (#1224): it was left as it is
68
+ if (asked.busy) return { ok: false, wasRunning: true, busy: true, message: asked.message }
69
+ stopped = asked.ok && (await waitUntil(down, timeoutMs))
70
+ if (!asked.ok) note = `the host did not take the request to stop (${asked.message})`
71
+ }
55
72
  if (!stopped) {
56
- const target = platform !== 'win32' && launcherPid ? launcherPid : hostPid
73
+ const target = platform !== 'win32' && launcherPid ? launcherPid : mayKill ? hostPid : null
74
+ if (hostPid && !mayKill) note = `${note ? `${note}; ` : ''}the lock names pid ${hostPid}, which centralu serve did not start, so it was not signalled`
57
75
  if (target) {
58
76
  how = platform === 'win32' ? 'ended' : 'signal'
59
77
  try {
@@ -85,6 +103,16 @@ export async function stopServedHost({ dataDir, entry, platform = process.platfo
85
103
  return { ok: true, wasRunning: true, how, ...(note ? { message: note } : {}) }
86
104
  }
87
105
 
106
+ /**
107
+ * A host `serve` started that holds the folder's lock while nothing listens on its port (#1903): it took the lock and hung
108
+ * before it listened, or is still starting. Told by a listen on the port, not by the probe: a host that listens but whose
109
+ * event loop is stalled answers no probe in time either, and is running (#1906 review). Not part of `heldHere`, which
110
+ * `--connection` and `--detach`'s wait read: there a host still starting must not count as one that answers
111
+ */
112
+ async function heldSilently(dataDir, port) {
113
+ return startedByServe(dataDir) && lockHolderIsAlive(dataDir) && !(await listensHere(port))
114
+ }
115
+
88
116
  /** How long the hand-over waits for the host's work to end before it gives up and says so (#1224 review) */
89
117
  export const HANDOVER_WAIT_MS = 30 * 60_000
90
118
 
@@ -126,28 +154,22 @@ export async function moveIntoSignIn({
126
154
  const state = readServeState(dataDir)
127
155
  if (!state) return false
128
156
  const port = state.port ?? DEFAULT_SERVE_PORT
129
- const prove = hostProvesOf(entry)
130
157
  const protocolVersion = existing.protocolVersion ?? protocolVersionOf(entry) ?? 0
131
- const own = ownCredentials(state, entry)
158
+ const own = { token: state.token, proofKey: state.proofKey }
132
159
  /*
133
- * `host.handover` is a hub's call (#1405, `link`): said with a full credential made for this one call and withdrawn
134
- * right after, never kept. An older installed host knows only the token kept for it
160
+ * `host.handover` takes everyday rights (#1711, docs/plans/key-pairing.md §4.4): said with the credentials this
161
+ * launcher holds for its own host (the limited credential, after a proof under the key), and no credential is made for
162
+ * it. Before, a full one made for this call went to whatever answered that proof, which a reader of serve.json can. A
163
+ * host from #1405 to key pairing refuses it (`link`): no note on the row while this waits, and the move goes on
135
164
  */
136
- const sayHandover = async (h) => {
137
- const made = takesVerifiers(entry) ? pairServe(dataDir, 'handover', platform, Date.now(), 'handover') : null
138
- try {
139
- return await askHostRpc({ port, token: made?.token ?? own.token, proofKey: own.proofKey, protocolVersion, prove, method: 'host.handover', params: { handover: h } })
140
- } finally {
141
- if (made) revokeServe(dataDir, made.id, platform)
142
- }
143
- }
165
+ const sayHandover = (h) => askHostRpc({ port, ...own, protocolVersion, method: 'host.handover', params: { handover: h } })
144
166
  const report = (handover) => (tell ?? sayHandover)(handover)
145
167
  const pid = lockHolderPid(dataDir)
146
168
  say(`the host running for ${dataDir}${pid ? ` (pid ${pid})` : ''} runs in a network logon (started over ssh), where Git cannot use the credentials saved in Windows; moving it into this Windows sign-in once nothing runs there`)
147
169
  const t0 = now()
148
170
  let waiting = false
149
171
  for (;;) {
150
- if (!(await probeHost({ port, ...own, protocolVersion, prove })).running) return true
172
+ if (!(await probeHost({ port, ...own, protocolVersion })).running) return true
151
173
  const r = await stop()
152
174
  if (r.ok) {
153
175
  say(`stopped it${r.how && r.how !== 'asked' ? ` (${r.how})` : ''}; starting the host in this sign-in`)
@@ -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) => ({
@@ -105,7 +105,7 @@ export function remoteLayout(dataDir) {
105
105
  }
106
106
 
107
107
  /** A word a pointer file may hold: a folder name, nothing a path could escape with */
108
- const POINTER_WORD = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/
108
+ export const POINTER_WORD = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/
109
109
 
110
110
  /** `current` / `previous`: one line, "<centralu version> <node version>". null for anything else */
111
111
  export function parsePointer(text) {
@@ -146,7 +146,21 @@ export function installInfo(dataDir, env, nodeVersion = process.versions.node) {
146
146
  const source = p && installSource(join(l.versions, p.version))
147
147
  return p && source ? { ...p, source } : p
148
148
  }
149
- return { managed: env.CENTRALU_MANAGED === '1', current: read(l.current), previous: read(l.previous), node: nodeVersion }
149
+ return { managed: env.CENTRALU_MANAGED === '1', current: read(l.current), previous: read(l.previous), node: nodeVersion, lastSwitch: lastSwitchOf(l.root) }
150
+ }
151
+
152
+ /**
153
+ * The last switch there (`switch.json`, serve-switch.mjs), as much as a hub needs to know whether to read it: its id,
154
+ * whether it ended, and whether its outcome was reported (`--seen`). A hub that was away for the whole of it, or
155
+ * restarted since, learns from this at its next open that there is an outcome to say (#1907). Null when none ran
156
+ */
157
+ function lastSwitchOf(root) {
158
+ try {
159
+ const s = JSON.parse(readFileSync(join(root, 'switch.json'), 'utf8'))
160
+ return typeof s?.id === 'string' ? { id: s.id, ended: typeof s.outcome === 'string', seen: s.seen === true } : null
161
+ } catch {
162
+ return null
163
+ }
150
164
  }
151
165
 
152
166
  /**
@@ -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
+ }