@frockbot/machine-protocol 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/package.json +22 -6
- package/src/index.ts +4 -0
- package/src/messages.test.ts +200 -0
- package/src/protocol.test.ts +406 -0
- package/src/protocol.ts +1540 -0
- package/src/quota.test.ts +156 -0
- package/src/quota.ts +152 -0
- package/src/routes.test.ts +105 -0
- package/src/routes.ts +161 -0
- package/src/token.test.ts +149 -0
- package/src/token.ts +241 -0
- package/tsconfig.json +14 -0
- package/README.md +0 -3
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
MACHINE_AUTHORIZATION_SCHEME,
|
|
4
|
+
MachineTokenError,
|
|
5
|
+
constantTimeEqualsV1,
|
|
6
|
+
machineBearerTokenV1,
|
|
7
|
+
machineTokenClaimsV1,
|
|
8
|
+
machineTokenDigestV1,
|
|
9
|
+
machineTokenMatchesRecordV1,
|
|
10
|
+
mintMachineTokenV1,
|
|
11
|
+
verifyMachineTokenV1,
|
|
12
|
+
} from "./token.ts";
|
|
13
|
+
|
|
14
|
+
const SECRET = "machine-token-secret-value-32-bytes";
|
|
15
|
+
const MACHINE_ID = "994dc2ee-3f42-4a4d-9f2a-0a3f6f0d1b77";
|
|
16
|
+
const CLAIMS = { u: "user-1", m: MACHINE_ID, v: 1 } as const;
|
|
17
|
+
|
|
18
|
+
describe("machine token", () => {
|
|
19
|
+
test("round-trips its claims and is deterministic", async () => {
|
|
20
|
+
const token = await mintMachineTokenV1(SECRET, CLAIMS);
|
|
21
|
+
expect(await mintMachineTokenV1(SECRET, CLAIMS)).toBe(token);
|
|
22
|
+
expect(await verifyMachineTokenV1(SECRET, token)).toEqual({ ...CLAIMS });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("a tampered payload, signature or secret never verifies", async () => {
|
|
26
|
+
const token = await mintMachineTokenV1(SECRET, CLAIMS);
|
|
27
|
+
const [payload, signature] = token.split(".");
|
|
28
|
+
const forged = btoa(JSON.stringify({ u: "user-2", m: MACHINE_ID, v: 1 }))
|
|
29
|
+
.replace(/\+/g, "-")
|
|
30
|
+
.replace(/\//g, "_")
|
|
31
|
+
.replace(/=+$/, "");
|
|
32
|
+
for (const bad of [
|
|
33
|
+
`${forged}.${signature}`,
|
|
34
|
+
`${payload}.${signature!.slice(0, -1)}A`,
|
|
35
|
+
payload!,
|
|
36
|
+
"",
|
|
37
|
+
"x".repeat(4_096),
|
|
38
|
+
]) {
|
|
39
|
+
await expect(verifyMachineTokenV1(SECRET, bad)).rejects.toThrow(
|
|
40
|
+
MachineTokenError,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
await expect(
|
|
44
|
+
verifyMachineTokenV1("another-secret-of-sufficient-length", token),
|
|
45
|
+
).rejects.toThrow(/machine token is invalid/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a secret too short to be a secret fails loudly, not silently", async () => {
|
|
49
|
+
await expect(mintMachineTokenV1("short", CLAIMS)).rejects.toThrow(
|
|
50
|
+
/MACHINE_TOKEN_SECRET/,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("claims are exact-key and bounded", () => {
|
|
55
|
+
expect(machineTokenClaimsV1({ ...CLAIMS })).toEqual({ ...CLAIMS });
|
|
56
|
+
for (const bad of [
|
|
57
|
+
{ ...CLAIMS, extra: 1 },
|
|
58
|
+
{ u: "", m: MACHINE_ID, v: 1 },
|
|
59
|
+
{ u: "user-1", m: "not a machine id", v: 1 },
|
|
60
|
+
{ u: "user-1", m: MACHINE_ID, v: 0 },
|
|
61
|
+
{ u: "user-1", m: MACHINE_ID, v: 1.5 },
|
|
62
|
+
{ u: "user-1", m: MACHINE_ID },
|
|
63
|
+
[CLAIMS],
|
|
64
|
+
null,
|
|
65
|
+
]) {
|
|
66
|
+
expect(() => machineTokenClaimsV1(bad)).toThrow(MachineTokenError);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("verification says nothing about which half failed", async () => {
|
|
71
|
+
const token = await mintMachineTokenV1(SECRET, CLAIMS);
|
|
72
|
+
const failure = await verifyMachineTokenV1(SECRET, `${token}x`).catch(
|
|
73
|
+
(error: MachineTokenError) => error,
|
|
74
|
+
);
|
|
75
|
+
expect((failure as MachineTokenError).status).toBe(401);
|
|
76
|
+
expect((failure as MachineTokenError).message).toBe(
|
|
77
|
+
"machine token is invalid",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("digest-only storage", () => {
|
|
83
|
+
test("the digest is a hex SHA-256 and the token is not recoverable from it", async () => {
|
|
84
|
+
const token = await mintMachineTokenV1(SECRET, CLAIMS);
|
|
85
|
+
const digest = await machineTokenDigestV1(token);
|
|
86
|
+
expect(digest).toMatch(/^[0-9a-f]{64}$/);
|
|
87
|
+
expect(digest).not.toContain(token.slice(0, 8));
|
|
88
|
+
expect(await machineTokenDigestV1(token)).toBe(digest);
|
|
89
|
+
expect(await machineTokenDigestV1(`${token} `)).not.toBe(digest);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("the record check refuses a wrong key version, a wrong digest and a revocation", async () => {
|
|
93
|
+
const token = await mintMachineTokenV1(SECRET, CLAIMS);
|
|
94
|
+
const digest = await machineTokenDigestV1(token);
|
|
95
|
+
const record = { keyVersion: 1, tokenDigest: digest };
|
|
96
|
+
expect(machineTokenMatchesRecordV1(record, CLAIMS, digest)).toBe(true);
|
|
97
|
+
// Revocation bumps the key version: a token minted at v1 verifies at the
|
|
98
|
+
// edge and is still refused by the record, which is the second check.
|
|
99
|
+
const rotated = await mintMachineTokenV1(SECRET, { ...CLAIMS, v: 2 });
|
|
100
|
+
expect(
|
|
101
|
+
machineTokenMatchesRecordV1(
|
|
102
|
+
record,
|
|
103
|
+
{ ...CLAIMS, v: 2 },
|
|
104
|
+
await machineTokenDigestV1(rotated),
|
|
105
|
+
),
|
|
106
|
+
).toBe(false);
|
|
107
|
+
expect(machineTokenMatchesRecordV1(record, CLAIMS, "b".repeat(64))).toBe(
|
|
108
|
+
false,
|
|
109
|
+
);
|
|
110
|
+
expect(
|
|
111
|
+
machineTokenMatchesRecordV1(
|
|
112
|
+
{ ...record, revokedAt: "2026-09-01T00:00:00.000Z" },
|
|
113
|
+
CLAIMS,
|
|
114
|
+
digest,
|
|
115
|
+
),
|
|
116
|
+
).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("constant-time compare", () => {
|
|
121
|
+
test("answers on content, not on where the first difference is", () => {
|
|
122
|
+
expect(constantTimeEqualsV1("abc", "abc")).toBe(true);
|
|
123
|
+
expect(constantTimeEqualsV1("abc", "abd")).toBe(false);
|
|
124
|
+
expect(constantTimeEqualsV1("abc", "zbc")).toBe(false);
|
|
125
|
+
expect(constantTimeEqualsV1("abc", "abcd")).toBe(false);
|
|
126
|
+
expect(constantTimeEqualsV1("", "")).toBe(true);
|
|
127
|
+
expect(constantTimeEqualsV1("abc", "")).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("bearer presentation", () => {
|
|
132
|
+
test("reads a token only under the declared scheme", () => {
|
|
133
|
+
expect(
|
|
134
|
+
machineBearerTokenV1(`${MACHINE_AUTHORIZATION_SCHEME} abc.def`),
|
|
135
|
+
).toBe("abc.def");
|
|
136
|
+
expect(machineBearerTokenV1("Bearer abc.def ")).toBe("abc.def");
|
|
137
|
+
for (const bad of [
|
|
138
|
+
"bearer abc.def",
|
|
139
|
+
"Basic abc.def",
|
|
140
|
+
"abc.def",
|
|
141
|
+
"Bearer ",
|
|
142
|
+
"Bearer",
|
|
143
|
+
null,
|
|
144
|
+
undefined,
|
|
145
|
+
]) {
|
|
146
|
+
expect(machineBearerTokenV1(bad)).toBeUndefined();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
package/src/token.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// The machine door: the key an agent presents, and the two places it is checked.
|
|
2
|
+
//
|
|
3
|
+
// A device agent has no session — it is a program on the User's laptop, not a
|
|
4
|
+
// browser — so `poll`, `claim` and `result` run before gateway authentication.
|
|
5
|
+
// That makes the token the only thing standing between the open internet and a
|
|
6
|
+
// Durable Object, and one check is not enough. This is `plugin-routines`'
|
|
7
|
+
// webhook door, port for port:
|
|
8
|
+
//
|
|
9
|
+
// 1. **At the edge**, the token is a *self-describing signed token*. Its
|
|
10
|
+
// payload names the User, the machine and the key version; its signature is
|
|
11
|
+
// `HMAC-SHA256(MACHINE_TOKEN_SECRET, payload)`. The gateway is stateless
|
|
12
|
+
// and cannot map a machine to its User, so without the claims it could not
|
|
13
|
+
// address a Durable Object at all without first creating one — which would
|
|
14
|
+
// hand an anonymous caller Durable Object creation. A token that does not
|
|
15
|
+
// verify never reaches an object.
|
|
16
|
+
// 2. **In the User Durable Object**, `SHA-256(token)` is compared against the
|
|
17
|
+
// `tokenDigest` on the machine record, together with its `keyVersion`. That
|
|
18
|
+
// record is the authority: revocation bumps the version and sets
|
|
19
|
+
// `revokedAt`, so a token that verified at the edge is still refused the
|
|
20
|
+
// instant it is no longer the machine's.
|
|
21
|
+
//
|
|
22
|
+
// The token is derived, never stored: given the secret and the three payload
|
|
23
|
+
// fields it is reproducible, and the digest is all the backend keeps. Nothing
|
|
24
|
+
// here writes key material to durable storage, and no view carries any.
|
|
25
|
+
|
|
26
|
+
import { decodeMachineIdV1 } from "./protocol.js";
|
|
27
|
+
|
|
28
|
+
/** The self-describing claims a machine token carries. */
|
|
29
|
+
export interface MachineTokenClaimsV1 {
|
|
30
|
+
/** User. */
|
|
31
|
+
u: string;
|
|
32
|
+
/** Machine. */
|
|
33
|
+
m: string;
|
|
34
|
+
/** Key version. */
|
|
35
|
+
v: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class MachineTokenError extends Error {
|
|
39
|
+
override readonly name = "MachineTokenError";
|
|
40
|
+
readonly status: number;
|
|
41
|
+
constructor(status: number, message: string) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.status = status;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The one thing a failed verify ever says. Which half failed is not the caller's. */
|
|
48
|
+
const INVALID = "machine token is invalid";
|
|
49
|
+
|
|
50
|
+
const TEXT = new TextEncoder();
|
|
51
|
+
|
|
52
|
+
function base64url(bytes: Uint8Array): string {
|
|
53
|
+
let binary = "";
|
|
54
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
55
|
+
return btoa(binary)
|
|
56
|
+
.replace(/\+/g, "-")
|
|
57
|
+
.replace(/\//g, "_")
|
|
58
|
+
.replace(/=+$/, "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fromBase64url(value: string): Uint8Array {
|
|
62
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
63
|
+
const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
|
|
64
|
+
const bytes = new Uint8Array(binary.length);
|
|
65
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
66
|
+
bytes[index] = binary.charCodeAt(index);
|
|
67
|
+
}
|
|
68
|
+
return bytes;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hex(bytes: ArrayBuffer): string {
|
|
72
|
+
return [...new Uint8Array(bytes)]
|
|
73
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
74
|
+
.join("");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Constant-time comparison. A signature check that returns early on the first
|
|
79
|
+
* differing byte leaks the signature one byte at a time to anyone willing to
|
|
80
|
+
* time it, and this check is the whole of the edge's authority.
|
|
81
|
+
*/
|
|
82
|
+
export function constantTimeEqualsV1(left: string, right: string): boolean {
|
|
83
|
+
const a = TEXT.encode(left);
|
|
84
|
+
const b = TEXT.encode(right);
|
|
85
|
+
// The lengths themselves are not secret; the contents are, so the loop runs
|
|
86
|
+
// over a fixed span either way.
|
|
87
|
+
let mismatch = a.length ^ b.length;
|
|
88
|
+
const span = Math.max(a.length, b.length);
|
|
89
|
+
for (let index = 0; index < span; index += 1) {
|
|
90
|
+
mismatch |= (a[index] ?? 0) ^ (b[index] ?? 0);
|
|
91
|
+
}
|
|
92
|
+
return mismatch === 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function signingKey(secret: string): Promise<CryptoKey> {
|
|
96
|
+
if (typeof secret !== "string" || secret.length < 16) {
|
|
97
|
+
throw new MachineTokenError(
|
|
98
|
+
500,
|
|
99
|
+
"MACHINE_TOKEN_SECRET is missing or too short for machine enrollment",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return crypto.subtle.importKey(
|
|
103
|
+
"raw",
|
|
104
|
+
TEXT.encode(secret),
|
|
105
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
106
|
+
false,
|
|
107
|
+
["sign"],
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `SHA-256` of a token, hex. The only form of a key the backend keeps. */
|
|
112
|
+
export async function machineTokenDigestV1(token: string): Promise<string> {
|
|
113
|
+
return hex(await crypto.subtle.digest("SHA-256", TEXT.encode(token)));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Mint the token for one machine at one key version. Deterministic. */
|
|
117
|
+
export async function mintMachineTokenV1(
|
|
118
|
+
secret: string,
|
|
119
|
+
claims: MachineTokenClaimsV1,
|
|
120
|
+
): Promise<string> {
|
|
121
|
+
const payload = base64url(
|
|
122
|
+
TEXT.encode(JSON.stringify({ u: claims.u, m: claims.m, v: claims.v })),
|
|
123
|
+
);
|
|
124
|
+
const signature = await crypto.subtle.sign(
|
|
125
|
+
"HMAC",
|
|
126
|
+
await signingKey(secret),
|
|
127
|
+
TEXT.encode(payload),
|
|
128
|
+
);
|
|
129
|
+
return `${payload}.${base64url(new Uint8Array(signature))}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Verify a presented token and answer with the claims it carries.
|
|
134
|
+
*
|
|
135
|
+
* This is the edge's whole decision. It says nothing about whether the machine
|
|
136
|
+
* exists, is revoked, or still holds this key version — those are the User
|
|
137
|
+
* Durable Object's to answer against `tokenDigest`, and are answered only after
|
|
138
|
+
* the token proved it was minted here.
|
|
139
|
+
*/
|
|
140
|
+
export async function verifyMachineTokenV1(
|
|
141
|
+
secret: string,
|
|
142
|
+
token: string,
|
|
143
|
+
): Promise<MachineTokenClaimsV1> {
|
|
144
|
+
if (typeof token !== "string" || token.length === 0 || token.length > 2_048) {
|
|
145
|
+
throw new MachineTokenError(401, INVALID);
|
|
146
|
+
}
|
|
147
|
+
const separator = token.lastIndexOf(".");
|
|
148
|
+
if (separator <= 0) throw new MachineTokenError(401, INVALID);
|
|
149
|
+
const payload = token.slice(0, separator);
|
|
150
|
+
const presented = token.slice(separator + 1);
|
|
151
|
+
let expected: string;
|
|
152
|
+
try {
|
|
153
|
+
expected = base64url(
|
|
154
|
+
new Uint8Array(
|
|
155
|
+
await crypto.subtle.sign(
|
|
156
|
+
"HMAC",
|
|
157
|
+
await signingKey(secret),
|
|
158
|
+
TEXT.encode(payload),
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error instanceof MachineTokenError) throw error;
|
|
164
|
+
throw new MachineTokenError(401, INVALID);
|
|
165
|
+
}
|
|
166
|
+
if (!constantTimeEqualsV1(expected, presented)) {
|
|
167
|
+
throw new MachineTokenError(401, INVALID);
|
|
168
|
+
}
|
|
169
|
+
let decoded: unknown;
|
|
170
|
+
try {
|
|
171
|
+
decoded = JSON.parse(new TextDecoder().decode(fromBase64url(payload)));
|
|
172
|
+
} catch {
|
|
173
|
+
throw new MachineTokenError(401, INVALID);
|
|
174
|
+
}
|
|
175
|
+
return machineTokenClaimsV1(decoded);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Decode the claims half of a token. Exact-key, like every other seam. */
|
|
179
|
+
export function machineTokenClaimsV1(value: unknown): MachineTokenClaimsV1 {
|
|
180
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
181
|
+
throw new MachineTokenError(401, INVALID);
|
|
182
|
+
}
|
|
183
|
+
const candidate = value as Record<string, unknown>;
|
|
184
|
+
for (const key of Object.keys(candidate)) {
|
|
185
|
+
if (key !== "u" && key !== "m" && key !== "v") {
|
|
186
|
+
throw new MachineTokenError(401, INVALID);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const held = candidate.u;
|
|
190
|
+
if (typeof held !== "string" || held.length === 0 || held.length > 256) {
|
|
191
|
+
throw new MachineTokenError(401, INVALID);
|
|
192
|
+
}
|
|
193
|
+
let machineId: string;
|
|
194
|
+
try {
|
|
195
|
+
machineId = decodeMachineIdV1(candidate.m, "machine token m");
|
|
196
|
+
} catch {
|
|
197
|
+
throw new MachineTokenError(401, INVALID);
|
|
198
|
+
}
|
|
199
|
+
if (
|
|
200
|
+
!Number.isSafeInteger(candidate.v) ||
|
|
201
|
+
(candidate.v as number) < 1 ||
|
|
202
|
+
(candidate.v as number) > 1_000_000
|
|
203
|
+
) {
|
|
204
|
+
throw new MachineTokenError(401, INVALID);
|
|
205
|
+
}
|
|
206
|
+
return { u: held, m: machineId, v: candidate.v as number };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The second check, expressed once so the Durable Object and its tests cannot
|
|
211
|
+
* drift: the presented token's digest must be the record's, at the record's
|
|
212
|
+
* current key version, and the record must not be revoked. Answering `false`
|
|
213
|
+
* rather than throwing keeps the caller free to choose 401 or 403.
|
|
214
|
+
*/
|
|
215
|
+
export function machineTokenMatchesRecordV1(
|
|
216
|
+
record: { keyVersion: number; tokenDigest: string; revokedAt?: string },
|
|
217
|
+
claims: MachineTokenClaimsV1,
|
|
218
|
+
presentedDigest: string,
|
|
219
|
+
): boolean {
|
|
220
|
+
if (record.revokedAt !== undefined) return false;
|
|
221
|
+
if (record.keyVersion !== claims.v) return false;
|
|
222
|
+
return constantTimeEqualsV1(record.tokenDigest, presentedDigest);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The scheme a machine presents its token under, at every machine route. */
|
|
226
|
+
export const MACHINE_AUTHORIZATION_SCHEME = "Bearer";
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The token out of an `Authorization` header, or `undefined`. Parsing lives in
|
|
230
|
+
* the protocol so the gateway, the desktop agent and the stub agent all agree
|
|
231
|
+
* on what a presented token looks like before anyone tries to verify one.
|
|
232
|
+
*/
|
|
233
|
+
export function machineBearerTokenV1(
|
|
234
|
+
header: string | null | undefined,
|
|
235
|
+
): string | undefined {
|
|
236
|
+
if (typeof header !== "string") return undefined;
|
|
237
|
+
const prefix = `${MACHINE_AUTHORIZATION_SCHEME} `;
|
|
238
|
+
if (!header.startsWith(prefix)) return undefined;
|
|
239
|
+
const token = header.slice(prefix.length).trim();
|
|
240
|
+
return token.length === 0 ? undefined : token;
|
|
241
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"lib": ["ES2023", "DOM"],
|
|
10
|
+
"types": ["bun"],
|
|
11
|
+
"allowImportingTsExtensions": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*.ts"]
|
|
14
|
+
}
|
package/README.md
DELETED