mecord-connect 1.0.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/DISCLOSURE +47 -0
- package/LICENSE +64 -0
- package/README.md +44 -0
- package/bin/operator.mjs +10 -0
- package/package.json +58 -0
- package/runtime/app/apps/local-agent/src/approval-store.js +342 -0
- package/runtime/app/apps/local-agent/src/bootstrap-config.js +255 -0
- package/runtime/app/apps/local-agent/src/cli.js +237 -0
- package/runtime/app/apps/local-agent/src/device-reset.js +260 -0
- package/runtime/app/apps/local-agent/src/emergency-stop.js +141 -0
- package/runtime/app/apps/local-agent/src/main.js +311 -0
- package/runtime/app/apps/local-agent/src/privacy-data.js +129 -0
- package/runtime/app/apps/local-agent/src/relay-agent.js +333 -0
- package/runtime/app/apps/local-agent/src/relay-enrollment.js +156 -0
- package/runtime/app/apps/local-agent/src/relay-session-credentials.js +436 -0
- package/runtime/app/apps/local-agent/src/remote.js +130 -0
- package/runtime/app/apps/local-agent/src/runtime-factory.js +66 -0
- package/runtime/app/apps/local-agent/src/server.js +578 -0
- package/runtime/app/package.json +4 -0
- package/runtime/app/src/capabilities/browser-cdp-connection.js +199 -0
- package/runtime/app/src/capabilities/browser-cdp-frames.js +288 -0
- package/runtime/app/src/capabilities/browser-cdp-page.js +430 -0
- package/runtime/app/src/capabilities/browser-cdp.js +384 -0
- package/runtime/app/src/capabilities/browser-managed.js +345 -0
- package/runtime/app/src/capabilities/docker.js +448 -0
- package/runtime/app/src/capabilities/filesystem.js +264 -0
- package/runtime/app/src/capabilities/git-checkpoint.js +415 -0
- package/runtime/app/src/capabilities/git-write.js +321 -0
- package/runtime/app/src/capabilities/git.js +252 -0
- package/runtime/app/src/capabilities/index.js +16 -0
- package/runtime/app/src/capabilities/path-scope.js +86 -0
- package/runtime/app/src/capabilities/postgres.js +566 -0
- package/runtime/app/src/capabilities/process.js +258 -0
- package/runtime/app/src/capabilities/project-command.js +599 -0
- package/runtime/app/src/capabilities/project-transaction.js +215 -0
- package/runtime/app/src/capabilities/project.js +67 -0
- package/runtime/app/src/capabilities/system.js +40 -0
- package/runtime/app/src/capabilities/vscode.js +312 -0
- package/runtime/app/src/capabilities/windows-uia.js +331 -0
- package/runtime/app/src/core/account-device-registry.js +604 -0
- package/runtime/app/src/core/action-identity.js +44 -0
- package/runtime/app/src/core/audit.js +295 -0
- package/runtime/app/src/core/capability-policy.js +58 -0
- package/runtime/app/src/core/child-environment.js +29 -0
- package/runtime/app/src/core/device-enrollment.js +417 -0
- package/runtime/app/src/core/device-identity.js +436 -0
- package/runtime/app/src/core/device-registry.js +477 -0
- package/runtime/app/src/core/device-reset.js +178 -0
- package/runtime/app/src/core/device-routing.js +305 -0
- package/runtime/app/src/core/durable-state.js +306 -0
- package/runtime/app/src/core/errors.js +20 -0
- package/runtime/app/src/core/evidence.js +9 -0
- package/runtime/app/src/core/index.js +17 -0
- package/runtime/app/src/core/machine-state.js +160 -0
- package/runtime/app/src/core/network-authority.js +20 -0
- package/runtime/app/src/core/policy.js +67 -0
- package/runtime/app/src/core/product-identity.js +7 -0
- package/runtime/app/src/core/provenance.js +18 -0
- package/runtime/app/src/core/provider-learning.js +274 -0
- package/runtime/app/src/core/public-plugin-surface.js +25 -0
- package/runtime/app/src/core/public-restricted-data.js +105 -0
- package/runtime/app/src/core/rate-limit.js +118 -0
- package/runtime/app/src/core/relay-client.js +568 -0
- package/runtime/app/src/core/relay-delivery-store.js +512 -0
- package/runtime/app/src/core/relay-result-store.js +321 -0
- package/runtime/app/src/core/release-update.js +253 -0
- package/runtime/app/src/core/router.js +96 -0
- package/runtime/app/src/core/runtime.js +136 -0
- package/runtime/app/src/core/scoped-path-syntax.js +16 -0
- package/runtime/app/src/core/session-token.js +498 -0
- package/runtime/app/src/core/task-orchestrator.js +1148 -0
- package/runtime/app/src/core/task-store.js +517 -0
- package/runtime/app/src/core/task.js +142 -0
- package/runtime/app/src/core/trusted-executable.js +124 -0
- package/runtime/app/src/core/types.js +83 -0
- package/runtime/app/src/core/windows-path-lease.js +138 -0
- package/runtime/native/operator-windows-dpapi.exe +0 -0
- package/runtime/native/operator-windows-path-lease.exe +0 -0
- package/runtime/native/operator-windows-uia.exe +0 -0
- package/runtime/runtime-manifest.json +380 -0
- package/src/cli.mjs +345 -0
- package/src/publish-check.mjs +22 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { OperatorError } from './errors.js';
|
|
5
|
+
import { readDurableStateText, writeDurableStateText } from './durable-state.js';
|
|
6
|
+
|
|
7
|
+
const VERSION = 1 ;
|
|
8
|
+
const MAX_RECORDS = 2048;
|
|
9
|
+
const DEFAULT_TTL_MS = 10 * 60_000;
|
|
10
|
+
const MIN_TTL_MS = 60_000;
|
|
11
|
+
const MAX_TTL_MS = 15 * 60_000;
|
|
12
|
+
const RETENTION_MS = 24 * 60 * 60_000;
|
|
13
|
+
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
export class DeviceEnrollmentStore {
|
|
51
|
+
#file ;
|
|
52
|
+
#clock ;
|
|
53
|
+
#queue = Promise.resolve();
|
|
54
|
+
|
|
55
|
+
constructor(stateDir , options = {}) {
|
|
56
|
+
this.#file = path.join(path.resolve(stateDir), 'device-enrollments.json');
|
|
57
|
+
this.#clock = options.clock ?? (() => new Date());
|
|
58
|
+
}
|
|
59
|
+
async create(deviceInput , options = {}) {
|
|
60
|
+
const device = normalizePeer(deviceInput);
|
|
61
|
+
const ttl = boundedTtl(options.ttlMs ?? DEFAULT_TTL_MS);
|
|
62
|
+
const now = this.#clock();
|
|
63
|
+
const enrollmentId = options.enrollmentId === undefined ? crypto.randomUUID() : validUuid(options.enrollmentId, 'enrollmentId');
|
|
64
|
+
const pollToken = options.pollToken === undefined ? crypto.randomBytes(32).toString('base64url') : boundedPollToken(options.pollToken);
|
|
65
|
+
const userCode = userCodeFor(enrollmentId, pollToken);
|
|
66
|
+
const pollTokenSha256 = secretHash('poll', pollToken);
|
|
67
|
+
const userCodeSha256 = secretHash('code', normalizeUserCode(userCode));
|
|
68
|
+
const base = {
|
|
69
|
+
enrollmentId,
|
|
70
|
+
deviceId: device.deviceId,
|
|
71
|
+
deviceName: device.deviceName,
|
|
72
|
+
fingerprint: device.fingerprint,
|
|
73
|
+
peerCreatedAt: device.createdAt,
|
|
74
|
+
peerPublicKeyPem: device.publicKeyPem
|
|
75
|
+
};
|
|
76
|
+
const stored = await this.#mutate((state) => {
|
|
77
|
+
prune(state, now.getTime());
|
|
78
|
+
const existing = state.enrollments.find((item) => item.enrollmentId === enrollmentId);
|
|
79
|
+
if (existing) {
|
|
80
|
+
if (existing.deviceId !== base.deviceId || existing.fingerprint !== base.fingerprint || existing.peerCreatedAt !== base.peerCreatedAt || existing.peerPublicKeyPem !== base.peerPublicKeyPem || !hashEquals(existing.pollTokenSha256, pollTokenSha256) || !hashEquals(existing.userCodeSha256, userCodeSha256)) {
|
|
81
|
+
throw new OperatorError('DEVICE_ENROLLMENT_STATE_MISMATCH', 'Enrollment retry does not match the original device authority.');
|
|
82
|
+
}
|
|
83
|
+
return existing;
|
|
84
|
+
}
|
|
85
|
+
state.enrollments = state.enrollments.filter((item) => item.deviceId !== base.deviceId || item.status === 'issued');
|
|
86
|
+
if (state.enrollments.length >= MAX_RECORDS) throw new OperatorError('DEVICE_ENROLLMENT_LIMIT', 'Device enrollment retention limit reached.');
|
|
87
|
+
if (state.enrollments.some((item) => hashEquals(item.userCodeSha256, userCodeSha256))) throw new OperatorError('DEVICE_ENROLLMENT_CODE_COLLISION', 'Device enrollment code collision; begin a fresh pairing challenge.');
|
|
88
|
+
const record = { ...base, status: 'pending', createdAt: now.toISOString(), expiresAt: new Date(now.getTime() + ttl).toISOString(), userCodeSha256, pollTokenSha256 };
|
|
89
|
+
state.enrollments.push(record);
|
|
90
|
+
return record;
|
|
91
|
+
});
|
|
92
|
+
return { enrollmentId, deviceId: stored.deviceId, deviceName: stored.deviceName, fingerprint: stored.fingerprint, userCode, pollToken, expiresAt: stored.expiresAt };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async pendingForCode(codeInput ) {
|
|
97
|
+
const codeHash = secretHash('code', normalizeUserCode(codeInput));
|
|
98
|
+
const state = await this.#read();
|
|
99
|
+
const now = this.#clock().getTime();
|
|
100
|
+
const found = state.enrollments.find((item) => item.status === 'pending' && Date.parse(item.expiresAt) > now && hashEquals(item.userCodeSha256, codeHash));
|
|
101
|
+
if (!found) throw new OperatorError('DEVICE_ENROLLMENT_CODE_INVALID', 'Device enrollment code is invalid or expired.');
|
|
102
|
+
return publicRecord(found);
|
|
103
|
+
}
|
|
104
|
+
async reserve(codeInput , accountIdInput ) {
|
|
105
|
+
const codeHash = secretHash('code', normalizeUserCode(codeInput));
|
|
106
|
+
const accountId = validUuid(accountIdInput, 'accountId');
|
|
107
|
+
return await this.#mutate((state) => {
|
|
108
|
+
prune(state, this.#clock().getTime());
|
|
109
|
+
const item = state.enrollments.find((candidate) => hashEquals(candidate.userCodeSha256, codeHash));
|
|
110
|
+
if (!item) throw new OperatorError('DEVICE_ENROLLMENT_CODE_INVALID', 'Device enrollment code is invalid or expired.');
|
|
111
|
+
if (item.accountId && item.accountId !== accountId) throw new OperatorError('DEVICE_ENROLLMENT_ACCOUNT_CONFLICT', 'Device enrollment is already reserved by another account.');
|
|
112
|
+
if (item.status === 'pending' && Date.parse(item.expiresAt) <= this.#clock().getTime()) throw new OperatorError('DEVICE_ENROLLMENT_CODE_INVALID', 'Device enrollment code is invalid or expired.');
|
|
113
|
+
if (item.status === 'pending') {
|
|
114
|
+
item.status = 'reserved';
|
|
115
|
+
item.accountId = accountId;
|
|
116
|
+
item.reservedAt = this.#clock().toISOString();
|
|
117
|
+
}
|
|
118
|
+
return publicRecord(item);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async peerForClaim(enrollmentIdInput , accountIdInput ) {
|
|
123
|
+
const enrollmentId = validUuid(enrollmentIdInput, 'enrollmentId');
|
|
124
|
+
const accountId = validUuid(accountIdInput, 'accountId');
|
|
125
|
+
const state = await this.#read();
|
|
126
|
+
const item = state.enrollments.find((candidate) => candidate.enrollmentId === enrollmentId);
|
|
127
|
+
if (!item || item.accountId !== accountId || item.status === 'pending') throw new OperatorError('DEVICE_ENROLLMENT_STATE_MISMATCH', 'Device enrollment is not reserved for this account.');
|
|
128
|
+
if (!item.peerCreatedAt || !item.peerPublicKeyPem) throw new OperatorError('DEVICE_ENROLLMENT_RESTART_REQUIRED', 'Legacy provisional enrollment lacks promotion authority; begin enrollment again.');
|
|
129
|
+
return normalizePeer({ deviceId: item.deviceId, deviceName: item.deviceName, createdAt: item.peerCreatedAt, publicKeyPem: item.peerPublicKeyPem, fingerprint: item.fingerprint });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async markBound(enrollmentIdInput , accountIdInput , authorityGenerationInput ) {
|
|
133
|
+
const enrollmentId = validUuid(enrollmentIdInput, 'enrollmentId');
|
|
134
|
+
const accountId = validUuid(accountIdInput, 'accountId');
|
|
135
|
+
const authorityGeneration = validGeneration(authorityGenerationInput);
|
|
136
|
+
return await this.#mutate((state) => {
|
|
137
|
+
const item = state.enrollments.find((candidate) => candidate.enrollmentId === enrollmentId);
|
|
138
|
+
if (!item || item.accountId !== accountId) throw new OperatorError('DEVICE_ENROLLMENT_STATE_MISMATCH', 'Device enrollment account reservation changed.');
|
|
139
|
+
if (item.status === 'pending') throw new OperatorError('DEVICE_ENROLLMENT_NOT_RESERVED', 'Device enrollment has not been reserved by an account.');
|
|
140
|
+
if (item.status === 'reserved') {
|
|
141
|
+
item.status = 'claimed';
|
|
142
|
+
item.authorityGeneration = authorityGeneration;
|
|
143
|
+
item.claimedAt = this.#clock().toISOString();
|
|
144
|
+
} else if (item.authorityGeneration !== authorityGeneration) {
|
|
145
|
+
throw new OperatorError('DEVICE_ENROLLMENT_STATE_MISMATCH', 'Device enrollment authority generation changed.');
|
|
146
|
+
}
|
|
147
|
+
return publicRecord(item);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async poll(enrollmentIdInput , pollTokenInput ) {
|
|
152
|
+
const enrollmentId = validUuid(enrollmentIdInput, 'enrollmentId');
|
|
153
|
+
const pollHash = secretHash('poll', boundedPollToken(pollTokenInput));
|
|
154
|
+
const state = await this.#read();
|
|
155
|
+
const item = state.enrollments.find((candidate) => candidate.enrollmentId === enrollmentId);
|
|
156
|
+
if (!item || !hashEquals(item.pollTokenSha256, pollHash)) throw new OperatorError('DEVICE_ENROLLMENT_UNAUTHORIZED', 'Device enrollment poll authority is invalid.');
|
|
157
|
+
if (item.status === 'pending' && Date.parse(item.expiresAt) <= this.#clock().getTime()) throw new OperatorError('DEVICE_ENROLLMENT_EXPIRED', 'Device enrollment has expired.');
|
|
158
|
+
return publicRecord(item);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async markIssued(enrollmentIdInput , pollTokenInput , sessionJtiInput ) {
|
|
162
|
+
const enrollmentId = validUuid(enrollmentIdInput, 'enrollmentId');
|
|
163
|
+
const pollHash = secretHash('poll', boundedPollToken(pollTokenInput));
|
|
164
|
+
const sessionJti = validUuid(sessionJtiInput, 'sessionJti');
|
|
165
|
+
return await this.#mutate((state) => {
|
|
166
|
+
const item = state.enrollments.find((candidate) => candidate.enrollmentId === enrollmentId);
|
|
167
|
+
if (!item || !hashEquals(item.pollTokenSha256, pollHash)) throw new OperatorError('DEVICE_ENROLLMENT_UNAUTHORIZED', 'Device enrollment poll authority is invalid.');
|
|
168
|
+
if (item.status === 'pending' || item.status === 'reserved') throw new OperatorError('DEVICE_ENROLLMENT_NOT_CLAIMED', 'Device enrollment is waiting for completed account binding.');
|
|
169
|
+
if (item.status === 'issued') {
|
|
170
|
+
if (item.sessionJti !== sessionJti) throw new OperatorError('DEVICE_ENROLLMENT_STATE_MISMATCH', 'Device enrollment session identity changed.');
|
|
171
|
+
return publicRecord(item);
|
|
172
|
+
}
|
|
173
|
+
item.status = 'issued';
|
|
174
|
+
item.sessionJti = sessionJti;
|
|
175
|
+
item.issuedAt = this.#clock().toISOString();
|
|
176
|
+
return publicRecord(item);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
async purgeDevice(deviceIdInput ) {
|
|
180
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
181
|
+
return await this.#mutate((state) => {
|
|
182
|
+
const before = state.enrollments.length;
|
|
183
|
+
state.enrollments = state.enrollments.filter((item) => item.deviceId !== deviceId);
|
|
184
|
+
return before - state.enrollments.length;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async purgeDeviceForAccount(deviceIdInput , accountIdInput ) {
|
|
189
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
190
|
+
const accountId = validUuid(accountIdInput, 'accountId');
|
|
191
|
+
return await this.#mutate((state) => {
|
|
192
|
+
const before = state.enrollments.length;
|
|
193
|
+
state.enrollments = state.enrollments.filter((item) => item.deviceId !== deviceId || item.accountId !== accountId || item.status === 'reserved');
|
|
194
|
+
return before - state.enrollments.length;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async #read() {
|
|
199
|
+
try {
|
|
200
|
+
const text = await readDurableStateText(this.#file, {
|
|
201
|
+
maxBytes: 4 * 1024 * 1024,
|
|
202
|
+
errorCode: 'DEVICE_ENROLLMENT_STATE_CORRUPT',
|
|
203
|
+
invalidMessage: 'Device enrollment state is invalid.'
|
|
204
|
+
});
|
|
205
|
+
return validateState(JSON.parse(text));
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if ((error ).code === 'ENOENT') return { version: VERSION, enrollments: [] };
|
|
208
|
+
if (error instanceof OperatorError) throw error;
|
|
209
|
+
throw new OperatorError('DEVICE_ENROLLMENT_STATE_CORRUPT', 'Device enrollment state could not be read.');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async #write(stateInput ) {
|
|
214
|
+
const state = validateState(stateInput);
|
|
215
|
+
await writeDurableStateText(this.#file, JSON.stringify(state, null, 2), {
|
|
216
|
+
maxBytes: 4 * 1024 * 1024,
|
|
217
|
+
errorCode: 'DEVICE_ENROLLMENT_STATE_CORRUPT',
|
|
218
|
+
invalidMessage: 'Device enrollment state is invalid.'
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
async #mutate (mutator ) {
|
|
222
|
+
let release ;
|
|
223
|
+
const previous = this.#queue;
|
|
224
|
+
this.#queue = new Promise ((resolve) => { release = resolve; });
|
|
225
|
+
await previous;
|
|
226
|
+
try {
|
|
227
|
+
const state = await this.#read();
|
|
228
|
+
const value = await mutator(state);
|
|
229
|
+
await this.#write(state);
|
|
230
|
+
return value;
|
|
231
|
+
} finally {
|
|
232
|
+
release();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function validateState(input ) {
|
|
238
|
+
if (!input || typeof input !== 'object' || input.version !== VERSION || !Array.isArray(input.enrollments) || input.enrollments.length > MAX_RECORDS) {
|
|
239
|
+
throw new OperatorError('DEVICE_ENROLLMENT_STATE_CORRUPT', 'Device enrollment state structure is invalid.');
|
|
240
|
+
}
|
|
241
|
+
const ids = new Set ();
|
|
242
|
+
const codes = new Set ();
|
|
243
|
+
return { version: VERSION, enrollments: input.enrollments.map((raw) => {
|
|
244
|
+
const item = validateStored(raw);
|
|
245
|
+
if (ids.has(item.enrollmentId) || codes.has(item.userCodeSha256)) throw new OperatorError('DEVICE_ENROLLMENT_STATE_CORRUPT', 'Device enrollment state contains duplicate authority.');
|
|
246
|
+
ids.add(item.enrollmentId); codes.add(item.userCodeSha256);
|
|
247
|
+
return item;
|
|
248
|
+
}) };
|
|
249
|
+
}
|
|
250
|
+
function validateStored(raw ) {
|
|
251
|
+
const enrollmentId = validUuid(raw.enrollmentId, 'enrollmentId');
|
|
252
|
+
const deviceId = validUuid(raw.deviceId, 'deviceId');
|
|
253
|
+
const deviceName = boundedName(raw.deviceName);
|
|
254
|
+
const fingerprint = validFingerprint(raw.fingerprint);
|
|
255
|
+
const peerCreatedAt = raw.peerCreatedAt === undefined ? undefined : validIso(raw.peerCreatedAt, 'peerCreatedAt');
|
|
256
|
+
const peerPublicKeyPem = raw.peerPublicKeyPem === undefined ? undefined : String(raw.peerPublicKeyPem);
|
|
257
|
+
if (Boolean(peerCreatedAt) !== Boolean(peerPublicKeyPem)) throw new OperatorError('DEVICE_ENROLLMENT_STATE_CORRUPT', 'Provisional device identity is incomplete.');
|
|
258
|
+
if (peerCreatedAt && peerPublicKeyPem) {
|
|
259
|
+
const peer = normalizePeer({ deviceId, deviceName, createdAt: peerCreatedAt, publicKeyPem: peerPublicKeyPem, fingerprint });
|
|
260
|
+
if (peer.fingerprint !== fingerprint) throw new OperatorError('DEVICE_ENROLLMENT_STATE_CORRUPT', 'Provisional device fingerprint changed.');
|
|
261
|
+
}
|
|
262
|
+
const status = raw.status === 'pending' || raw.status === 'reserved' || raw.status === 'claimed' || raw.status === 'issued'
|
|
263
|
+
? raw.status : (() => { throw new OperatorError('DEVICE_ENROLLMENT_STATE_CORRUPT', 'Device enrollment status is invalid.'); })();
|
|
264
|
+
const createdAt = validIso(raw.createdAt, 'createdAt');
|
|
265
|
+
const expiresAt = validIso(raw.expiresAt, 'expiresAt');
|
|
266
|
+
if (Date.parse(expiresAt) <= Date.parse(createdAt) || Date.parse(expiresAt) - Date.parse(createdAt) > MAX_TTL_MS) {
|
|
267
|
+
throw new OperatorError('DEVICE_ENROLLMENT_STATE_CORRUPT', 'Device enrollment lifetime is invalid.');
|
|
268
|
+
}
|
|
269
|
+
const userCodeSha256 = validHash(raw.userCodeSha256);
|
|
270
|
+
const pollTokenSha256 = validHash(raw.pollTokenSha256);
|
|
271
|
+
const accountId = raw.accountId === undefined ? undefined : validUuid(raw.accountId, 'accountId');
|
|
272
|
+
const authorityGeneration = raw.authorityGeneration === undefined ? undefined : validGeneration(raw.authorityGeneration);
|
|
273
|
+
const reservedAt = raw.reservedAt === undefined ? undefined : validIso(raw.reservedAt, 'reservedAt');
|
|
274
|
+
const claimedAt = raw.claimedAt === undefined ? undefined : validIso(raw.claimedAt, 'claimedAt');
|
|
275
|
+
const sessionJti = raw.sessionJti === undefined ? undefined : validUuid(raw.sessionJti, 'sessionJti');
|
|
276
|
+
const issuedAt = raw.issuedAt === undefined ? undefined : validIso(raw.issuedAt, 'issuedAt');
|
|
277
|
+
if (status === 'pending' && (accountId || authorityGeneration || reservedAt || claimedAt || sessionJti || issuedAt)) {
|
|
278
|
+
throw new OperatorError('DEVICE_ENROLLMENT_STATE_CORRUPT', 'Pending enrollment contains reserved authority.');
|
|
279
|
+
}
|
|
280
|
+
if (status === 'reserved' && (!accountId || !reservedAt || authorityGeneration || claimedAt || sessionJti || issuedAt)) {
|
|
281
|
+
throw new OperatorError('DEVICE_ENROLLMENT_STATE_CORRUPT', 'Reserved enrollment authority is incomplete.');
|
|
282
|
+
}
|
|
283
|
+
if (status === 'claimed' && (!accountId || !authorityGeneration || !reservedAt || !claimedAt || sessionJti || issuedAt)) {
|
|
284
|
+
throw new OperatorError('DEVICE_ENROLLMENT_STATE_CORRUPT', 'Claimed enrollment authority is incomplete.');
|
|
285
|
+
}
|
|
286
|
+
if (status === 'issued' && (!accountId || !authorityGeneration || !reservedAt || !claimedAt || !sessionJti || !issuedAt)) {
|
|
287
|
+
throw new OperatorError('DEVICE_ENROLLMENT_STATE_CORRUPT', 'Issued enrollment authority is incomplete.');
|
|
288
|
+
}
|
|
289
|
+
return { enrollmentId, deviceId, deviceName, fingerprint, status, createdAt, expiresAt,
|
|
290
|
+
userCodeSha256, pollTokenSha256, peerCreatedAt, peerPublicKeyPem, accountId, authorityGeneration, reservedAt, claimedAt, sessionJti, issuedAt };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function prune(state , nowMs ) {
|
|
294
|
+
state.enrollments = state.enrollments.filter((item) => {
|
|
295
|
+
if (item.status === 'pending' && Date.parse(item.expiresAt) <= nowMs) return false;
|
|
296
|
+
const reference = item.issuedAt ?? item.claimedAt ?? item.reservedAt ?? item.expiresAt;
|
|
297
|
+
return nowMs - Date.parse(reference) <= RETENTION_MS;
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function publicRecord(item ) {
|
|
302
|
+
return {
|
|
303
|
+
enrollmentId: item.enrollmentId, deviceId: item.deviceId, deviceName: item.deviceName,
|
|
304
|
+
fingerprint: item.fingerprint, status: item.status, createdAt: item.createdAt, expiresAt: item.expiresAt,
|
|
305
|
+
accountId: item.accountId, authorityGeneration: item.authorityGeneration,
|
|
306
|
+
reservedAt: item.reservedAt, claimedAt: item.claimedAt, sessionJti: item.sessionJti, issuedAt: item.issuedAt
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function normalizePeer(input ) {
|
|
310
|
+
const deviceId = validUuid(String(input?.deviceId ?? ''), 'deviceId');
|
|
311
|
+
const deviceName = boundedName(String(input?.deviceName ?? ''));
|
|
312
|
+
const createdAt = validIso(String(input?.createdAt ?? ''), 'peerCreatedAt');
|
|
313
|
+
let publicKey ;
|
|
314
|
+
try { publicKey = crypto.createPublicKey(String(input?.publicKeyPem ?? '')); }
|
|
315
|
+
catch { throw new OperatorError('DEVICE_ENROLLMENT_INPUT_INVALID', 'Provisional device public key is invalid.'); }
|
|
316
|
+
if (publicKey.asymmetricKeyType !== 'ed25519') throw new OperatorError('DEVICE_ENROLLMENT_INPUT_INVALID', 'Provisional device key must be Ed25519.');
|
|
317
|
+
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
318
|
+
const fingerprint = crypto.createHash('sha256').update(publicKeyPem).digest('base64url');
|
|
319
|
+
if (fingerprint !== validFingerprint(String(input?.fingerprint ?? ''))) throw new OperatorError('DEVICE_ENROLLMENT_INPUT_INVALID', 'Provisional device fingerprint does not match its key.');
|
|
320
|
+
return { deviceId, deviceName, createdAt, publicKeyPem, fingerprint };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function boundedTtl(input ) {
|
|
324
|
+
const value = Number(input);
|
|
325
|
+
if (!Number.isInteger(value) || value < MIN_TTL_MS || value > MAX_TTL_MS) {
|
|
326
|
+
throw new OperatorError('DEVICE_ENROLLMENT_TTL_INVALID', `Device enrollment TTL must be between ${MIN_TTL_MS} and ${MAX_TTL_MS} ms.`);
|
|
327
|
+
}
|
|
328
|
+
return value;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function boundedName(input ) {
|
|
332
|
+
const value = String(input ?? '').trim();
|
|
333
|
+
if (!value || value.length > 128 || /[\0\r\n]/.test(value)) {
|
|
334
|
+
throw new OperatorError('DEVICE_ENROLLMENT_INPUT_INVALID', 'Device enrollment name is invalid.');
|
|
335
|
+
}
|
|
336
|
+
return value;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function boundedPollToken(input ) {
|
|
340
|
+
const value = String(input ?? '').trim();
|
|
341
|
+
if (!/^[A-Za-z0-9_-]{40,128}$/.test(value)) {
|
|
342
|
+
throw new OperatorError('DEVICE_ENROLLMENT_UNAUTHORIZED', 'Device enrollment poll authority is invalid.');
|
|
343
|
+
}
|
|
344
|
+
return value;
|
|
345
|
+
}
|
|
346
|
+
function normalizeUserCode(input ) {
|
|
347
|
+
const compact = String(input ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
348
|
+
if (!/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/.test(compact)) {
|
|
349
|
+
throw new OperatorError('DEVICE_ENROLLMENT_CODE_INVALID', 'Device enrollment code is invalid or expired.');
|
|
350
|
+
}
|
|
351
|
+
return compact;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function userCodeFor(enrollmentId , pollToken ) {
|
|
355
|
+
const digest = crypto.createHash('sha256').update('operator-device-enrollment-v1:display:').update(enrollmentId).update(':').update(pollToken).digest();
|
|
356
|
+
let value = '';
|
|
357
|
+
for (let index = 0; index < 8; index += 1) value += CODE_ALPHABET[digest[index] % CODE_ALPHABET.length];
|
|
358
|
+
digest.fill(0);
|
|
359
|
+
return `${value.slice(0, 4)}-${value.slice(4)}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function secretHash(domain , value ) {
|
|
363
|
+
return crypto.createHash('sha256').update(`operator-device-enrollment-v1:${domain}:`).update(value).digest('hex');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function hashEquals(left , right ) {
|
|
367
|
+
const a = Buffer.from(validHash(left), 'hex');
|
|
368
|
+
const b = Buffer.from(validHash(right), 'hex');
|
|
369
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
370
|
+
}
|
|
371
|
+
function validHash(input ) {
|
|
372
|
+
const value = String(input ?? '').toLowerCase();
|
|
373
|
+
if (!/^[0-9a-f]{64}$/.test(value)) throw new OperatorError('DEVICE_ENROLLMENT_STATE_CORRUPT', 'Device enrollment secret hash is invalid.');
|
|
374
|
+
return value;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function validFingerprint(input ) {
|
|
378
|
+
const value = String(input ?? '');
|
|
379
|
+
if (!/^[A-Za-z0-9_-]{32,128}$/.test(value)) throw new OperatorError('DEVICE_ENROLLMENT_INPUT_INVALID', 'Device fingerprint is invalid.');
|
|
380
|
+
return value;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function validGeneration(input ) {
|
|
384
|
+
const value = Number(input);
|
|
385
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new OperatorError('DEVICE_ENROLLMENT_INPUT_INVALID', 'Device authority generation is invalid.');
|
|
386
|
+
return value;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function validUuid(input , label ) {
|
|
390
|
+
const value = String(input ?? '').toLowerCase();
|
|
391
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)) {
|
|
392
|
+
throw new OperatorError('DEVICE_ENROLLMENT_INPUT_INVALID', `${label} must be a UUID.`);
|
|
393
|
+
}
|
|
394
|
+
return value;
|
|
395
|
+
}
|
|
396
|
+
function validIso(input , label ) {
|
|
397
|
+
const value = String(input ?? '');
|
|
398
|
+
const time = Date.parse(value);
|
|
399
|
+
if (!Number.isFinite(time) || new Date(time).toISOString() !== value) {
|
|
400
|
+
throw new OperatorError('DEVICE_ENROLLMENT_STATE_CORRUPT', `${label} must be an ISO timestamp.`);
|
|
401
|
+
}
|
|
402
|
+
return value;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
export function enrollmentPollBinding(enrollmentIdInput , deviceIdInput , pollTokenInput ) {
|
|
407
|
+
const enrollmentId = validUuid(enrollmentIdInput, 'enrollmentId');
|
|
408
|
+
const deviceId = validUuid(deviceIdInput, 'deviceId');
|
|
409
|
+
const pollToken = boundedPollToken(pollTokenInput);
|
|
410
|
+
return Buffer.from(JSON.stringify({
|
|
411
|
+
version: 1,
|
|
412
|
+
purpose: 'operator-device-enrollment-poll-v1',
|
|
413
|
+
enrollmentId,
|
|
414
|
+
deviceId,
|
|
415
|
+
pollToken
|
|
416
|
+
}), 'utf8');
|
|
417
|
+
}
|