rollbridge 0.1.36 → 0.1.37
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/README.md +5 -3
- package/changelog.d/20260830-guardian-retired-replacement-process-key.md +7 -0
- package/docs/cli.md +2 -1
- package/docs/config.md +4 -2
- package/docs/troubleshooting.md +3 -0
- package/package.json +1 -1
- package/src/daemon.js +216 -61
- package/src/guardian-client.js +66 -1
- package/src/process-guardian.js +31 -6
- package/test/fixtures/partial-owner-replacement-process-guardian.js +106 -0
- package/test/guardian-client.test.js +74 -0
- package/test/owner-replacement.test.js +519 -2
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs/promises"
|
|
4
|
+
import net from "node:net"
|
|
5
|
+
|
|
6
|
+
const [socketPath, backendPath, mode = "partial"] = process.argv.slice(2)
|
|
7
|
+
|
|
8
|
+
if (!socketPath || !backendPath || !process.send) throw new Error("partial process-guardian requires frontend and backend sockets plus a private bootstrap channel")
|
|
9
|
+
|
|
10
|
+
const token = await new Promise((resolve, reject) => {
|
|
11
|
+
process.once("disconnect", () => reject(new Error("Partial guardian bootstrap channel closed before authentication capability arrived")))
|
|
12
|
+
process.once("message", (message) => {
|
|
13
|
+
if (!message || typeof message !== "object" || !("token" in message) || typeof message.token !== "string" || !message.token) {
|
|
14
|
+
reject(new Error("Partial guardian bootstrap authentication capability is invalid"))
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
resolve(message.token)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const clients = new Set()
|
|
22
|
+
const server = net.createServer((frontend) => {
|
|
23
|
+
const backend = net.createConnection(backendPath)
|
|
24
|
+
let frontendBuffer = ""
|
|
25
|
+
let backendBuffer = ""
|
|
26
|
+
|
|
27
|
+
clients.add(frontend)
|
|
28
|
+
frontend.setEncoding("utf8")
|
|
29
|
+
backend.setEncoding("utf8")
|
|
30
|
+
frontend.on("error", () => frontend.destroy())
|
|
31
|
+
backend.on("error", () => backend.destroy())
|
|
32
|
+
frontend.once("close", () => {
|
|
33
|
+
clients.delete(frontend)
|
|
34
|
+
backend.destroy()
|
|
35
|
+
})
|
|
36
|
+
backend.once("close", () => frontend.destroy())
|
|
37
|
+
frontend.on("data", (chunk) => {
|
|
38
|
+
frontendBuffer += chunk
|
|
39
|
+
let newline = frontendBuffer.indexOf("\n")
|
|
40
|
+
|
|
41
|
+
while (newline >= 0) {
|
|
42
|
+
const line = frontendBuffer.slice(0, newline)
|
|
43
|
+
const request = JSON.parse(line)
|
|
44
|
+
|
|
45
|
+
frontendBuffer = frontendBuffer.slice(newline + 1)
|
|
46
|
+
if (request.token !== token) {
|
|
47
|
+
frontend.write(`${JSON.stringify({error: "Guardian authentication failed", id: request.id})}\n`)
|
|
48
|
+
} else if (request.command === "owner-replacement-capabilities") {
|
|
49
|
+
const result = mode === "malformed-capability"
|
|
50
|
+
? {protocol: "owner-replacement", version: 1}
|
|
51
|
+
: undefined
|
|
52
|
+
|
|
53
|
+
frontend.write(`${JSON.stringify(result
|
|
54
|
+
? {id: request.id, result}
|
|
55
|
+
: {error: "Guardian owner-replacement-capabilities requires a process key", id: request.id})}\n`)
|
|
56
|
+
} else if (request.command === "commit-retired-owner-replacement") {
|
|
57
|
+
frontend.write(`${JSON.stringify({
|
|
58
|
+
error: request.key
|
|
59
|
+
? "Guardian commit-retired-owner-replacement requires the committed owner"
|
|
60
|
+
: "Guardian commit-retired-owner-replacement requires a process key",
|
|
61
|
+
id: request.id
|
|
62
|
+
})}\n`)
|
|
63
|
+
} else {
|
|
64
|
+
if (mode === "wrong-provenance" && request.command === "register") {
|
|
65
|
+
request.provenance = `${request.provenance}-tampered`
|
|
66
|
+
backend.write(`${JSON.stringify(request)}\n`)
|
|
67
|
+
} else {
|
|
68
|
+
backend.write(`${line}\n`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
newline = frontendBuffer.indexOf("\n")
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
backend.on("data", (chunk) => {
|
|
75
|
+
backendBuffer += chunk
|
|
76
|
+
let newline = backendBuffer.indexOf("\n")
|
|
77
|
+
|
|
78
|
+
while (newline >= 0) {
|
|
79
|
+
const line = backendBuffer.slice(0, newline)
|
|
80
|
+
|
|
81
|
+
backendBuffer = backendBuffer.slice(newline + 1)
|
|
82
|
+
frontend.write(`${line}\n`)
|
|
83
|
+
newline = backendBuffer.indexOf("\n")
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
server.on("error", (error) => {
|
|
89
|
+
if (process.send) process.send({error: error.message})
|
|
90
|
+
else throw error
|
|
91
|
+
})
|
|
92
|
+
server.listen(socketPath, async () => {
|
|
93
|
+
await fs.chmod(socketPath, 0o600)
|
|
94
|
+
process.send?.({ready: true})
|
|
95
|
+
process.disconnect?.()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const shutdown = () => {
|
|
99
|
+
for (const client of clients) client.destroy()
|
|
100
|
+
server.close(() => {
|
|
101
|
+
void fs.rm(socketPath, {force: true}).finally(() => process.exit(0))
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
process.once("SIGINT", shutdown)
|
|
106
|
+
process.once("SIGTERM", shutdown)
|
|
@@ -210,6 +210,32 @@ test("replacement staging rejects owner state published after prepare", async ()
|
|
|
210
210
|
}
|
|
211
211
|
})
|
|
212
212
|
|
|
213
|
+
test("replacement abort notifies both the candidate and committed owner", async () => {
|
|
214
|
+
const fixture = await createGuardian()
|
|
215
|
+
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|
|
216
|
+
const authority = {configDigest: "incumbent", runtime: null}
|
|
217
|
+
const nextAuthority = {configDigest: "candidate", runtime: null}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
await fixture.client.publishOwnerState({authority, snapshot: {activeReleaseId: "v1"}})
|
|
221
|
+
await candidate.connect()
|
|
222
|
+
const prepared = await candidate.prepareOwnerReplacement(authority, nextAuthority)
|
|
223
|
+
const incumbentAborted = fixture.client.waitForEvent("replacement-aborted")
|
|
224
|
+
const candidateAborted = candidate.waitForEvent("replacement-aborted")
|
|
225
|
+
|
|
226
|
+
await candidate.abortOwnerReplacement(prepared.replacementId)
|
|
227
|
+
assert.deepEqual(await Promise.all([incumbentAborted, candidateAborted]), [
|
|
228
|
+
{event: "replacement-aborted", reason: "Replacement candidate aborted the prepared transaction"},
|
|
229
|
+
{event: "replacement-aborted", reason: "Replacement candidate aborted the prepared transaction"}
|
|
230
|
+
])
|
|
231
|
+
await fixture.client.shutdown()
|
|
232
|
+
await fixture.client.guardianExit()
|
|
233
|
+
} finally {
|
|
234
|
+
candidate.disconnect()
|
|
235
|
+
await cleanupGuardian(fixture)
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
213
239
|
test("retired owner replacement commit carries its exact recovered process key", async () => {
|
|
214
240
|
const client = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
|
|
215
241
|
const replacementId = "prepared-replacement"
|
|
@@ -224,6 +250,54 @@ test("retired owner replacement commit carries its exact recovered process key",
|
|
|
224
250
|
await client.commitRetiredOwnerReplacement(replacementId, processKey)
|
|
225
251
|
})
|
|
226
252
|
|
|
253
|
+
test("guardian owner-replacement capability classification is explicit and fail closed", async () => {
|
|
254
|
+
const current = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
|
|
255
|
+
|
|
256
|
+
current.request = async (request) => {
|
|
257
|
+
assert.deepEqual(request, {command: "owner-replacement-capabilities"})
|
|
258
|
+
return {
|
|
259
|
+
commands: ["commit-retired-owner-replacement", "future-command"],
|
|
260
|
+
futureField: {supported: true},
|
|
261
|
+
protocol: "owner-replacement",
|
|
262
|
+
version: 2
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
assert.equal(await current.ownerReplacementProtocol(), "atomic")
|
|
266
|
+
|
|
267
|
+
for (const [commitDiagnostic, expected] of [
|
|
268
|
+
["Owner replacement transaction is not the prepared candidate", "atomic"],
|
|
269
|
+
["Guardian commit-retired-owner-replacement requires a process key", "legacy"],
|
|
270
|
+
["Unknown guardian command: commit-retired-owner-replacement", "legacy"]
|
|
271
|
+
]) {
|
|
272
|
+
const older = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
|
|
273
|
+
const requests = /** @type {Record<string, import("../src/json.js").JsonValue>[]} */ ([])
|
|
274
|
+
|
|
275
|
+
older.request = async (request) => {
|
|
276
|
+
requests.push(request)
|
|
277
|
+
if (request.command === "owner-replacement-capabilities") throw new Error("Guardian owner-replacement-capabilities requires a process key")
|
|
278
|
+
throw new Error(commitDiagnostic)
|
|
279
|
+
}
|
|
280
|
+
assert.equal(await older.ownerReplacementProtocol(), expected)
|
|
281
|
+
assert.deepEqual(requests, [
|
|
282
|
+
{command: "owner-replacement-capabilities"},
|
|
283
|
+
{command: "commit-retired-owner-replacement", replacementId: "owner-replacement-capability-probe"}
|
|
284
|
+
])
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const fixture of [
|
|
288
|
+
async () => ({commands: [], protocol: "owner-replacement", version: 1}),
|
|
289
|
+
async (/** @type {Record<string, import("../src/json.js").JsonValue>} */ request) => {
|
|
290
|
+
if (request.command === "owner-replacement-capabilities") throw new Error("Guardian owner-replacement-capabilities requires a process key")
|
|
291
|
+
throw new Error("Guardian commit-retired-owner-replacement requires the committed owner")
|
|
292
|
+
}
|
|
293
|
+
]) {
|
|
294
|
+
const ambiguous = new GuardianClient({socketPath: "/unused", token: "authenticated-capability"})
|
|
295
|
+
|
|
296
|
+
ambiguous.request = fixture
|
|
297
|
+
await assert.rejects(() => ambiguous.ownerReplacementProtocol(), /invalid owner-replacement capability response|ambiguous retired-owner capability response/)
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
227
301
|
test("reserved process recovery rejects a reconstructed definition with different provenance", async () => {
|
|
228
302
|
const fixture = await createGuardian()
|
|
229
303
|
const candidate = new GuardianClient({socketPath: fixture.client.socketPath, token: fixture.token})
|