@constructive-db/machine-protocol 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/LICENSE +21 -0
- package/README.md +13 -0
- package/esm/index.js +241 -0
- package/esm/token.js +22 -0
- package/index.d.ts +210 -0
- package/index.js +250 -0
- package/package.json +29 -0
- package/token.d.ts +13 -0
- package/token.js +26 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Interweb, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @constructive-db/machine-protocol
|
|
2
|
+
|
|
3
|
+
The wire protocol shared by the machine relay (`compute/services/machine-relay`)
|
|
4
|
+
and the machine runner (`compute/lib/machine-runner`): the JSON frames a remote
|
|
5
|
+
session is made of — `open`, `input`, `resize`, `close` from the client side;
|
|
6
|
+
`output`, `exit`, `error` from the runner side; `attached` from the relay — and
|
|
7
|
+
the codec that encodes them and refuses malformed ones.
|
|
8
|
+
|
|
9
|
+
The relay never interprets terminal bytes: `input` flows client → runner and
|
|
10
|
+
`output` flows runner → client as opaque UTF-8 strings. Everything else is
|
|
11
|
+
session lifecycle. The package has no dependencies so the relay can carry it
|
|
12
|
+
into the functions image without dragging the runner's native pty bindings
|
|
13
|
+
along.
|
package/esm/index.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// The machine transport wire protocol: JSON text frames over a WebSocket. Two
|
|
2
|
+
// socket roles speak it — a runner (the daemon on the user's machine) and a
|
|
3
|
+
// client (whatever is driving a session) — with the relay in the middle routing
|
|
4
|
+
// frames between them by session id.
|
|
5
|
+
//
|
|
6
|
+
// The relay never interprets terminal bytes: `input` flows client → runner,
|
|
7
|
+
// `output` flows runner → client, both opaque UTF-8 strings. Everything else
|
|
8
|
+
// is session lifecycle.
|
|
9
|
+
//
|
|
10
|
+
// Phase 2 adds the rest of a terminal — `resize`, `signal`, `detach`,
|
|
11
|
+
// `reattach` — as frames on this same socket rather than a second transport.
|
|
12
|
+
// Each is an *operation*, not a routing hint: the relay answers every one of
|
|
13
|
+
// them by asking the tenant's SQL again, so a frame here is a request and never
|
|
14
|
+
// a decision. That is what makes a reattach from a different client safe —
|
|
15
|
+
// nothing about the attach that opened the session carries over.
|
|
16
|
+
//
|
|
17
|
+
// This package is vocabulary, never policy. Everything a socket puts on the wire
|
|
18
|
+
// — its database, its machine id — is a *coordinate*: it says which row the
|
|
19
|
+
// socket is asking about, and the relay answers by asking that database's SQL.
|
|
20
|
+
// Nothing here grants anything and no frame carries an authorization decision,
|
|
21
|
+
// so naming a coordinate buys nothing: a secret only matches a machine row in
|
|
22
|
+
// the tenant that issued it, and RLS decides everything after that. Addressing a
|
|
23
|
+
// database you have no credential for is simply a refusal.
|
|
24
|
+
// Token hashing is deliberately NOT re-exported here: it needs node's `crypto`,
|
|
25
|
+
// and this index is imported by the browser terminal. Node consumers reach it at
|
|
26
|
+
// `@constructive-db/machine-protocol/token`.
|
|
27
|
+
/**
|
|
28
|
+
* Header a runner identifies itself with at upgrade time.
|
|
29
|
+
*
|
|
30
|
+
* A coordinate, not a claim: the bearer credential is what authenticates, and
|
|
31
|
+
* the machine row it matches is what decides. A socket naming a machine it
|
|
32
|
+
* cannot present the secret for is refused.
|
|
33
|
+
*/
|
|
34
|
+
export const HEADER_MACHINE_ID = 'x-machine-id';
|
|
35
|
+
/**
|
|
36
|
+
* Header naming the tenant database the runner is enrolled in — which database
|
|
37
|
+
* to ask, never permission to be there, exactly as a sync route's hostname
|
|
38
|
+
* chooses a database before anyone is authenticated.
|
|
39
|
+
*/
|
|
40
|
+
export const HEADER_MACHINE_DATABASE = 'x-machine-database';
|
|
41
|
+
/** URL paths the relay serves. */
|
|
42
|
+
export const RUNNER_CONNECT_PATH = '/v1/machines/connect';
|
|
43
|
+
/**
|
|
44
|
+
* Where a client attaches, with its own credential as a bearer token. The
|
|
45
|
+
* database is in the path because a tenant credential is only meaningful to the
|
|
46
|
+
* tenant that issued it: the relay authenticates the token *there*.
|
|
47
|
+
*/
|
|
48
|
+
export const clientAttachPath = (databaseId, machineId) => `/v1/databases/${encodeURIComponent(databaseId)}/machines/${encodeURIComponent(machineId)}/attach`;
|
|
49
|
+
/**
|
|
50
|
+
* Subprotocol a browser attaches with.
|
|
51
|
+
*
|
|
52
|
+
* A `WebSocket` in a page cannot set request headers, so the one thing the
|
|
53
|
+
* standard does let it send — the subprotocol list — carries the credential:
|
|
54
|
+
* `[ATTACH_SUBPROTOCOL, '<credential>']`. It is the same bearer token the
|
|
55
|
+
* `Authorization` header would carry, authenticated by the same tenant
|
|
56
|
+
* `authenticate()`, and it stays out of the URL (and therefore out of access
|
|
57
|
+
* logs and `Referer`) which a query parameter would not. The relay answers with
|
|
58
|
+
* {@link ATTACH_SUBPROTOCOL} alone, so the credential is never echoed back.
|
|
59
|
+
*/
|
|
60
|
+
export const ATTACH_SUBPROTOCOL = 'constructive.machine.v1';
|
|
61
|
+
/** The subprotocol list a browser client attaches with. */
|
|
62
|
+
export const attachSubprotocols = (credential) => [
|
|
63
|
+
ATTACH_SUBPROTOCOL,
|
|
64
|
+
credential
|
|
65
|
+
];
|
|
66
|
+
/**
|
|
67
|
+
* The credential out of a subprotocol list, or null when the list is not an
|
|
68
|
+
* attach list. Only the documented two-entry shape is accepted — anything else
|
|
69
|
+
* is a client that has not agreed this protocol.
|
|
70
|
+
*/
|
|
71
|
+
export function credentialFromSubprotocols(header) {
|
|
72
|
+
if (!header)
|
|
73
|
+
return null;
|
|
74
|
+
const values = header.split(',').map(value => value.trim());
|
|
75
|
+
if (values.length !== 2 || values[0] !== ATTACH_SUBPROTOCOL || !values[1])
|
|
76
|
+
return null;
|
|
77
|
+
return values[1];
|
|
78
|
+
}
|
|
79
|
+
/** Parse {@link clientAttachPath}, or null when the path is something else. */
|
|
80
|
+
export function parseClientAttachPath(path) {
|
|
81
|
+
const match = /^\/v1\/databases\/([^/]+)\/machines\/([^/]+)\/attach$/.exec(path);
|
|
82
|
+
if (!match)
|
|
83
|
+
return null;
|
|
84
|
+
return {
|
|
85
|
+
databaseId: decodeURIComponent(match[1]),
|
|
86
|
+
machineId: decodeURIComponent(match[2])
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Signals a client may ask for. `SIGINT` and `SIGQUIT` are the terminal's own
|
|
91
|
+
* interrupt characters, so on a pty they are delivered the way a keyboard
|
|
92
|
+
* delivers them — through the line discipline, to the *foreground* process
|
|
93
|
+
* group — which is what interrupts a running command without ending the
|
|
94
|
+
* session. The rest are delivered to the session leader and do end it.
|
|
95
|
+
*/
|
|
96
|
+
export const SIGNAL_NAMES = ['SIGINT', 'SIGQUIT', 'SIGTERM', 'SIGHUP', 'SIGKILL'];
|
|
97
|
+
const FRAME_TYPES = new Set([
|
|
98
|
+
'open',
|
|
99
|
+
'input',
|
|
100
|
+
'resize',
|
|
101
|
+
'signal',
|
|
102
|
+
'detach',
|
|
103
|
+
'reattach',
|
|
104
|
+
'close',
|
|
105
|
+
'output',
|
|
106
|
+
'exit',
|
|
107
|
+
'error',
|
|
108
|
+
'scrollback',
|
|
109
|
+
'attached',
|
|
110
|
+
'detached',
|
|
111
|
+
'reattached',
|
|
112
|
+
'enrolled'
|
|
113
|
+
]);
|
|
114
|
+
/** Frame types that must carry a session id. */
|
|
115
|
+
const SESSION_FRAME_TYPES = new Set([
|
|
116
|
+
'open',
|
|
117
|
+
'input',
|
|
118
|
+
'resize',
|
|
119
|
+
'signal',
|
|
120
|
+
'detach',
|
|
121
|
+
'reattach',
|
|
122
|
+
'close',
|
|
123
|
+
'output',
|
|
124
|
+
'exit',
|
|
125
|
+
'scrollback',
|
|
126
|
+
'detached',
|
|
127
|
+
'reattached'
|
|
128
|
+
]);
|
|
129
|
+
const SIGNALS = new Set(SIGNAL_NAMES);
|
|
130
|
+
/**
|
|
131
|
+
* Terminal geometry a pty will accept. A resize is a client-supplied pair of
|
|
132
|
+
* numbers that ends up in `ioctl(TIOCSWINSZ)`, so it is validated here rather
|
|
133
|
+
* than trusted: zero, negative, fractional or absurd values are a protocol
|
|
134
|
+
* fault, not something to clamp silently.
|
|
135
|
+
*/
|
|
136
|
+
const MAX_DIMENSION = 1000;
|
|
137
|
+
function validDimension(value) {
|
|
138
|
+
return (typeof value === 'number' &&
|
|
139
|
+
Number.isInteger(value) &&
|
|
140
|
+
value > 0 &&
|
|
141
|
+
value <= MAX_DIMENSION);
|
|
142
|
+
}
|
|
143
|
+
export function encodeFrame(frame) {
|
|
144
|
+
return JSON.stringify(frame);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Parse one wire message into a frame. Malformed input is a protocol fault on
|
|
148
|
+
* the sending socket, so this throws rather than returning a placeholder —
|
|
149
|
+
* the caller decides whether that closes the connection.
|
|
150
|
+
*/
|
|
151
|
+
export function decodeFrame(raw) {
|
|
152
|
+
let parsed;
|
|
153
|
+
try {
|
|
154
|
+
parsed = JSON.parse(raw);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
throw new Error(`machine-protocol: frame is not JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
158
|
+
}
|
|
159
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
160
|
+
throw new Error('machine-protocol: frame is not an object');
|
|
161
|
+
}
|
|
162
|
+
const frame = parsed;
|
|
163
|
+
const type = frame.type;
|
|
164
|
+
if (typeof type !== 'string' || !FRAME_TYPES.has(type)) {
|
|
165
|
+
throw new Error(`machine-protocol: unknown frame type '${String(type)}'`);
|
|
166
|
+
}
|
|
167
|
+
if (SESSION_FRAME_TYPES.has(type) && typeof frame.sessionId !== 'string') {
|
|
168
|
+
throw new Error(`machine-protocol: '${type}' frame is missing sessionId`);
|
|
169
|
+
}
|
|
170
|
+
switch (type) {
|
|
171
|
+
case 'open':
|
|
172
|
+
if (typeof frame.command !== 'string' || frame.command.length === 0) {
|
|
173
|
+
throw new Error("machine-protocol: 'open' frame is missing command");
|
|
174
|
+
}
|
|
175
|
+
if (frame.cols !== undefined || frame.rows !== undefined) {
|
|
176
|
+
if (!validDimension(frame.cols) || !validDimension(frame.rows)) {
|
|
177
|
+
throw new Error(`machine-protocol: 'open' frame needs integer cols/rows in 1..${MAX_DIMENSION}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
case 'input':
|
|
182
|
+
case 'output':
|
|
183
|
+
if (typeof frame.data !== 'string') {
|
|
184
|
+
throw new Error(`machine-protocol: '${type}' frame is missing data`);
|
|
185
|
+
}
|
|
186
|
+
break;
|
|
187
|
+
case 'resize':
|
|
188
|
+
if (!validDimension(frame.cols) || !validDimension(frame.rows)) {
|
|
189
|
+
throw new Error(`machine-protocol: 'resize' frame needs integer cols/rows in 1..${MAX_DIMENSION}`);
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
case 'reattach':
|
|
193
|
+
// Geometry is optional on a reattach (a client may want the size the
|
|
194
|
+
// session already has), but a supplied one is held to the same bounds.
|
|
195
|
+
if (frame.cols !== undefined || frame.rows !== undefined) {
|
|
196
|
+
if (!validDimension(frame.cols) || !validDimension(frame.rows)) {
|
|
197
|
+
throw new Error(`machine-protocol: 'reattach' frame needs integer cols/rows in 1..${MAX_DIMENSION}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
case 'signal':
|
|
202
|
+
if (typeof frame.signal !== 'string' || !SIGNALS.has(frame.signal)) {
|
|
203
|
+
throw new Error(`machine-protocol: 'signal' frame has unknown signal '${String(frame.signal)}'`);
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
case 'scrollback':
|
|
207
|
+
if (typeof frame.data !== 'string' || typeof frame.truncated !== 'boolean') {
|
|
208
|
+
throw new Error("machine-protocol: 'scrollback' frame is missing data/truncated");
|
|
209
|
+
}
|
|
210
|
+
break;
|
|
211
|
+
case 'reattached':
|
|
212
|
+
if (typeof frame.state !== 'string' || typeof frame.lastSeq !== 'string') {
|
|
213
|
+
throw new Error("machine-protocol: 'reattached' frame is missing state/lastSeq");
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
case 'exit':
|
|
217
|
+
if (typeof frame.exitCode !== 'number') {
|
|
218
|
+
throw new Error("machine-protocol: 'exit' frame is missing exitCode");
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
case 'error':
|
|
222
|
+
if (typeof frame.message !== 'string') {
|
|
223
|
+
throw new Error("machine-protocol: 'error' frame is missing message");
|
|
224
|
+
}
|
|
225
|
+
break;
|
|
226
|
+
case 'attached':
|
|
227
|
+
if (typeof frame.machineId !== 'string') {
|
|
228
|
+
throw new Error("machine-protocol: 'attached' frame is missing machineId");
|
|
229
|
+
}
|
|
230
|
+
break;
|
|
231
|
+
case 'enrolled':
|
|
232
|
+
if (typeof frame.machineId !== 'string' || typeof frame.principalId !== 'string') {
|
|
233
|
+
throw new Error("machine-protocol: 'enrolled' frame is missing machineId/principalId");
|
|
234
|
+
}
|
|
235
|
+
if (typeof frame.credential !== 'string' || typeof frame.expiresAt !== 'number') {
|
|
236
|
+
throw new Error("machine-protocol: 'enrolled' frame is missing credential/expiresAt");
|
|
237
|
+
}
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
return frame;
|
|
241
|
+
}
|
package/esm/token.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How an enrollment token is stored and compared.
|
|
3
|
+
*
|
|
4
|
+
* The `machine` row keeps a hash, never the token: the runner holds the only
|
|
5
|
+
* copy of the secret, and a database dump of the machines table hands out no
|
|
6
|
+
* shells. Enrollment hashes the token it generated with this function, and the
|
|
7
|
+
* relay hashes what a socket presented and compares the two — in constant time,
|
|
8
|
+
* so the comparison leaks nothing about how much of a prefix matched.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash, timingSafeEqual } from 'crypto';
|
|
11
|
+
/** The hash stored in `machine.token_hash` for an enrollment token. */
|
|
12
|
+
export function hashMachineToken(token) {
|
|
13
|
+
return createHash('sha256').update(token, 'utf8').digest('hex');
|
|
14
|
+
}
|
|
15
|
+
/** Constant-time comparison of two token hashes. */
|
|
16
|
+
export function machineTokenHashMatches(expected, presented) {
|
|
17
|
+
const a = Buffer.from(expected, 'utf8');
|
|
18
|
+
const b = Buffer.from(presented, 'utf8');
|
|
19
|
+
if (a.length !== b.length)
|
|
20
|
+
return false;
|
|
21
|
+
return timingSafeEqual(a, b);
|
|
22
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header a runner identifies itself with at upgrade time.
|
|
3
|
+
*
|
|
4
|
+
* A coordinate, not a claim: the bearer credential is what authenticates, and
|
|
5
|
+
* the machine row it matches is what decides. A socket naming a machine it
|
|
6
|
+
* cannot present the secret for is refused.
|
|
7
|
+
*/
|
|
8
|
+
export declare const HEADER_MACHINE_ID = "x-machine-id";
|
|
9
|
+
/**
|
|
10
|
+
* Header naming the tenant database the runner is enrolled in — which database
|
|
11
|
+
* to ask, never permission to be there, exactly as a sync route's hostname
|
|
12
|
+
* chooses a database before anyone is authenticated.
|
|
13
|
+
*/
|
|
14
|
+
export declare const HEADER_MACHINE_DATABASE = "x-machine-database";
|
|
15
|
+
/** URL paths the relay serves. */
|
|
16
|
+
export declare const RUNNER_CONNECT_PATH = "/v1/machines/connect";
|
|
17
|
+
/**
|
|
18
|
+
* Where a client attaches, with its own credential as a bearer token. The
|
|
19
|
+
* database is in the path because a tenant credential is only meaningful to the
|
|
20
|
+
* tenant that issued it: the relay authenticates the token *there*.
|
|
21
|
+
*/
|
|
22
|
+
export declare const clientAttachPath: (databaseId: string, machineId: string) => string;
|
|
23
|
+
/**
|
|
24
|
+
* Subprotocol a browser attaches with.
|
|
25
|
+
*
|
|
26
|
+
* A `WebSocket` in a page cannot set request headers, so the one thing the
|
|
27
|
+
* standard does let it send — the subprotocol list — carries the credential:
|
|
28
|
+
* `[ATTACH_SUBPROTOCOL, '<credential>']`. It is the same bearer token the
|
|
29
|
+
* `Authorization` header would carry, authenticated by the same tenant
|
|
30
|
+
* `authenticate()`, and it stays out of the URL (and therefore out of access
|
|
31
|
+
* logs and `Referer`) which a query parameter would not. The relay answers with
|
|
32
|
+
* {@link ATTACH_SUBPROTOCOL} alone, so the credential is never echoed back.
|
|
33
|
+
*/
|
|
34
|
+
export declare const ATTACH_SUBPROTOCOL = "constructive.machine.v1";
|
|
35
|
+
/** The subprotocol list a browser client attaches with. */
|
|
36
|
+
export declare const attachSubprotocols: (credential: string) => [string, string];
|
|
37
|
+
/**
|
|
38
|
+
* The credential out of a subprotocol list, or null when the list is not an
|
|
39
|
+
* attach list. Only the documented two-entry shape is accepted — anything else
|
|
40
|
+
* is a client that has not agreed this protocol.
|
|
41
|
+
*/
|
|
42
|
+
export declare function credentialFromSubprotocols(header: string | undefined): string | null;
|
|
43
|
+
/** Parse {@link clientAttachPath}, or null when the path is something else. */
|
|
44
|
+
export declare function parseClientAttachPath(path: string): {
|
|
45
|
+
databaseId: string;
|
|
46
|
+
machineId: string;
|
|
47
|
+
} | null;
|
|
48
|
+
/** Client → runner: start a process in a new session. */
|
|
49
|
+
export interface OpenFrame {
|
|
50
|
+
type: 'open';
|
|
51
|
+
sessionId: string;
|
|
52
|
+
command: string;
|
|
53
|
+
args?: string[];
|
|
54
|
+
cols?: number;
|
|
55
|
+
rows?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Ask for a terminal rather than a pipe. An interactive session gets a pty,
|
|
58
|
+
* so its output is opaque terminal bytes (escape sequences and all) and its
|
|
59
|
+
* geometry is part of the session — which is what makes `vim` render and
|
|
60
|
+
* reflow. Command mode (phase 1) is the default and unchanged.
|
|
61
|
+
*/
|
|
62
|
+
interactive?: boolean;
|
|
63
|
+
}
|
|
64
|
+
/** Client → runner: bytes for the session's stdin. */
|
|
65
|
+
export interface InputFrame {
|
|
66
|
+
type: 'input';
|
|
67
|
+
sessionId: string;
|
|
68
|
+
data: string;
|
|
69
|
+
}
|
|
70
|
+
/** Client → runner: the terminal was resized. */
|
|
71
|
+
export interface ResizeFrame {
|
|
72
|
+
type: 'resize';
|
|
73
|
+
sessionId: string;
|
|
74
|
+
cols: number;
|
|
75
|
+
rows: number;
|
|
76
|
+
}
|
|
77
|
+
/** Client → runner: tear the session down. */
|
|
78
|
+
export interface CloseFrame {
|
|
79
|
+
type: 'close';
|
|
80
|
+
sessionId: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Signals a client may ask for. `SIGINT` and `SIGQUIT` are the terminal's own
|
|
84
|
+
* interrupt characters, so on a pty they are delivered the way a keyboard
|
|
85
|
+
* delivers them — through the line discipline, to the *foreground* process
|
|
86
|
+
* group — which is what interrupts a running command without ending the
|
|
87
|
+
* session. The rest are delivered to the session leader and do end it.
|
|
88
|
+
*/
|
|
89
|
+
export declare const SIGNAL_NAMES: readonly ["SIGINT", "SIGQUIT", "SIGTERM", "SIGHUP", "SIGKILL"];
|
|
90
|
+
export type SignalName = (typeof SIGNAL_NAMES)[number];
|
|
91
|
+
/** Client → runner: deliver a signal to the session. */
|
|
92
|
+
export interface SignalFrame {
|
|
93
|
+
type: 'signal';
|
|
94
|
+
sessionId: string;
|
|
95
|
+
signal: SignalName;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Client → relay → runner: stop streaming, but leave the process running.
|
|
99
|
+
*
|
|
100
|
+
* The session stays alive on the machine and its record moves to `detached`,
|
|
101
|
+
* which is what a later {@link ReattachFrame} — from this client or another
|
|
102
|
+
* one — comes back to.
|
|
103
|
+
*/
|
|
104
|
+
export interface DetachFrame {
|
|
105
|
+
type: 'detach';
|
|
106
|
+
sessionId: string;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Client → relay → runner: take over a session that is already running.
|
|
110
|
+
*
|
|
111
|
+
* A reattach is a fresh authorization question, never a continuation of the
|
|
112
|
+
* attach that opened the session: the client may be a different one, and the
|
|
113
|
+
* tenant answers again. `cols`/`rows` are the new terminal's geometry, applied
|
|
114
|
+
* to the pty so the program repaints at the size it is being watched on.
|
|
115
|
+
*/
|
|
116
|
+
export interface ReattachFrame {
|
|
117
|
+
type: 'reattach';
|
|
118
|
+
sessionId: string;
|
|
119
|
+
cols?: number;
|
|
120
|
+
rows?: number;
|
|
121
|
+
}
|
|
122
|
+
/** Runner → client: bytes from the session's pty. */
|
|
123
|
+
export interface OutputFrame {
|
|
124
|
+
type: 'output';
|
|
125
|
+
sessionId: string;
|
|
126
|
+
data: string;
|
|
127
|
+
}
|
|
128
|
+
/** Runner → client: the process ended. */
|
|
129
|
+
export interface ExitFrame {
|
|
130
|
+
type: 'exit';
|
|
131
|
+
sessionId: string;
|
|
132
|
+
exitCode: number;
|
|
133
|
+
signal?: number;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Any → any: something failed. Carries a session id when the failure belongs
|
|
137
|
+
* to one session (a rejected command), none when it belongs to the connection
|
|
138
|
+
* (the runner dropped).
|
|
139
|
+
*/
|
|
140
|
+
export interface ErrorFrame {
|
|
141
|
+
type: 'error';
|
|
142
|
+
sessionId?: string;
|
|
143
|
+
message: string;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Runner → relay: the scrollback a detaching session leaves behind, capped by
|
|
147
|
+
* the runner. The relay records it as the session's one interactive output row
|
|
148
|
+
* (the ledger is the agreed home for terminal bytes) and does not keep it.
|
|
149
|
+
*/
|
|
150
|
+
export interface ScrollbackFrame {
|
|
151
|
+
type: 'scrollback';
|
|
152
|
+
sessionId: string;
|
|
153
|
+
data: string;
|
|
154
|
+
/** True when older bytes fell out of the runner's ring buffer. */
|
|
155
|
+
truncated: boolean;
|
|
156
|
+
}
|
|
157
|
+
/** Relay → client: the session is detached and still running. */
|
|
158
|
+
export interface DetachedFrame {
|
|
159
|
+
type: 'detached';
|
|
160
|
+
sessionId: string;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Relay → client: a reattach was authorized, and here is the session as the
|
|
164
|
+
* ledger has it — the geometry it is running at and how much of its record was
|
|
165
|
+
* already written, so a client can read the transcript back from SQL.
|
|
166
|
+
*/
|
|
167
|
+
export interface ReattachedFrame {
|
|
168
|
+
type: 'reattached';
|
|
169
|
+
sessionId: string;
|
|
170
|
+
state: string;
|
|
171
|
+
cols: number | null;
|
|
172
|
+
rows: number | null;
|
|
173
|
+
/** `machine_session.last_seq`, a bigint, so it travels as a decimal string. */
|
|
174
|
+
lastSeq: string;
|
|
175
|
+
interactive: boolean;
|
|
176
|
+
}
|
|
177
|
+
/** Relay → client: the attach succeeded and the runner is online. */
|
|
178
|
+
export interface AttachedFrame {
|
|
179
|
+
type: 'attached';
|
|
180
|
+
machineId: string;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Relay → runner: the enrollment token was exchanged.
|
|
184
|
+
*
|
|
185
|
+
* The runner learns two things it did not know: the principal the tenant says it
|
|
186
|
+
* acts as, and a short-lived credential to dial with next time. The credential
|
|
187
|
+
* is held in memory and never logged or written beside the enrollment file —
|
|
188
|
+
* `expiresAt` is when it stops verifying, after which the runner falls back to
|
|
189
|
+
* the enrollment token and is issued a fresh one.
|
|
190
|
+
*/
|
|
191
|
+
export interface EnrolledFrame {
|
|
192
|
+
type: 'enrolled';
|
|
193
|
+
machineId: string;
|
|
194
|
+
/** The principal the machine's own writes are attributed to. */
|
|
195
|
+
principalId: string;
|
|
196
|
+
/** Short-lived database credential. Never log this value. */
|
|
197
|
+
credential: string;
|
|
198
|
+
/** Epoch seconds at which {@link credential} stops verifying. */
|
|
199
|
+
expiresAt: number;
|
|
200
|
+
}
|
|
201
|
+
export type ClientToRunnerFrame = OpenFrame | InputFrame | ResizeFrame | SignalFrame | DetachFrame | ReattachFrame | CloseFrame;
|
|
202
|
+
export type RunnerToClientFrame = OutputFrame | ExitFrame | ErrorFrame | ScrollbackFrame;
|
|
203
|
+
export type Frame = ClientToRunnerFrame | RunnerToClientFrame | AttachedFrame | DetachedFrame | ReattachedFrame | EnrolledFrame;
|
|
204
|
+
export declare function encodeFrame(frame: Frame): string;
|
|
205
|
+
/**
|
|
206
|
+
* Parse one wire message into a frame. Malformed input is a protocol fault on
|
|
207
|
+
* the sending socket, so this throws rather than returning a placeholder —
|
|
208
|
+
* the caller decides whether that closes the connection.
|
|
209
|
+
*/
|
|
210
|
+
export declare function decodeFrame(raw: string): Frame;
|
package/index.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The machine transport wire protocol: JSON text frames over a WebSocket. Two
|
|
3
|
+
// socket roles speak it — a runner (the daemon on the user's machine) and a
|
|
4
|
+
// client (whatever is driving a session) — with the relay in the middle routing
|
|
5
|
+
// frames between them by session id.
|
|
6
|
+
//
|
|
7
|
+
// The relay never interprets terminal bytes: `input` flows client → runner,
|
|
8
|
+
// `output` flows runner → client, both opaque UTF-8 strings. Everything else
|
|
9
|
+
// is session lifecycle.
|
|
10
|
+
//
|
|
11
|
+
// Phase 2 adds the rest of a terminal — `resize`, `signal`, `detach`,
|
|
12
|
+
// `reattach` — as frames on this same socket rather than a second transport.
|
|
13
|
+
// Each is an *operation*, not a routing hint: the relay answers every one of
|
|
14
|
+
// them by asking the tenant's SQL again, so a frame here is a request and never
|
|
15
|
+
// a decision. That is what makes a reattach from a different client safe —
|
|
16
|
+
// nothing about the attach that opened the session carries over.
|
|
17
|
+
//
|
|
18
|
+
// This package is vocabulary, never policy. Everything a socket puts on the wire
|
|
19
|
+
// — its database, its machine id — is a *coordinate*: it says which row the
|
|
20
|
+
// socket is asking about, and the relay answers by asking that database's SQL.
|
|
21
|
+
// Nothing here grants anything and no frame carries an authorization decision,
|
|
22
|
+
// so naming a coordinate buys nothing: a secret only matches a machine row in
|
|
23
|
+
// the tenant that issued it, and RLS decides everything after that. Addressing a
|
|
24
|
+
// database you have no credential for is simply a refusal.
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.SIGNAL_NAMES = exports.attachSubprotocols = exports.ATTACH_SUBPROTOCOL = exports.clientAttachPath = exports.RUNNER_CONNECT_PATH = exports.HEADER_MACHINE_DATABASE = exports.HEADER_MACHINE_ID = void 0;
|
|
27
|
+
exports.credentialFromSubprotocols = credentialFromSubprotocols;
|
|
28
|
+
exports.parseClientAttachPath = parseClientAttachPath;
|
|
29
|
+
exports.encodeFrame = encodeFrame;
|
|
30
|
+
exports.decodeFrame = decodeFrame;
|
|
31
|
+
// Token hashing is deliberately NOT re-exported here: it needs node's `crypto`,
|
|
32
|
+
// and this index is imported by the browser terminal. Node consumers reach it at
|
|
33
|
+
// `@constructive-db/machine-protocol/token`.
|
|
34
|
+
/**
|
|
35
|
+
* Header a runner identifies itself with at upgrade time.
|
|
36
|
+
*
|
|
37
|
+
* A coordinate, not a claim: the bearer credential is what authenticates, and
|
|
38
|
+
* the machine row it matches is what decides. A socket naming a machine it
|
|
39
|
+
* cannot present the secret for is refused.
|
|
40
|
+
*/
|
|
41
|
+
exports.HEADER_MACHINE_ID = 'x-machine-id';
|
|
42
|
+
/**
|
|
43
|
+
* Header naming the tenant database the runner is enrolled in — which database
|
|
44
|
+
* to ask, never permission to be there, exactly as a sync route's hostname
|
|
45
|
+
* chooses a database before anyone is authenticated.
|
|
46
|
+
*/
|
|
47
|
+
exports.HEADER_MACHINE_DATABASE = 'x-machine-database';
|
|
48
|
+
/** URL paths the relay serves. */
|
|
49
|
+
exports.RUNNER_CONNECT_PATH = '/v1/machines/connect';
|
|
50
|
+
/**
|
|
51
|
+
* Where a client attaches, with its own credential as a bearer token. The
|
|
52
|
+
* database is in the path because a tenant credential is only meaningful to the
|
|
53
|
+
* tenant that issued it: the relay authenticates the token *there*.
|
|
54
|
+
*/
|
|
55
|
+
const clientAttachPath = (databaseId, machineId) => `/v1/databases/${encodeURIComponent(databaseId)}/machines/${encodeURIComponent(machineId)}/attach`;
|
|
56
|
+
exports.clientAttachPath = clientAttachPath;
|
|
57
|
+
/**
|
|
58
|
+
* Subprotocol a browser attaches with.
|
|
59
|
+
*
|
|
60
|
+
* A `WebSocket` in a page cannot set request headers, so the one thing the
|
|
61
|
+
* standard does let it send — the subprotocol list — carries the credential:
|
|
62
|
+
* `[ATTACH_SUBPROTOCOL, '<credential>']`. It is the same bearer token the
|
|
63
|
+
* `Authorization` header would carry, authenticated by the same tenant
|
|
64
|
+
* `authenticate()`, and it stays out of the URL (and therefore out of access
|
|
65
|
+
* logs and `Referer`) which a query parameter would not. The relay answers with
|
|
66
|
+
* {@link ATTACH_SUBPROTOCOL} alone, so the credential is never echoed back.
|
|
67
|
+
*/
|
|
68
|
+
exports.ATTACH_SUBPROTOCOL = 'constructive.machine.v1';
|
|
69
|
+
/** The subprotocol list a browser client attaches with. */
|
|
70
|
+
const attachSubprotocols = (credential) => [
|
|
71
|
+
exports.ATTACH_SUBPROTOCOL,
|
|
72
|
+
credential
|
|
73
|
+
];
|
|
74
|
+
exports.attachSubprotocols = attachSubprotocols;
|
|
75
|
+
/**
|
|
76
|
+
* The credential out of a subprotocol list, or null when the list is not an
|
|
77
|
+
* attach list. Only the documented two-entry shape is accepted — anything else
|
|
78
|
+
* is a client that has not agreed this protocol.
|
|
79
|
+
*/
|
|
80
|
+
function credentialFromSubprotocols(header) {
|
|
81
|
+
if (!header)
|
|
82
|
+
return null;
|
|
83
|
+
const values = header.split(',').map(value => value.trim());
|
|
84
|
+
if (values.length !== 2 || values[0] !== exports.ATTACH_SUBPROTOCOL || !values[1])
|
|
85
|
+
return null;
|
|
86
|
+
return values[1];
|
|
87
|
+
}
|
|
88
|
+
/** Parse {@link clientAttachPath}, or null when the path is something else. */
|
|
89
|
+
function parseClientAttachPath(path) {
|
|
90
|
+
const match = /^\/v1\/databases\/([^/]+)\/machines\/([^/]+)\/attach$/.exec(path);
|
|
91
|
+
if (!match)
|
|
92
|
+
return null;
|
|
93
|
+
return {
|
|
94
|
+
databaseId: decodeURIComponent(match[1]),
|
|
95
|
+
machineId: decodeURIComponent(match[2])
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Signals a client may ask for. `SIGINT` and `SIGQUIT` are the terminal's own
|
|
100
|
+
* interrupt characters, so on a pty they are delivered the way a keyboard
|
|
101
|
+
* delivers them — through the line discipline, to the *foreground* process
|
|
102
|
+
* group — which is what interrupts a running command without ending the
|
|
103
|
+
* session. The rest are delivered to the session leader and do end it.
|
|
104
|
+
*/
|
|
105
|
+
exports.SIGNAL_NAMES = ['SIGINT', 'SIGQUIT', 'SIGTERM', 'SIGHUP', 'SIGKILL'];
|
|
106
|
+
const FRAME_TYPES = new Set([
|
|
107
|
+
'open',
|
|
108
|
+
'input',
|
|
109
|
+
'resize',
|
|
110
|
+
'signal',
|
|
111
|
+
'detach',
|
|
112
|
+
'reattach',
|
|
113
|
+
'close',
|
|
114
|
+
'output',
|
|
115
|
+
'exit',
|
|
116
|
+
'error',
|
|
117
|
+
'scrollback',
|
|
118
|
+
'attached',
|
|
119
|
+
'detached',
|
|
120
|
+
'reattached',
|
|
121
|
+
'enrolled'
|
|
122
|
+
]);
|
|
123
|
+
/** Frame types that must carry a session id. */
|
|
124
|
+
const SESSION_FRAME_TYPES = new Set([
|
|
125
|
+
'open',
|
|
126
|
+
'input',
|
|
127
|
+
'resize',
|
|
128
|
+
'signal',
|
|
129
|
+
'detach',
|
|
130
|
+
'reattach',
|
|
131
|
+
'close',
|
|
132
|
+
'output',
|
|
133
|
+
'exit',
|
|
134
|
+
'scrollback',
|
|
135
|
+
'detached',
|
|
136
|
+
'reattached'
|
|
137
|
+
]);
|
|
138
|
+
const SIGNALS = new Set(exports.SIGNAL_NAMES);
|
|
139
|
+
/**
|
|
140
|
+
* Terminal geometry a pty will accept. A resize is a client-supplied pair of
|
|
141
|
+
* numbers that ends up in `ioctl(TIOCSWINSZ)`, so it is validated here rather
|
|
142
|
+
* than trusted: zero, negative, fractional or absurd values are a protocol
|
|
143
|
+
* fault, not something to clamp silently.
|
|
144
|
+
*/
|
|
145
|
+
const MAX_DIMENSION = 1000;
|
|
146
|
+
function validDimension(value) {
|
|
147
|
+
return (typeof value === 'number' &&
|
|
148
|
+
Number.isInteger(value) &&
|
|
149
|
+
value > 0 &&
|
|
150
|
+
value <= MAX_DIMENSION);
|
|
151
|
+
}
|
|
152
|
+
function encodeFrame(frame) {
|
|
153
|
+
return JSON.stringify(frame);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Parse one wire message into a frame. Malformed input is a protocol fault on
|
|
157
|
+
* the sending socket, so this throws rather than returning a placeholder —
|
|
158
|
+
* the caller decides whether that closes the connection.
|
|
159
|
+
*/
|
|
160
|
+
function decodeFrame(raw) {
|
|
161
|
+
let parsed;
|
|
162
|
+
try {
|
|
163
|
+
parsed = JSON.parse(raw);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
throw new Error(`machine-protocol: frame is not JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
167
|
+
}
|
|
168
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
169
|
+
throw new Error('machine-protocol: frame is not an object');
|
|
170
|
+
}
|
|
171
|
+
const frame = parsed;
|
|
172
|
+
const type = frame.type;
|
|
173
|
+
if (typeof type !== 'string' || !FRAME_TYPES.has(type)) {
|
|
174
|
+
throw new Error(`machine-protocol: unknown frame type '${String(type)}'`);
|
|
175
|
+
}
|
|
176
|
+
if (SESSION_FRAME_TYPES.has(type) && typeof frame.sessionId !== 'string') {
|
|
177
|
+
throw new Error(`machine-protocol: '${type}' frame is missing sessionId`);
|
|
178
|
+
}
|
|
179
|
+
switch (type) {
|
|
180
|
+
case 'open':
|
|
181
|
+
if (typeof frame.command !== 'string' || frame.command.length === 0) {
|
|
182
|
+
throw new Error("machine-protocol: 'open' frame is missing command");
|
|
183
|
+
}
|
|
184
|
+
if (frame.cols !== undefined || frame.rows !== undefined) {
|
|
185
|
+
if (!validDimension(frame.cols) || !validDimension(frame.rows)) {
|
|
186
|
+
throw new Error(`machine-protocol: 'open' frame needs integer cols/rows in 1..${MAX_DIMENSION}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
case 'input':
|
|
191
|
+
case 'output':
|
|
192
|
+
if (typeof frame.data !== 'string') {
|
|
193
|
+
throw new Error(`machine-protocol: '${type}' frame is missing data`);
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
case 'resize':
|
|
197
|
+
if (!validDimension(frame.cols) || !validDimension(frame.rows)) {
|
|
198
|
+
throw new Error(`machine-protocol: 'resize' frame needs integer cols/rows in 1..${MAX_DIMENSION}`);
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
case 'reattach':
|
|
202
|
+
// Geometry is optional on a reattach (a client may want the size the
|
|
203
|
+
// session already has), but a supplied one is held to the same bounds.
|
|
204
|
+
if (frame.cols !== undefined || frame.rows !== undefined) {
|
|
205
|
+
if (!validDimension(frame.cols) || !validDimension(frame.rows)) {
|
|
206
|
+
throw new Error(`machine-protocol: 'reattach' frame needs integer cols/rows in 1..${MAX_DIMENSION}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
case 'signal':
|
|
211
|
+
if (typeof frame.signal !== 'string' || !SIGNALS.has(frame.signal)) {
|
|
212
|
+
throw new Error(`machine-protocol: 'signal' frame has unknown signal '${String(frame.signal)}'`);
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
case 'scrollback':
|
|
216
|
+
if (typeof frame.data !== 'string' || typeof frame.truncated !== 'boolean') {
|
|
217
|
+
throw new Error("machine-protocol: 'scrollback' frame is missing data/truncated");
|
|
218
|
+
}
|
|
219
|
+
break;
|
|
220
|
+
case 'reattached':
|
|
221
|
+
if (typeof frame.state !== 'string' || typeof frame.lastSeq !== 'string') {
|
|
222
|
+
throw new Error("machine-protocol: 'reattached' frame is missing state/lastSeq");
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
case 'exit':
|
|
226
|
+
if (typeof frame.exitCode !== 'number') {
|
|
227
|
+
throw new Error("machine-protocol: 'exit' frame is missing exitCode");
|
|
228
|
+
}
|
|
229
|
+
break;
|
|
230
|
+
case 'error':
|
|
231
|
+
if (typeof frame.message !== 'string') {
|
|
232
|
+
throw new Error("machine-protocol: 'error' frame is missing message");
|
|
233
|
+
}
|
|
234
|
+
break;
|
|
235
|
+
case 'attached':
|
|
236
|
+
if (typeof frame.machineId !== 'string') {
|
|
237
|
+
throw new Error("machine-protocol: 'attached' frame is missing machineId");
|
|
238
|
+
}
|
|
239
|
+
break;
|
|
240
|
+
case 'enrolled':
|
|
241
|
+
if (typeof frame.machineId !== 'string' || typeof frame.principalId !== 'string') {
|
|
242
|
+
throw new Error("machine-protocol: 'enrolled' frame is missing machineId/principalId");
|
|
243
|
+
}
|
|
244
|
+
if (typeof frame.credential !== 'string' || typeof frame.expiresAt !== 'number') {
|
|
245
|
+
throw new Error("machine-protocol: 'enrolled' frame is missing credential/expiresAt");
|
|
246
|
+
}
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
return frame;
|
|
250
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@constructive-db/machine-protocol",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Wire protocol shared by the machine relay and the machine runner: the JSON frames a session is made of, and the codec that refuses malformed ones.",
|
|
5
|
+
"author": "Constructive <developers@constructive.io>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"module": "esm/index.js",
|
|
9
|
+
"types": "index.d.ts",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public",
|
|
12
|
+
"directory": "dist"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/jest": "^29.5.14",
|
|
16
|
+
"@types/node": "^22.10.4",
|
|
17
|
+
"jest": "^29.7.0",
|
|
18
|
+
"makage": "^0.1.12",
|
|
19
|
+
"ts-jest": "^29.2.0",
|
|
20
|
+
"typescript": "^5.1.6"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "makage build && cp LICENSE dist/LICENSE",
|
|
24
|
+
"build:dev": "makage build --dev && cp LICENSE dist/LICENSE",
|
|
25
|
+
"clean": "makage clean",
|
|
26
|
+
"lint": "eslint . --fix",
|
|
27
|
+
"test": "jest"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/token.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How an enrollment token is stored and compared.
|
|
3
|
+
*
|
|
4
|
+
* The `machine` row keeps a hash, never the token: the runner holds the only
|
|
5
|
+
* copy of the secret, and a database dump of the machines table hands out no
|
|
6
|
+
* shells. Enrollment hashes the token it generated with this function, and the
|
|
7
|
+
* relay hashes what a socket presented and compares the two — in constant time,
|
|
8
|
+
* so the comparison leaks nothing about how much of a prefix matched.
|
|
9
|
+
*/
|
|
10
|
+
/** The hash stored in `machine.token_hash` for an enrollment token. */
|
|
11
|
+
export declare function hashMachineToken(token: string): string;
|
|
12
|
+
/** Constant-time comparison of two token hashes. */
|
|
13
|
+
export declare function machineTokenHashMatches(expected: string, presented: string): boolean;
|
package/token.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* How an enrollment token is stored and compared.
|
|
4
|
+
*
|
|
5
|
+
* The `machine` row keeps a hash, never the token: the runner holds the only
|
|
6
|
+
* copy of the secret, and a database dump of the machines table hands out no
|
|
7
|
+
* shells. Enrollment hashes the token it generated with this function, and the
|
|
8
|
+
* relay hashes what a socket presented and compares the two — in constant time,
|
|
9
|
+
* so the comparison leaks nothing about how much of a prefix matched.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.hashMachineToken = hashMachineToken;
|
|
13
|
+
exports.machineTokenHashMatches = machineTokenHashMatches;
|
|
14
|
+
const crypto_1 = require("crypto");
|
|
15
|
+
/** The hash stored in `machine.token_hash` for an enrollment token. */
|
|
16
|
+
function hashMachineToken(token) {
|
|
17
|
+
return (0, crypto_1.createHash)('sha256').update(token, 'utf8').digest('hex');
|
|
18
|
+
}
|
|
19
|
+
/** Constant-time comparison of two token hashes. */
|
|
20
|
+
function machineTokenHashMatches(expected, presented) {
|
|
21
|
+
const a = Buffer.from(expected, 'utf8');
|
|
22
|
+
const b = Buffer.from(presented, 'utf8');
|
|
23
|
+
if (a.length !== b.length)
|
|
24
|
+
return false;
|
|
25
|
+
return (0, crypto_1.timingSafeEqual)(a, b);
|
|
26
|
+
}
|