@frockbot/plugin-user-machine 0.0.0 → 0.1.0
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/frockbot.json +66 -0
- package/package.json +54 -6
- package/src/agent.test.ts +509 -0
- package/src/agent.ts +733 -0
- package/src/approval.test.ts +323 -0
- package/src/approval.ts +148 -0
- package/src/backend.test.ts +370 -0
- package/src/backend.ts +370 -0
- package/src/client/MachineSection.vue +97 -0
- package/src/client/MachineSurface.vue +308 -0
- package/src/client/index.test.ts +244 -0
- package/src/client/index.ts +180 -0
- package/src/client/state.ts +49 -0
- package/src/delivery.ts +145 -0
- package/src/desktop.test.ts +233 -0
- package/src/desktop.ts +238 -0
- package/src/device-runner.test.ts +326 -0
- package/src/device-runner.ts +205 -0
- package/src/device.test.ts +418 -0
- package/src/device.ts +707 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +13 -0
- package/src/intent.ts +325 -0
- package/src/manifest.ts +3 -0
- package/src/pairing.test.ts +85 -0
- package/src/pairing.ts +182 -0
- package/src/storage-keys.ts +102 -0
- package/src/store.test.ts +507 -0
- package/src/store.ts +638 -0
- package/src/target.ts +86 -0
- package/src/testing.ts +352 -0
- package/src/user.test.ts +182 -0
- package/src/user.ts +442 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from "./agent.js";
|
|
2
|
+
export * from "./approval.js";
|
|
3
|
+
export * from "./backend.js";
|
|
4
|
+
export * from "./delivery.js";
|
|
5
|
+
export * from "./device.js";
|
|
6
|
+
export * from "./device-runner.js";
|
|
7
|
+
export * from "./intent.js";
|
|
8
|
+
export * from "./pairing.js";
|
|
9
|
+
export * from "./storage-keys.js";
|
|
10
|
+
export * from "./store.js";
|
|
11
|
+
export * from "./target.js";
|
|
12
|
+
export * from "./testing.js";
|
|
13
|
+
export * from "./user.js";
|
package/src/intent.ts
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// The Bot Durable Object's half of a machine command: intent, recorded before
|
|
2
|
+
// anything runs.
|
|
3
|
+
//
|
|
4
|
+
// A machine tool does not run a command. It writes one of these, asks the User
|
|
5
|
+
// for an approval, and ends the Turn. The command reaches the User's laptop
|
|
6
|
+
// only when a person answers, and it is *this* record the settlement reads to
|
|
7
|
+
// know what they answered about — the pending-input preamble carries only an
|
|
8
|
+
// `approvalId` and a decision, so the action it authorizes has to be
|
|
9
|
+
// recoverable from that id alone.
|
|
10
|
+
//
|
|
11
|
+
// Three properties the rest of the slice rests on:
|
|
12
|
+
//
|
|
13
|
+
// 1. **`approvalId === commandId === effectId`.** One identity for the
|
|
14
|
+
// decision, the queue key and the Turn's durable occurrence, so a replayed
|
|
15
|
+
// settlement addresses the same command rather than queueing a second one.
|
|
16
|
+
// 2. **Written before the send.** "Record intent before an external effect."
|
|
17
|
+
// The record is durable before the card the User sees exists, so there is
|
|
18
|
+
// no window in which somebody could approve an action nothing describes.
|
|
19
|
+
// 3. **Pure.** Everything here is a function of its arguments. The storage
|
|
20
|
+
// seam is in `approval.ts`; this module never reads a clock it was not
|
|
21
|
+
// handed.
|
|
22
|
+
import {
|
|
23
|
+
MACHINE_LIMITS_V1,
|
|
24
|
+
MachineDecodeError,
|
|
25
|
+
decodeMachineOpV1,
|
|
26
|
+
type MachineCommandV1,
|
|
27
|
+
type MachineOpV1,
|
|
28
|
+
} from "@frockbot/machine-protocol";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The approval id — and therefore the command id — one Turn's `effectId` maps
|
|
32
|
+
* to.
|
|
33
|
+
*
|
|
34
|
+
* `effectId` is `tool:<turn>:<step>:<ordinal>`, and an `approvalId` may not
|
|
35
|
+
* carry a colon: it becomes a URL path segment and a durable storage key, and
|
|
36
|
+
* `decodeSendToUserPayloadV1` refuses anything but letters, digits, dot,
|
|
37
|
+
* underscore and dash. The mapping is total, deterministic and injective over
|
|
38
|
+
* that format, so `commandId === approvalId` is still exactly one identity per
|
|
39
|
+
* durable occurrence — which is all the idempotency rests on.
|
|
40
|
+
*/
|
|
41
|
+
export function machineApprovalIdV1(effectId: string): string {
|
|
42
|
+
const mapped = effectId.replace(/[^a-zA-Z0-9._-]/g, ".");
|
|
43
|
+
return /^[a-zA-Z0-9]/.test(mapped) ? mapped : `m${mapped}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One intent per approval, in the Bot Durable Object's own storage. */
|
|
47
|
+
export const MACHINE_INTENT_PREFIX = "machine-command:";
|
|
48
|
+
|
|
49
|
+
export function machineIntentKeyV1(approvalId: string): string {
|
|
50
|
+
return `${MACHINE_INTENT_PREFIX}${approvalId}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What the settlement did with the intent, once a person (or the clock)
|
|
55
|
+
* answered. `refused` is the queue's own answer — a machine revoked between the
|
|
56
|
+
* card and the decision — and is a fact about the command, not about the User.
|
|
57
|
+
*/
|
|
58
|
+
export type MachineIntentOutcomeV1 =
|
|
59
|
+
"dispatched" | "duplicate" | "denied" | "expired" | "refused";
|
|
60
|
+
|
|
61
|
+
export const MACHINE_INTENT_OUTCOMES_V1: readonly MachineIntentOutcomeV1[] = [
|
|
62
|
+
"dispatched",
|
|
63
|
+
"duplicate",
|
|
64
|
+
"denied",
|
|
65
|
+
"expired",
|
|
66
|
+
"refused",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The durable record of one asked-for machine command.
|
|
71
|
+
*
|
|
72
|
+
* `decision` is what the User's answer was; `outcome` is what this Package then
|
|
73
|
+
* did about it. They are separate because "approved" and "queued" are different
|
|
74
|
+
* facts: an approval whose machine was revoked in between is `approved` and
|
|
75
|
+
* `refused`, and a reader months later should be able to tell.
|
|
76
|
+
*/
|
|
77
|
+
export interface MachineIntentRecordV1 {
|
|
78
|
+
schemaVersion: 1;
|
|
79
|
+
approvalId: string;
|
|
80
|
+
commandId: string;
|
|
81
|
+
machineId: string;
|
|
82
|
+
botId: string;
|
|
83
|
+
runId: string;
|
|
84
|
+
turn: number;
|
|
85
|
+
op: MachineOpV1;
|
|
86
|
+
createdAt: string;
|
|
87
|
+
decision?: "approved" | "denied" | "expired";
|
|
88
|
+
decidedAt?: string;
|
|
89
|
+
dispatchedAt?: string;
|
|
90
|
+
outcome?: MachineIntentOutcomeV1;
|
|
91
|
+
/** Why the dispatch refused, in the queue's own words. */
|
|
92
|
+
reason?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function object(input: unknown, label: string): Record<string, unknown> {
|
|
96
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
97
|
+
throw new MachineDecodeError(`${label} must be an object`);
|
|
98
|
+
}
|
|
99
|
+
return input as Record<string, unknown>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function exactly(
|
|
103
|
+
value: Record<string, unknown>,
|
|
104
|
+
allowed: readonly string[],
|
|
105
|
+
label: string,
|
|
106
|
+
): void {
|
|
107
|
+
for (const key of Object.keys(value)) {
|
|
108
|
+
if (!allowed.includes(key)) {
|
|
109
|
+
throw new MachineDecodeError(`${label} has an unexpected key "${key}"`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function text(value: unknown, maximum: number, label: string): string {
|
|
115
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
116
|
+
throw new MachineDecodeError(`${label} must be a non-empty string`);
|
|
117
|
+
}
|
|
118
|
+
if (value.length > maximum) {
|
|
119
|
+
throw new MachineDecodeError(`${label} exceeds ${maximum} characters`);
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function timestamp(value: unknown, label: string): string {
|
|
125
|
+
const stamp = text(value, 64, label);
|
|
126
|
+
if (Number.isNaN(Date.parse(stamp))) {
|
|
127
|
+
throw new MachineDecodeError(`${label} is not a timestamp`);
|
|
128
|
+
}
|
|
129
|
+
return stamp;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function decision(
|
|
133
|
+
value: unknown,
|
|
134
|
+
label: string,
|
|
135
|
+
): "approved" | "denied" | "expired" {
|
|
136
|
+
if (value !== "approved" && value !== "denied" && value !== "expired") {
|
|
137
|
+
throw new MachineDecodeError(`${label} is invalid`);
|
|
138
|
+
}
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function decodeMachineIntentRecordV1(
|
|
143
|
+
input: unknown,
|
|
144
|
+
label = "machine intent",
|
|
145
|
+
): MachineIntentRecordV1 {
|
|
146
|
+
const value = object(input, label);
|
|
147
|
+
exactly(
|
|
148
|
+
value,
|
|
149
|
+
[
|
|
150
|
+
"schemaVersion",
|
|
151
|
+
"approvalId",
|
|
152
|
+
"commandId",
|
|
153
|
+
"machineId",
|
|
154
|
+
"botId",
|
|
155
|
+
"runId",
|
|
156
|
+
"turn",
|
|
157
|
+
"op",
|
|
158
|
+
"createdAt",
|
|
159
|
+
"decision",
|
|
160
|
+
"decidedAt",
|
|
161
|
+
"dispatchedAt",
|
|
162
|
+
"outcome",
|
|
163
|
+
"reason",
|
|
164
|
+
],
|
|
165
|
+
label,
|
|
166
|
+
);
|
|
167
|
+
if (value.schemaVersion !== 1) {
|
|
168
|
+
throw new MachineDecodeError(`${label} schemaVersion is unsupported`);
|
|
169
|
+
}
|
|
170
|
+
if (
|
|
171
|
+
typeof value.turn !== "number" ||
|
|
172
|
+
!Number.isSafeInteger(value.turn) ||
|
|
173
|
+
value.turn < 0
|
|
174
|
+
) {
|
|
175
|
+
throw new MachineDecodeError(`${label} turn is invalid`);
|
|
176
|
+
}
|
|
177
|
+
if (
|
|
178
|
+
value.outcome !== undefined &&
|
|
179
|
+
!MACHINE_INTENT_OUTCOMES_V1.includes(
|
|
180
|
+
value.outcome as MachineIntentOutcomeV1,
|
|
181
|
+
)
|
|
182
|
+
) {
|
|
183
|
+
throw new MachineDecodeError(`${label} outcome is invalid`);
|
|
184
|
+
}
|
|
185
|
+
const identifier = MACHINE_LIMITS_V1.identifier;
|
|
186
|
+
return {
|
|
187
|
+
schemaVersion: 1,
|
|
188
|
+
approvalId: text(value.approvalId, identifier, `${label} approvalId`),
|
|
189
|
+
commandId: text(value.commandId, identifier, `${label} commandId`),
|
|
190
|
+
machineId: text(value.machineId, identifier, `${label} machineId`),
|
|
191
|
+
botId: text(value.botId, identifier, `${label} botId`),
|
|
192
|
+
runId: text(value.runId, identifier, `${label} runId`),
|
|
193
|
+
turn: value.turn,
|
|
194
|
+
op: decodeMachineOpV1(value.op, `${label} op`),
|
|
195
|
+
createdAt: timestamp(value.createdAt, `${label} createdAt`),
|
|
196
|
+
...(value.decision === undefined
|
|
197
|
+
? {}
|
|
198
|
+
: { decision: decision(value.decision, `${label} decision`) }),
|
|
199
|
+
...(value.decidedAt === undefined
|
|
200
|
+
? {}
|
|
201
|
+
: { decidedAt: timestamp(value.decidedAt, `${label} decidedAt`) }),
|
|
202
|
+
...(value.dispatchedAt === undefined
|
|
203
|
+
? {}
|
|
204
|
+
: {
|
|
205
|
+
dispatchedAt: timestamp(value.dispatchedAt, `${label} dispatchedAt`),
|
|
206
|
+
}),
|
|
207
|
+
...(value.outcome === undefined
|
|
208
|
+
? {}
|
|
209
|
+
: { outcome: value.outcome as MachineIntentOutcomeV1 }),
|
|
210
|
+
...(value.reason === undefined
|
|
211
|
+
? {}
|
|
212
|
+
: {
|
|
213
|
+
reason: text(
|
|
214
|
+
value.reason,
|
|
215
|
+
MACHINE_LIMITS_V1.message,
|
|
216
|
+
`${label} reason`,
|
|
217
|
+
),
|
|
218
|
+
}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The command an approved intent dispatches.
|
|
224
|
+
*
|
|
225
|
+
* `commandId` is carried over rather than minted, which is the whole
|
|
226
|
+
* idempotency story: a settlement replayed after an eviction builds the
|
|
227
|
+
* byte-identical command and the queue answers `duplicate`.
|
|
228
|
+
*/
|
|
229
|
+
export function machineCommandForIntentV1(
|
|
230
|
+
intent: MachineIntentRecordV1,
|
|
231
|
+
issuedAt: string,
|
|
232
|
+
): MachineCommandV1 {
|
|
233
|
+
return {
|
|
234
|
+
schemaVersion: 1,
|
|
235
|
+
commandId: intent.commandId,
|
|
236
|
+
machineId: intent.machineId,
|
|
237
|
+
botId: intent.botId,
|
|
238
|
+
runId: intent.runId,
|
|
239
|
+
turn: intent.turn,
|
|
240
|
+
approvalId: intent.approvalId,
|
|
241
|
+
op: intent.op,
|
|
242
|
+
issuedAt,
|
|
243
|
+
status: "queued",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The intent as the settling transaction leaves it.
|
|
249
|
+
*
|
|
250
|
+
* First write wins here too: an intent that already carries a decision is
|
|
251
|
+
* returned unchanged, so an alarm racing a person cannot overwrite the answer
|
|
252
|
+
* the person gave.
|
|
253
|
+
*/
|
|
254
|
+
export function settledMachineIntentV1(
|
|
255
|
+
intent: MachineIntentRecordV1,
|
|
256
|
+
answer: "approved" | "denied" | "expired",
|
|
257
|
+
at: string,
|
|
258
|
+
): MachineIntentRecordV1 {
|
|
259
|
+
if (intent.decision !== undefined) return intent;
|
|
260
|
+
return {
|
|
261
|
+
...intent,
|
|
262
|
+
decision: answer,
|
|
263
|
+
decidedAt: at,
|
|
264
|
+
// A denial or an expiry is terminal at the moment it is recorded: nothing
|
|
265
|
+
// is dispatched, and there is no later step to wait for.
|
|
266
|
+
...(answer === "approved" ? {} : { outcome: answer }),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The intent once the queue has answered a dispatch. */
|
|
271
|
+
export function dispatchedMachineIntentV1(
|
|
272
|
+
intent: MachineIntentRecordV1,
|
|
273
|
+
outcome: MachineIntentOutcomeV1,
|
|
274
|
+
at: string,
|
|
275
|
+
reason?: string,
|
|
276
|
+
): MachineIntentRecordV1 {
|
|
277
|
+
return {
|
|
278
|
+
...intent,
|
|
279
|
+
...(outcome === "refused" ? {} : { dispatchedAt: at }),
|
|
280
|
+
outcome,
|
|
281
|
+
...(reason === undefined
|
|
282
|
+
? {}
|
|
283
|
+
: { reason: reason.slice(0, MACHINE_LIMITS_V1.message) }),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The sentence the approval card puts in front of the User. */
|
|
288
|
+
export function machineApprovalActionV1(
|
|
289
|
+
op: MachineOpV1,
|
|
290
|
+
label: string,
|
|
291
|
+
): string {
|
|
292
|
+
switch (op.kind) {
|
|
293
|
+
case "exec":
|
|
294
|
+
return `Run on ${label}: ${op.command}${op.cwd === undefined ? "" : ` (in ${op.cwd})`}`;
|
|
295
|
+
case "read":
|
|
296
|
+
return `Read ${op.path} from ${label}`;
|
|
297
|
+
case "copy-to-computer":
|
|
298
|
+
return `Copy ${op.path} from ${label} into the Computer workspace at ${op.workspacePath}`;
|
|
299
|
+
case "copy-from-computer":
|
|
300
|
+
return `Copy ${op.workspacePath} from the Computer workspace onto ${label} at ${op.path}`;
|
|
301
|
+
case "messages":
|
|
302
|
+
// Row 57g. Only `send` ever reaches a card — the six reads are exempt —
|
|
303
|
+
// but the sentence is written for the whole variant so a later call that
|
|
304
|
+
// does take one cannot fall through to something vague. The card carries
|
|
305
|
+
// the *exact text*, because approving a message you have not read is not
|
|
306
|
+
// approving anything.
|
|
307
|
+
return op.call.kind === "send"
|
|
308
|
+
? `Send an iMessage from ${label} to ${op.call.to}: "${op.call.text}"`
|
|
309
|
+
: `Read Messages on ${label} (${op.call.kind})`;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Why the card is being shown, in the words the User reads under the action. */
|
|
314
|
+
export function machineApprovalRationaleV1(
|
|
315
|
+
op: MachineOpV1,
|
|
316
|
+
label: string,
|
|
317
|
+
): string {
|
|
318
|
+
if (op.kind === "exec") {
|
|
319
|
+
return `This runs on your own machine "${label}", outside the Computer sandbox, with your account's permissions.`;
|
|
320
|
+
}
|
|
321
|
+
if (op.kind === "messages") {
|
|
322
|
+
return `This sends from Messages.app on your own machine "${label}", as you. The person receiving it sees a message from you, and it cannot be unsent.`;
|
|
323
|
+
}
|
|
324
|
+
return `This touches the filesystem of your own machine "${label}", which is separate from the Computer workspace.`;
|
|
325
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { MACHINE_LIMITS_V1 } from "@frockbot/machine-protocol";
|
|
3
|
+
import {
|
|
4
|
+
machinePairingCodeDigestV1,
|
|
5
|
+
machinePairingNonceV1,
|
|
6
|
+
mintMachinePairingCodeV1,
|
|
7
|
+
verifyMachinePairingCodeV1,
|
|
8
|
+
} from "./pairing.ts";
|
|
9
|
+
|
|
10
|
+
const SECRET = "machine-pairing-secret-0123456789abcdef";
|
|
11
|
+
|
|
12
|
+
describe("the pairing code", () => {
|
|
13
|
+
test("round-trips the User and machine it was minted for", async () => {
|
|
14
|
+
const claims = {
|
|
15
|
+
userId: "user-with-a-fairly-long-identifier-0123",
|
|
16
|
+
machineId: crypto.randomUUID(),
|
|
17
|
+
nonce: machinePairingNonceV1(),
|
|
18
|
+
};
|
|
19
|
+
const code = await mintMachinePairingCodeV1(SECRET, claims);
|
|
20
|
+
expect(code.length).toBeLessThanOrEqual(MACHINE_LIMITS_V1.pairingCode);
|
|
21
|
+
expect(await verifyMachinePairingCodeV1(SECRET, code)).toEqual(claims);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("a tampered payload, a wrong secret and a truncated code are all refused", async () => {
|
|
25
|
+
const claims = {
|
|
26
|
+
userId: "pairing-user",
|
|
27
|
+
machineId: crypto.randomUUID(),
|
|
28
|
+
nonce: machinePairingNonceV1(),
|
|
29
|
+
};
|
|
30
|
+
const code = await mintMachinePairingCodeV1(SECRET, claims);
|
|
31
|
+
const [payload, tag] = [
|
|
32
|
+
code.slice(0, code.lastIndexOf(".")),
|
|
33
|
+
code.slice(code.lastIndexOf(".") + 1),
|
|
34
|
+
];
|
|
35
|
+
const otherMachine = await mintMachinePairingCodeV1(SECRET, {
|
|
36
|
+
...claims,
|
|
37
|
+
machineId: crypto.randomUUID(),
|
|
38
|
+
});
|
|
39
|
+
// The tag of one code on the payload of another: the pairing that would
|
|
40
|
+
// let a caller redirect an offer at a machine it names itself.
|
|
41
|
+
const spliced = `${payload}.${otherMachine.slice(otherMachine.lastIndexOf(".") + 1)}`;
|
|
42
|
+
for (const forged of [
|
|
43
|
+
spliced,
|
|
44
|
+
`${payload}x.${tag}`,
|
|
45
|
+
payload,
|
|
46
|
+
code.slice(0, -1),
|
|
47
|
+
"",
|
|
48
|
+
]) {
|
|
49
|
+
await expect(verifyMachinePairingCodeV1(SECRET, forged)).rejects.toThrow(
|
|
50
|
+
/invalid/,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
await expect(
|
|
54
|
+
verifyMachinePairingCodeV1(`${SECRET}-other`, code),
|
|
55
|
+
).rejects.toThrow(/invalid/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("no two mints are the same code, and the digest is not the code", async () => {
|
|
59
|
+
const machineId = crypto.randomUUID();
|
|
60
|
+
const first = await mintMachinePairingCodeV1(SECRET, {
|
|
61
|
+
userId: "u",
|
|
62
|
+
machineId,
|
|
63
|
+
nonce: machinePairingNonceV1(),
|
|
64
|
+
});
|
|
65
|
+
const second = await mintMachinePairingCodeV1(SECRET, {
|
|
66
|
+
userId: "u",
|
|
67
|
+
machineId,
|
|
68
|
+
nonce: machinePairingNonceV1(),
|
|
69
|
+
});
|
|
70
|
+
expect(first).not.toBe(second);
|
|
71
|
+
const digest = await machinePairingCodeDigestV1(first);
|
|
72
|
+
expect(digest).toMatch(/^[0-9a-f]{64}$/);
|
|
73
|
+
expect(digest).not.toContain(first);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a secret that is missing or too weak refuses to mint", async () => {
|
|
77
|
+
await expect(
|
|
78
|
+
mintMachinePairingCodeV1("short", {
|
|
79
|
+
userId: "u",
|
|
80
|
+
machineId: crypto.randomUUID(),
|
|
81
|
+
nonce: machinePairingNonceV1(),
|
|
82
|
+
}),
|
|
83
|
+
).rejects.toThrow(/MACHINE_TOKEN_SECRET/);
|
|
84
|
+
});
|
|
85
|
+
});
|
package/src/pairing.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The pairing code: the one secret a browser ever holds for a machine.
|
|
2
|
+
//
|
|
3
|
+
// A device agent that has never enrolled has no token, so the code is the only
|
|
4
|
+
// thing it can present — and `POST /api/machines/enroll` runs *before* gateway
|
|
5
|
+
// authentication, because a program on somebody's laptop has no session. That
|
|
6
|
+
// puts the code in exactly the position `plugin-routines`' webhook key is in:
|
|
7
|
+
// the gateway is stateless and cannot map a machine to its User, so a code that
|
|
8
|
+
// did not name one would force an anonymous caller to decide which Durable
|
|
9
|
+
// Object gets created. So the code is a signed token, not a random string:
|
|
10
|
+
//
|
|
11
|
+
// base64url(userId) "." machineId "." nonce "." truncated-HMAC
|
|
12
|
+
//
|
|
13
|
+
// The signature is checked at the edge, in constant time, before any object is
|
|
14
|
+
// addressed; the User Durable Object then checks the code's digest against the
|
|
15
|
+
// unspent pairing record it holds, which is what makes the code *one-time* and
|
|
16
|
+
// what expires it after five minutes. Neither check is sufficient alone: the
|
|
17
|
+
// signature proves only that this deployment minted the code, and the record
|
|
18
|
+
// proves only that some code was minted for this machine.
|
|
19
|
+
//
|
|
20
|
+
// The code is derived and never stored, exactly as the machine token is: the
|
|
21
|
+
// backend keeps `SHA-256(code)` and nothing else.
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
constantTimeEqualsV1,
|
|
25
|
+
MACHINE_LIMITS_V1,
|
|
26
|
+
MachineTokenError,
|
|
27
|
+
} from "@frockbot/machine-protocol";
|
|
28
|
+
|
|
29
|
+
/** What a verified pairing code names. */
|
|
30
|
+
export interface MachinePairingClaimsV1 {
|
|
31
|
+
userId: string;
|
|
32
|
+
machineId: string;
|
|
33
|
+
nonce: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The one thing a failed verify ever says. Which half failed is not the caller's. */
|
|
37
|
+
const INVALID = "machine pairing code is invalid";
|
|
38
|
+
|
|
39
|
+
const TEXT = new TextEncoder();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 128 bits of tag and 96 bits of nonce.
|
|
43
|
+
*
|
|
44
|
+
* The full `HMAC-SHA256` output would be 43 base64url characters and the code
|
|
45
|
+
* has a length ceiling it must live inside — `MACHINE_LIMITS_V1.pairingCode` —
|
|
46
|
+
* which the User claim already spends most of. A 128-bit tag is the standard
|
|
47
|
+
* truncation and is not the code's only defence: the code is single-use, dies
|
|
48
|
+
* in five minutes, and is checked against a stored digest.
|
|
49
|
+
*/
|
|
50
|
+
const TAG_BYTES = 16;
|
|
51
|
+
const NONCE_BYTES = 12;
|
|
52
|
+
|
|
53
|
+
function base64url(bytes: Uint8Array): string {
|
|
54
|
+
let binary = "";
|
|
55
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
56
|
+
return btoa(binary)
|
|
57
|
+
.replace(/\+/g, "-")
|
|
58
|
+
.replace(/\//g, "_")
|
|
59
|
+
.replace(/=+$/, "");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function fromBase64url(value: string): Uint8Array {
|
|
63
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
64
|
+
const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
|
|
65
|
+
const bytes = new Uint8Array(binary.length);
|
|
66
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
67
|
+
bytes[index] = binary.charCodeAt(index);
|
|
68
|
+
}
|
|
69
|
+
return bytes;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hex(bytes: ArrayBuffer): string {
|
|
73
|
+
return [...new Uint8Array(bytes)]
|
|
74
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
75
|
+
.join("");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function signingKey(secret: string): Promise<CryptoKey> {
|
|
79
|
+
if (typeof secret !== "string" || secret.length < 16) {
|
|
80
|
+
throw new MachineTokenError(
|
|
81
|
+
500,
|
|
82
|
+
"MACHINE_TOKEN_SECRET is missing or too short to mint a pairing code",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return crypto.subtle.importKey(
|
|
86
|
+
"raw",
|
|
87
|
+
TEXT.encode(secret),
|
|
88
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
89
|
+
false,
|
|
90
|
+
["sign"],
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function tag(secret: string, payload: string): Promise<string> {
|
|
95
|
+
const signature = await crypto.subtle.sign(
|
|
96
|
+
"HMAC",
|
|
97
|
+
await signingKey(secret),
|
|
98
|
+
TEXT.encode(payload),
|
|
99
|
+
);
|
|
100
|
+
return base64url(new Uint8Array(signature).slice(0, TAG_BYTES));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A fresh, unpredictable nonce. One per mint, so no two codes are the same. */
|
|
104
|
+
export function machinePairingNonceV1(): string {
|
|
105
|
+
return base64url(crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `SHA-256` of a code, hex. The only form of a code the backend keeps. */
|
|
109
|
+
export async function machinePairingCodeDigestV1(
|
|
110
|
+
code: string,
|
|
111
|
+
): Promise<string> {
|
|
112
|
+
return hex(await crypto.subtle.digest("SHA-256", TEXT.encode(code)));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Mint one pairing code. Deterministic given the nonce, so a test can mint the
|
|
117
|
+
* exact code it is about to present and a forged one it must be refused.
|
|
118
|
+
*/
|
|
119
|
+
export async function mintMachinePairingCodeV1(
|
|
120
|
+
secret: string,
|
|
121
|
+
claims: MachinePairingClaimsV1,
|
|
122
|
+
): Promise<string> {
|
|
123
|
+
const payload = `${base64url(TEXT.encode(claims.userId))}.${claims.machineId}.${claims.nonce}`;
|
|
124
|
+
const code = `${payload}.${await tag(secret, payload)}`;
|
|
125
|
+
if (code.length > MACHINE_LIMITS_V1.pairingCode) {
|
|
126
|
+
// Refused rather than truncated: a code the enrollment decoder would
|
|
127
|
+
// refuse is a pairing that fails at the machine instead of here, with
|
|
128
|
+
// nothing to say why.
|
|
129
|
+
throw new MachineTokenError(
|
|
130
|
+
500,
|
|
131
|
+
"machine pairing code exceeds its length bound for this account",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return code;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Verify a presented code and answer with the claims it carries.
|
|
139
|
+
*
|
|
140
|
+
* This is the edge's whole decision. Whether the code has been spent, has
|
|
141
|
+
* expired, or was ever minted for this machine is the User Durable Object's to
|
|
142
|
+
* answer against the pairing record it holds — and it is asked only after the
|
|
143
|
+
* code proved it was minted here.
|
|
144
|
+
*/
|
|
145
|
+
export async function verifyMachinePairingCodeV1(
|
|
146
|
+
secret: string,
|
|
147
|
+
code: string,
|
|
148
|
+
): Promise<MachinePairingClaimsV1> {
|
|
149
|
+
if (
|
|
150
|
+
typeof code !== "string" ||
|
|
151
|
+
code.length === 0 ||
|
|
152
|
+
code.length > MACHINE_LIMITS_V1.pairingCode
|
|
153
|
+
) {
|
|
154
|
+
throw new MachineTokenError(401, INVALID);
|
|
155
|
+
}
|
|
156
|
+
const separator = code.lastIndexOf(".");
|
|
157
|
+
if (separator <= 0) throw new MachineTokenError(401, INVALID);
|
|
158
|
+
const payload = code.slice(0, separator);
|
|
159
|
+
const presented = code.slice(separator + 1);
|
|
160
|
+
let expected: string;
|
|
161
|
+
try {
|
|
162
|
+
expected = await tag(secret, payload);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error instanceof MachineTokenError) throw error;
|
|
165
|
+
throw new MachineTokenError(401, INVALID);
|
|
166
|
+
}
|
|
167
|
+
if (!constantTimeEqualsV1(expected, presented)) {
|
|
168
|
+
throw new MachineTokenError(401, INVALID);
|
|
169
|
+
}
|
|
170
|
+
const parts = payload.split(".");
|
|
171
|
+
if (parts.length !== 3) throw new MachineTokenError(401, INVALID);
|
|
172
|
+
let userId: string;
|
|
173
|
+
try {
|
|
174
|
+
userId = new TextDecoder().decode(fromBase64url(parts[0]!));
|
|
175
|
+
} catch {
|
|
176
|
+
throw new MachineTokenError(401, INVALID);
|
|
177
|
+
}
|
|
178
|
+
if (userId.length === 0 || userId.length > 256) {
|
|
179
|
+
throw new MachineTokenError(401, INVALID);
|
|
180
|
+
}
|
|
181
|
+
return { userId, machineId: parts[1]!, nonce: parts[2]! };
|
|
182
|
+
}
|