ftown 0.19.53 → 0.19.55

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.
Files changed (53) hide show
  1. package/dist/bridge-auth.d.ts +3 -0
  2. package/dist/bridge-auth.js.map +1 -1
  3. package/dist/command-rpc.js +21 -12
  4. package/dist/command-rpc.js.map +1 -1
  5. package/dist/index.js +124 -7
  6. package/dist/index.js.map +1 -1
  7. package/dist/pairing-client.d.ts +24 -12
  8. package/dist/pairing-client.js +383 -13
  9. package/dist/pairing-client.js.map +1 -1
  10. package/dist/security/account-crypto.d.ts +33 -0
  11. package/dist/security/account-crypto.js +649 -0
  12. package/dist/security/account-crypto.js.map +1 -0
  13. package/dist/security/account-protocol.d.ts +228 -0
  14. package/dist/security/account-protocol.js +2 -0
  15. package/dist/security/account-protocol.js.map +1 -0
  16. package/dist/security/bridge/account-client.d.ts +7 -0
  17. package/dist/security/bridge/account-client.js +87 -0
  18. package/dist/security/bridge/account-client.js.map +1 -0
  19. package/dist/security/bridge/account-state.d.ts +471 -0
  20. package/dist/security/bridge/account-state.js +78 -0
  21. package/dist/security/bridge/account-state.js.map +1 -0
  22. package/dist/security/bridge/execute.js +2 -1
  23. package/dist/security/bridge/execute.js.map +1 -1
  24. package/dist/security/bridge/identity.d.ts +436 -0
  25. package/dist/security/bridge/identity.js +27 -5
  26. package/dist/security/bridge/identity.js.map +1 -1
  27. package/dist/security/bridge/pending-approval.d.ts +21 -0
  28. package/dist/security/bridge/pending-approval.js +79 -0
  29. package/dist/security/bridge/pending-approval.js.map +1 -0
  30. package/dist/security/bridge/pending-enrollment.d.ts +33 -0
  31. package/dist/security/bridge/pending-enrollment.js +177 -0
  32. package/dist/security/bridge/pending-enrollment.js.map +1 -0
  33. package/dist/security/bridge/permissions.js +2 -2
  34. package/dist/security/bridge/permissions.js.map +1 -1
  35. package/dist/security/bridge/policy.d.ts +20 -0
  36. package/dist/security/bridge/policy.js +159 -7
  37. package/dist/security/bridge/policy.js.map +1 -1
  38. package/dist/security/bridge/security.d.ts +11 -0
  39. package/dist/security/bridge/security.js +93 -3
  40. package/dist/security/bridge/security.js.map +1 -1
  41. package/dist/security/bridge/snapshot.d.ts +1 -1
  42. package/dist/security/bridge/snapshot.js.map +1 -1
  43. package/dist/security/pending-approval-crypto.d.ts +29 -0
  44. package/dist/security/pending-approval-crypto.js +716 -0
  45. package/dist/security/pending-approval-crypto.js.map +1 -0
  46. package/dist/security/pending-approval-protocol.d.ts +329 -0
  47. package/dist/security/pending-approval-protocol.js +2 -0
  48. package/dist/security/pending-approval-protocol.js.map +1 -0
  49. package/dist/security/protocol.d.ts +12 -1
  50. package/dist/security-cli.js +15 -0
  51. package/dist/security-cli.js.map +1 -1
  52. package/dist/types.d.ts +1 -1
  53. package/package.json +2 -2
@@ -0,0 +1,649 @@
1
+ import { exportPublicKey, generateEndpoint, importEndpoint, importPublicKey, open, parseSecureEnvelope, seal } from './crypto.js';
2
+ export const ACCOUNT_TRUST_LIMITS = Object.freeze({
3
+ maxHistoryBytes: 2097152, maxTransitions: 1024, maxDevices: 128, maxBridges: 128,
4
+ enrollmentLifetimeMs: 300000, refreshIntervalMs: 30000, offlineGraceMs: 86400000,
5
+ maxFutureSkewMs: 30000, maxLabelLength: 200,
6
+ });
7
+ /** No untrusted text, key material or underlying crypto error enters this error. */
8
+ export class AccountTrustError extends Error {
9
+ code;
10
+ retryable;
11
+ constructor(code) {
12
+ super(code === 'account_last_admin' ? 'Approve another account device before revoking this device.'
13
+ : code === 'account_enrollment_expired' ? 'This approval request expired. Start a new request.'
14
+ : code === 'account_capacity_exceeded' ? 'Account trust history reached its supported capacity.'
15
+ : code === 'account_trust_rollback' ? 'Account trust history does not continue the pinned history.'
16
+ : 'Account trust could not be verified.');
17
+ this.code = code;
18
+ this.name = 'AccountTrustError';
19
+ this.retryable = code === 'account_trust_conflict' || code === 'account_storage_unavailable';
20
+ }
21
+ }
22
+ const fail = (code = 'invalid_account_trust') => { throw new AccountTrustError(code); };
23
+ const encoder = new TextEncoder();
24
+ const TRUST_DOMAIN = 'ftown-account-trust-v1\n';
25
+ const ENROLL_DOMAIN = 'ftown-account-enrollment-v1\n';
26
+ const BOOTSTRAP_DOMAIN = 'ftown-account-bootstrap-v1\n';
27
+ const COMPARISON_DOMAIN = 'ftown-account-comparison-v1\n';
28
+ const PENDING_OWNER = '00000000-0000-0000-0000-000000000000';
29
+ const MAX_LINK_BYTES = 8192;
30
+ const utf8 = (value) => encoder.encode(value);
31
+ const nowValue = (now) => Number.isSafeInteger(now) && now >= 0 && now <= 253402300799999 ? now : fail();
32
+ function object(value) {
33
+ if (value === null || typeof value !== 'object' || Array.isArray(value)
34
+ || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null))
35
+ return fail();
36
+ return value;
37
+ }
38
+ function exact(value, fields) {
39
+ const record = object(value);
40
+ const keys = Object.keys(record);
41
+ if (keys.length !== fields.length || keys.some(key => !fields.includes(key)))
42
+ return fail();
43
+ return record;
44
+ }
45
+ function string(value, max) {
46
+ if (typeof value !== 'string' || value.length > max)
47
+ return fail();
48
+ return value;
49
+ }
50
+ function token(value) {
51
+ const parsed = string(value, 200);
52
+ if (!/^[A-Za-z0-9_-]{1,200}$/.test(parsed))
53
+ return fail();
54
+ return parsed;
55
+ }
56
+ function owner(value) {
57
+ const parsed = string(value, 36);
58
+ if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(parsed))
59
+ return fail();
60
+ return parsed;
61
+ }
62
+ function label(value) {
63
+ const parsed = string(value, ACCOUNT_TRUST_LIMITS.maxLabelLength);
64
+ // Refuse control characters and unpaired surrogates instead of silently changing signed labels.
65
+ if (/[\u0000-\u001f\u007f-\u009f\ud800-\udfff]/u.test(parsed))
66
+ return fail();
67
+ return parsed;
68
+ }
69
+ function b64(value) {
70
+ let binary = '';
71
+ for (const byte of value)
72
+ binary += String.fromCharCode(byte);
73
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
74
+ }
75
+ function bytes(value, length) {
76
+ const parsed = string(value, Math.ceil(length * 4 / 3));
77
+ if (!/^[A-Za-z0-9_-]+$/.test(parsed))
78
+ return fail();
79
+ let binary;
80
+ try {
81
+ binary = atob(parsed.replaceAll('-', '+').replaceAll('_', '/'));
82
+ }
83
+ catch {
84
+ return fail();
85
+ }
86
+ if (binary.length !== length)
87
+ return fail();
88
+ const result = Uint8Array.from(binary, character => character.charCodeAt(0));
89
+ if (b64(result) !== parsed)
90
+ return fail();
91
+ return result;
92
+ }
93
+ const fingerprint = (value) => { const parsed = string(value, 43); bytes(parsed, 32); return parsed; };
94
+ function point(value) {
95
+ const parsed = string(value, 87);
96
+ if (bytes(parsed, 65)[0] !== 4)
97
+ return fail();
98
+ return parsed;
99
+ }
100
+ function timestamp(value) {
101
+ const parsed = string(value, 24);
102
+ const ms = Date.parse(parsed);
103
+ if (!Number.isFinite(ms) || ms < 0 || new Date(ms).toISOString() !== parsed)
104
+ return fail();
105
+ return parsed;
106
+ }
107
+ function revision(value) {
108
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0 || value > ACCOUNT_TRUST_LIMITS.maxTransitions)
109
+ return fail();
110
+ return value;
111
+ }
112
+ function endpoint(value, kind, pendingBridgeId) {
113
+ const record = exact(value, ['version', 'id', 'ownerId', 'kind', 'publicKey']);
114
+ if (record.version !== 1 || record.kind !== kind)
115
+ return fail();
116
+ const ownerId = pendingBridgeId ? record.ownerId === `pending:${pendingBridgeId}` ? record.ownerId : fail() : owner(record.ownerId);
117
+ return { version: 1, id: fingerprint(record.id), ownerId, kind, publicKey: point(record.publicKey) };
118
+ }
119
+ function device(value) {
120
+ const record = exact(value, ['endpoint', 'signingPublicKey', 'label', 'approvedAt']);
121
+ const parsed = { endpoint: endpoint(record.endpoint, 'browser'), signingPublicKey: point(record.signingPublicKey), label: label(record.label), approvedAt: timestamp(record.approvedAt) };
122
+ if (parsed.endpoint.publicKey === parsed.signingPublicKey)
123
+ return fail();
124
+ return parsed;
125
+ }
126
+ function bridge(value) {
127
+ const record = exact(value, ['bridgeId', 'endpoint', 'label', 'approvedAt']);
128
+ return { bridgeId: token(record.bridgeId), endpoint: endpoint(record.endpoint, 'bridge'), label: label(record.label), approvedAt: timestamp(record.approvedAt) };
129
+ }
130
+ function operation(value) {
131
+ const record = object(value);
132
+ switch (record.type) {
133
+ case 'add_device':
134
+ exact(record, ['type', 'device']);
135
+ return { type: 'add_device', device: device(record.device) };
136
+ case 'add_bridge':
137
+ exact(record, ['type', 'bridge']);
138
+ return { type: 'add_bridge', bridge: bridge(record.bridge) };
139
+ case 'revoke_device':
140
+ exact(record, ['type', 'endpointId']);
141
+ return { type: 'revoke_device', endpointId: fingerprint(record.endpointId) };
142
+ case 'revoke_bridge':
143
+ exact(record, ['type', 'bridgeId']);
144
+ return { type: 'revoke_bridge', bridgeId: token(record.bridgeId) };
145
+ default: return fail();
146
+ }
147
+ }
148
+ function genesis(value) {
149
+ const record = exact(value, ['type', 'version', 'ownerId', 'rootId', 'createdAt', 'device']);
150
+ if (record.type !== 'ftown_account_genesis' || record.version !== 1)
151
+ return fail();
152
+ return { type: record.type, version: 1, ownerId: owner(record.ownerId), rootId: fingerprint(record.rootId), createdAt: timestamp(record.createdAt), device: device(record.device) };
153
+ }
154
+ function transition(value) {
155
+ const record = exact(value, ['type', 'version', 'ownerId', 'rootId', 'revision', 'previousHead', 'issuedAt', 'signerId', 'operation']);
156
+ if (record.type !== 'ftown_account_transition' || record.version !== 1 || record.revision === 0)
157
+ return fail();
158
+ return { type: record.type, version: 1, ownerId: owner(record.ownerId), rootId: fingerprint(record.rootId), revision: revision(record.revision),
159
+ previousHead: fingerprint(record.previousHead), issuedAt: timestamp(record.issuedAt), signerId: fingerprint(record.signerId), operation: operation(record.operation) };
160
+ }
161
+ function signed(value, parse) {
162
+ const record = exact(value, ['payload', 'signature']);
163
+ const signature = string(record.signature, 86);
164
+ bytes(signature, 64);
165
+ return { payload: parse(record.payload), signature };
166
+ }
167
+ function array(value, max, parse) {
168
+ if (!Array.isArray(value))
169
+ return fail();
170
+ if (value.length > max)
171
+ return fail('account_capacity_exceeded');
172
+ return value.map(parse);
173
+ }
174
+ function canonical(value) {
175
+ if (value === null || typeof value === 'boolean' || typeof value === 'string')
176
+ return JSON.stringify(value);
177
+ if (typeof value === 'number' && Number.isFinite(value))
178
+ return JSON.stringify(value);
179
+ if (Array.isArray(value))
180
+ return `[${value.map(canonical).join(',')}]`;
181
+ const record = object(value);
182
+ return `{${Object.keys(record).sort().map(key => `${JSON.stringify(key)}:${canonical(record[key])}`).join(',')}}`;
183
+ }
184
+ const canonicalBytes = (domain, value) => utf8(domain + canonical(value));
185
+ async function hash(value) {
186
+ try {
187
+ return b64(new Uint8Array(await crypto.subtle.digest('SHA-256', canonicalBytes(TRUST_DOMAIN, value))));
188
+ }
189
+ catch {
190
+ return fail();
191
+ }
192
+ }
193
+ const random = () => { try {
194
+ return b64(crypto.getRandomValues(new Uint8Array(32)));
195
+ }
196
+ catch {
197
+ return fail();
198
+ } };
199
+ const equal = (left, right) => canonical(left) === canonical(right);
200
+ async function publicEndpoint(value) {
201
+ try {
202
+ await importPublicKey(value.publicKey);
203
+ const digest = b64(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes(value.publicKey, 65))));
204
+ if (digest !== value.id)
205
+ return fail();
206
+ }
207
+ catch {
208
+ return fail();
209
+ }
210
+ }
211
+ async function signingKey(publicKey) {
212
+ try {
213
+ return await crypto.subtle.importKey('raw', bytes(publicKey, 65), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
214
+ }
215
+ catch {
216
+ return fail();
217
+ }
218
+ }
219
+ async function signingPoint(key) {
220
+ if (key.type !== 'public' || key.algorithm.name !== 'ECDSA' || !('namedCurve' in key.algorithm) || key.algorithm.namedCurve !== 'P-256')
221
+ return fail();
222
+ return point(b64(new Uint8Array(await crypto.subtle.exportKey('raw', key))));
223
+ }
224
+ async function publicDevice(value) { await publicEndpoint(value.endpoint); await signingKey(value.signingPublicKey); }
225
+ async function sign(value, identity, domain) {
226
+ try {
227
+ if (identity.keyPair.privateKey.algorithm.name !== 'ECDSA' || !identity.keyPair.privateKey.usages.includes('sign')
228
+ || await signingPoint(identity.keyPair.publicKey) !== identity.publicKey)
229
+ return fail();
230
+ const data = canonicalBytes(domain, value);
231
+ const signature = new Uint8Array(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, identity.keyPair.privateKey, data));
232
+ if (signature.length !== 64 || !await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, await signingKey(identity.publicKey), signature, data))
233
+ return fail();
234
+ return b64(signature);
235
+ }
236
+ catch {
237
+ return fail();
238
+ }
239
+ }
240
+ async function verify(value, signature, publicKey, domain) {
241
+ try {
242
+ if (!await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, await signingKey(publicKey), bytes(signature, 64), canonicalBytes(domain, value)))
243
+ return fail();
244
+ }
245
+ catch {
246
+ return fail();
247
+ }
248
+ }
249
+ function head(value) {
250
+ const record = exact(value, ['rootId', 'revision', 'head']);
251
+ return { rootId: fingerprint(record.rootId), revision: revision(record.revision), head: fingerprint(record.head) };
252
+ }
253
+ function checkpoint(value) {
254
+ const record = exact(value, ['version', 'ownerId', 'rootId', 'revision', 'head', 'devices', 'bridges', 'revokedDeviceIds', 'revokedBridgeIds']);
255
+ if (record.version !== 1)
256
+ return fail();
257
+ const parsed = { version: 1, ownerId: owner(record.ownerId), ...head({ rootId: record.rootId, revision: record.revision, head: record.head }),
258
+ devices: array(record.devices, ACCOUNT_TRUST_LIMITS.maxDevices, device), bridges: array(record.bridges, ACCOUNT_TRUST_LIMITS.maxBridges, bridge),
259
+ revokedDeviceIds: array(record.revokedDeviceIds, ACCOUNT_TRUST_LIMITS.maxTransitions, fingerprint), revokedBridgeIds: array(record.revokedBridgeIds, ACCOUNT_TRUST_LIMITS.maxTransitions, token) };
260
+ if (!parsed.devices.length || new Set(parsed.devices.map(item => item.endpoint.id)).size !== parsed.devices.length
261
+ || new Set(parsed.bridges.map(item => item.bridgeId)).size !== parsed.bridges.length
262
+ || new Set(parsed.bridges.map(item => item.endpoint.id)).size !== parsed.bridges.length
263
+ || new Set(parsed.revokedDeviceIds).size !== parsed.revokedDeviceIds.length || new Set(parsed.revokedBridgeIds).size !== parsed.revokedBridgeIds.length
264
+ || parsed.bridges.some(item => parsed.devices.some(device => device.endpoint.id === item.endpoint.id))
265
+ || parsed.devices.some(item => item.endpoint.ownerId !== parsed.ownerId || parsed.revokedDeviceIds.includes(item.endpoint.id))
266
+ || parsed.bridges.some(item => item.endpoint.ownerId !== parsed.ownerId || parsed.revokedBridgeIds.includes(item.bridgeId)))
267
+ return fail();
268
+ return parsed;
269
+ }
270
+ function apply(current, op, issuedAt) {
271
+ const next = { ...current, devices: [...current.devices], bridges: [...current.bridges], revokedDeviceIds: [...current.revokedDeviceIds], revokedBridgeIds: [...current.revokedBridgeIds] };
272
+ switch (op.type) {
273
+ case 'add_device':
274
+ if (op.device.endpoint.ownerId !== current.ownerId || Date.parse(op.device.approvedAt) > Date.parse(issuedAt)
275
+ || next.devices.some(item => item.endpoint.id === op.device.endpoint.id || item.signingPublicKey === op.device.signingPublicKey)
276
+ || next.bridges.some(item => item.endpoint.id === op.device.endpoint.id)
277
+ || next.revokedDeviceIds.includes(op.device.endpoint.id))
278
+ return fail();
279
+ if (next.devices.length >= ACCOUNT_TRUST_LIMITS.maxDevices)
280
+ return fail('account_capacity_exceeded');
281
+ next.devices.push(op.device);
282
+ break;
283
+ case 'add_bridge':
284
+ if (op.bridge.endpoint.ownerId !== current.ownerId || Date.parse(op.bridge.approvedAt) > Date.parse(issuedAt)
285
+ || next.bridges.some(item => item.bridgeId === op.bridge.bridgeId || item.endpoint.id === op.bridge.endpoint.id)
286
+ || next.devices.some(item => item.endpoint.id === op.bridge.endpoint.id)
287
+ || next.revokedBridgeIds.includes(op.bridge.bridgeId))
288
+ return fail();
289
+ if (next.bridges.length >= ACCOUNT_TRUST_LIMITS.maxBridges)
290
+ return fail('account_capacity_exceeded');
291
+ next.bridges.push(op.bridge);
292
+ break;
293
+ case 'revoke_device': {
294
+ const index = next.devices.findIndex(item => item.endpoint.id === op.endpointId);
295
+ if (index === -1)
296
+ return fail('account_device_revoked');
297
+ if (next.devices.length === 1)
298
+ return fail('account_last_admin');
299
+ next.devices.splice(index, 1);
300
+ next.revokedDeviceIds.push(op.endpointId);
301
+ break;
302
+ }
303
+ case 'revoke_bridge': {
304
+ const index = next.bridges.findIndex(item => item.bridgeId === op.bridgeId);
305
+ if (index === -1)
306
+ return fail('account_bridge_revoked');
307
+ next.bridges.splice(index, 1);
308
+ next.revokedBridgeIds.push(op.bridgeId);
309
+ break;
310
+ }
311
+ }
312
+ next.devices.sort((a, b) => a.endpoint.id < b.endpoint.id ? -1 : a.endpoint.id > b.endpoint.id ? 1 : 0);
313
+ next.bridges.sort((a, b) => a.bridgeId < b.bridgeId ? -1 : a.bridgeId > b.bridgeId ? 1 : 0);
314
+ next.revokedDeviceIds.sort();
315
+ next.revokedBridgeIds.sort();
316
+ return next;
317
+ }
318
+ function historySize(value) {
319
+ if (utf8(JSON.stringify(value)).byteLength > ACCOUNT_TRUST_LIMITS.maxHistoryBytes)
320
+ return fail('account_capacity_exceeded');
321
+ }
322
+ export function parseAccountHistory(value) {
323
+ const record = exact(value, ['genesis', 'transitions']);
324
+ const parsed = { genesis: signed(record.genesis, genesis), transitions: array(record.transitions, ACCOUNT_TRUST_LIMITS.maxTransitions, item => signed(item, transition)) };
325
+ historySize(parsed);
326
+ return parsed;
327
+ }
328
+ export async function generateAccountSigningIdentity(extractable = false) {
329
+ try {
330
+ const keyPair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, extractable, ['sign', 'verify']);
331
+ return { keyPair, publicKey: await signingPoint(keyPair.publicKey) };
332
+ }
333
+ catch {
334
+ return fail();
335
+ }
336
+ }
337
+ export async function generateAccountBridgeCandidate(bridgeId, extractable = false) {
338
+ const parsedId = token(bridgeId);
339
+ try {
340
+ const identity = await generateEndpoint(PENDING_OWNER, 'bridge', extractable);
341
+ return { ...identity, endpoint: { ...identity.endpoint, ownerId: `pending:${parsedId}` } };
342
+ }
343
+ catch {
344
+ return fail('account_enrollment_invalid');
345
+ }
346
+ }
347
+ export async function importAccountBridgeCandidate(value, privateKey, extractable = false) {
348
+ try {
349
+ if (typeof value.ownerId !== 'string' || !value.ownerId.startsWith('pending:'))
350
+ return fail();
351
+ const parsed = endpoint(value, 'bridge', token(value.ownerId.slice(8)));
352
+ const imported = await importEndpoint({ ...parsed, ownerId: PENDING_OWNER }, privateKey, extractable);
353
+ return { ...imported, endpoint: parsed };
354
+ }
355
+ catch {
356
+ return fail('account_enrollment_invalid');
357
+ }
358
+ }
359
+ export async function createAccountGenesis(ownerId, value, signing, name, now = Date.now()) {
360
+ const createdAt = new Date(nowValue(now)).toISOString();
361
+ const payload = genesis({ type: 'ftown_account_genesis', version: 1, ownerId, rootId: random(), createdAt,
362
+ device: { endpoint: value, signingPublicKey: signing.publicKey, label: name, approvedAt: createdAt } });
363
+ if (payload.device.endpoint.ownerId !== payload.ownerId)
364
+ return fail();
365
+ await publicDevice(payload.device);
366
+ return { payload, signature: await sign(payload, signing, TRUST_DOMAIN) };
367
+ }
368
+ export async function signAccountTransition(value, signerId, signing, op, now = Date.now()) {
369
+ const current = checkpoint(value);
370
+ const signer = current.devices.find(item => item.endpoint.id === signerId);
371
+ if (!signer || signer.signingPublicKey !== signing.publicKey)
372
+ return fail('account_device_revoked');
373
+ if (current.revision >= ACCOUNT_TRUST_LIMITS.maxTransitions)
374
+ return fail('account_capacity_exceeded');
375
+ const payload = transition({ type: 'ftown_account_transition', version: 1, ownerId: current.ownerId, rootId: current.rootId,
376
+ revision: current.revision + 1, previousHead: current.head, issuedAt: new Date(nowValue(now)).toISOString(), signerId, operation: op });
377
+ apply(current, payload.operation, payload.issuedAt);
378
+ if (payload.operation.type === 'add_device')
379
+ await publicDevice(payload.operation.device);
380
+ if (payload.operation.type === 'add_bridge')
381
+ await publicEndpoint(payload.operation.bridge.endpoint);
382
+ return { payload, signature: await sign(payload, signing, TRUST_DOMAIN) };
383
+ }
384
+ async function verifyHistory(value, now) {
385
+ nowValue(now);
386
+ const history = parseAccountHistory(value);
387
+ const initial = history.genesis.payload;
388
+ if (initial.device.endpoint.ownerId !== initial.ownerId || Date.parse(initial.device.approvedAt) !== Date.parse(initial.createdAt)
389
+ || Date.parse(initial.createdAt) > now + ACCOUNT_TRUST_LIMITS.maxFutureSkewMs)
390
+ return fail();
391
+ await publicDevice(initial.device);
392
+ await verify(initial, history.genesis.signature, initial.device.signingPublicKey, TRUST_DOMAIN);
393
+ let state = { version: 1, ownerId: initial.ownerId, rootId: initial.rootId, revision: 0,
394
+ head: await hash(initial), devices: [initial.device], bridges: [], revokedDeviceIds: [], revokedBridgeIds: [] };
395
+ const states = [state];
396
+ let previousTime = Date.parse(initial.createdAt);
397
+ for (const record of history.transitions) {
398
+ const next = record.payload;
399
+ const time = Date.parse(next.issuedAt);
400
+ if (next.ownerId !== state.ownerId || next.rootId !== state.rootId || next.revision !== state.revision + 1 || next.previousHead !== state.head
401
+ || time < previousTime || time > now + ACCOUNT_TRUST_LIMITS.maxFutureSkewMs)
402
+ return fail();
403
+ const signer = state.devices.find(item => item.endpoint.id === next.signerId);
404
+ if (!signer)
405
+ return fail('account_device_revoked');
406
+ await verify(next, record.signature, signer.signingPublicKey, TRUST_DOMAIN);
407
+ if (next.operation.type === 'add_device')
408
+ await publicDevice(next.operation.device);
409
+ if (next.operation.type === 'add_bridge')
410
+ await publicEndpoint(next.operation.bridge.endpoint);
411
+ state = { ...apply(state, next.operation, next.issuedAt), revision: next.revision, head: await hash(next) };
412
+ states.push(state);
413
+ previousTime = time;
414
+ }
415
+ return { history, states };
416
+ }
417
+ export async function verifyAccountHistory(history, pinned, now = Date.now()) {
418
+ if (pinned !== null && (history === null || typeof history !== 'object' || !('genesis' in history)))
419
+ return fail('account_trust_rollback');
420
+ const result = await verifyHistory(history, now);
421
+ if (pinned !== null) {
422
+ const previous = checkpoint(pinned);
423
+ const match = result.states[previous.revision];
424
+ if (!match || !equal(match, previous))
425
+ return fail('account_trust_rollback');
426
+ }
427
+ return result.states[result.states.length - 1];
428
+ }
429
+ function enrollmentShape(value) {
430
+ const record = object(value);
431
+ const shared = ['version', 'requestId', 'label', 'challenge', 'expiresAt', 'kind', 'candidate'];
432
+ if (record.version !== 1)
433
+ return fail();
434
+ const common = { version: 1, requestId: fingerprint(record.requestId), label: label(record.label), challenge: fingerprint(record.challenge), expiresAt: timestamp(record.expiresAt) };
435
+ if (record.kind === 'browser') {
436
+ exact(record, [...shared, 'signingPublicKey', 'signingProof']);
437
+ const parsed = { ...common, kind: 'browser', candidate: endpoint(record.candidate, 'browser'), signingPublicKey: point(record.signingPublicKey), signingProof: string(record.signingProof, 86) };
438
+ bytes(parsed.signingProof, 64);
439
+ if (parsed.candidate.publicKey === parsed.signingPublicKey)
440
+ return fail();
441
+ return parsed;
442
+ }
443
+ if (record.kind === 'bridge') {
444
+ exact(record, [...shared, 'bridgeId']);
445
+ const bridgeId = token(record.bridgeId);
446
+ return { ...common, kind: 'bridge', bridgeId, candidate: endpoint(record.candidate, 'bridge', bridgeId) };
447
+ }
448
+ return fail();
449
+ }
450
+ function enrollmentTime(request, now) {
451
+ nowValue(now);
452
+ const expiry = Date.parse(request.expiresAt);
453
+ if (expiry <= now)
454
+ return fail('account_enrollment_expired');
455
+ if (expiry > now + ACCOUNT_TRUST_LIMITS.enrollmentLifetimeMs)
456
+ return fail('account_enrollment_invalid');
457
+ }
458
+ function unsignedRequest(request) {
459
+ const { signingProof: _proof, ...unsigned } = request;
460
+ return unsigned;
461
+ }
462
+ export async function parseAccountEnrollmentRequest(value, now = Date.now()) {
463
+ try {
464
+ const parsed = enrollmentShape(value);
465
+ enrollmentTime(parsed, now);
466
+ await publicEndpoint(parsed.candidate);
467
+ if (parsed.kind === 'browser')
468
+ await verify(unsignedRequest(parsed), parsed.signingProof, parsed.signingPublicKey, ENROLL_DOMAIN);
469
+ return parsed;
470
+ }
471
+ catch (error) {
472
+ if (error instanceof AccountTrustError && error.code === 'account_enrollment_expired')
473
+ throw error;
474
+ return fail('account_enrollment_invalid');
475
+ }
476
+ }
477
+ export async function createAccountEnrollment(identity, name, signing, bridgeId, now = Date.now()) {
478
+ try {
479
+ const common = { version: 1, requestId: random(), challenge: random(), label: label(name), expiresAt: new Date(nowValue(now) + ACCOUNT_TRUST_LIMITS.enrollmentLifetimeMs).toISOString() };
480
+ let request;
481
+ if (bridgeId !== null) {
482
+ if (signing !== null)
483
+ return fail();
484
+ request = { ...common, kind: 'bridge', candidate: endpoint(identity.endpoint, 'bridge', token(bridgeId)), bridgeId };
485
+ }
486
+ else {
487
+ if (!signing)
488
+ return fail();
489
+ const unsigned = { ...common, kind: 'browser', candidate: endpoint(identity.endpoint, 'browser'), signingPublicKey: point(signing.publicKey) };
490
+ request = { ...unsigned, signingProof: await sign(unsigned, signing, ENROLL_DOMAIN) };
491
+ }
492
+ if (await exportPublicKey(identity.keyPair.publicKey) !== request.candidate.publicKey || identity.keyPair.privateKey.algorithm.name !== 'ECDH'
493
+ || !identity.keyPair.privateKey.usages.includes('deriveBits'))
494
+ return fail();
495
+ return { request: await parseAccountEnrollmentRequest(request, now), secret: random() };
496
+ }
497
+ catch (error) {
498
+ if (error instanceof AccountTrustError && error.code === 'account_enrollment_expired')
499
+ throw error;
500
+ return fail('account_enrollment_invalid');
501
+ }
502
+ }
503
+ function linkShape(value) {
504
+ const record = exact(value, ['request', 'secret']);
505
+ return { request: enrollmentShape(record.request), secret: fingerprint(record.secret) };
506
+ }
507
+ export function encodeAccountEnrollmentLink(link) {
508
+ try {
509
+ const data = utf8(JSON.stringify(linkShape(link)));
510
+ if (data.byteLength > MAX_LINK_BYTES)
511
+ return fail();
512
+ return b64(data);
513
+ }
514
+ catch {
515
+ return fail('account_enrollment_invalid');
516
+ }
517
+ }
518
+ export async function parseAccountEnrollmentLink(value, now = Date.now()) {
519
+ try {
520
+ let decoded = value;
521
+ if (typeof value === 'string') {
522
+ if (value.length > Math.ceil(MAX_LINK_BYTES * 4 / 3) || !/^[A-Za-z0-9_-]+$/.test(value))
523
+ return fail();
524
+ const binary = atob(value.replaceAll('-', '+').replaceAll('_', '/'));
525
+ if (binary.length > MAX_LINK_BYTES)
526
+ return fail();
527
+ const data = Uint8Array.from(binary, character => character.charCodeAt(0));
528
+ if (b64(data) !== value)
529
+ return fail();
530
+ decoded = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(data));
531
+ }
532
+ const link = linkShape(decoded);
533
+ link.request = await parseAccountEnrollmentRequest(link.request, now);
534
+ return link;
535
+ }
536
+ catch (error) {
537
+ if (error instanceof AccountTrustError && error.code === 'account_enrollment_expired')
538
+ throw error;
539
+ return fail('account_enrollment_invalid');
540
+ }
541
+ }
542
+ export async function accountComparisonCode(link) {
543
+ try {
544
+ const parsed = linkShape(link);
545
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', canonicalBytes(COMPARISON_DOMAIN, parsed)));
546
+ const hex = Array.from(digest.slice(0, 6), byte => byte.toString(16).padStart(2, '0')).join('').toUpperCase();
547
+ return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}`;
548
+ }
549
+ catch {
550
+ return fail('account_enrollment_invalid');
551
+ }
552
+ }
553
+ function normalizedCandidate(request, ownerId) {
554
+ if (request.kind === 'browser' && request.candidate.ownerId !== ownerId)
555
+ return fail('account_enrollment_invalid');
556
+ return { ...request.candidate, ownerId };
557
+ }
558
+ function member(state, request) {
559
+ const candidate = normalizedCandidate(request, state.ownerId);
560
+ if (request.kind === 'browser') {
561
+ const found = state.devices.find(item => item.endpoint.id === candidate.id);
562
+ if (!found || !equal(found.endpoint, candidate) || found.signingPublicKey !== request.signingPublicKey || found.label !== request.label)
563
+ return fail('account_device_revoked');
564
+ }
565
+ else {
566
+ const found = state.bridges.find(item => item.bridgeId === request.bridgeId);
567
+ if (!found || !equal(found.endpoint, candidate) || found.label !== request.label)
568
+ return fail('account_bridge_revoked');
569
+ }
570
+ }
571
+ async function secretKey(secret, usage) {
572
+ return crypto.subtle.importKey('raw', bytes(secret, 32), { name: 'HMAC', hash: 'SHA-256' }, false, [usage]);
573
+ }
574
+ const proofBytes = (plaintext, approver) => canonicalBytes(BOOTSTRAP_DOMAIN, { ...plaintext, approver });
575
+ function bundleShape(value) {
576
+ const record = exact(value, ['version', 'requestId', 'ownerId', 'approver', 'envelope']);
577
+ if (record.version !== 1)
578
+ return fail();
579
+ return { version: 1, requestId: fingerprint(record.requestId), ownerId: owner(record.ownerId), approver: endpoint(record.approver, 'browser'), envelope: parseSecureEnvelope(record.envelope) };
580
+ }
581
+ export async function sealAccountApproval(approver, link, history, now = Date.now()) {
582
+ try {
583
+ const parsed = await parseAccountEnrollmentLink(link, now);
584
+ const state = await verifyAccountHistory(history, null, now);
585
+ const activeApprover = state.devices.find(item => item.endpoint.id === approver.endpoint.id);
586
+ if (!activeApprover || !equal(activeApprover.endpoint, approver.endpoint))
587
+ return fail();
588
+ member(state, parsed.request);
589
+ const plaintext = { type: 'ftown_account_approval', version: 1, request: parsed.request,
590
+ ownerId: state.ownerId, head: { rootId: state.rootId, revision: state.revision, head: state.head }, approvedAt: new Date(now).toISOString() };
591
+ const secretProof = b64(new Uint8Array(await crypto.subtle.sign('HMAC', await secretKey(parsed.secret, 'sign'), proofBytes(plaintext, approver.endpoint))));
592
+ const request = parsed.request;
593
+ const envelope = await seal(approver, normalizedCandidate(request, state.ownerId), {
594
+ type: 'ftown_encrypted', version: 1, ownerId: state.ownerId, bridgeId: request.kind === 'bridge' ? request.bridgeId : `account_${state.ownerId}`,
595
+ channel: `account-enrollment:${request.requestId}`, purpose: 'enrolled', senderId: approver.endpoint.id, recipientId: request.candidate.id,
596
+ messageId: request.requestId, createdAt: now, expiresAt: Math.min(now + 120000, Date.parse(request.expiresAt)), receiverEpoch: null,
597
+ }, utf8(JSON.stringify({ ...plaintext, secretProof })));
598
+ return { version: 1, requestId: request.requestId, ownerId: state.ownerId, approver: { ...approver.endpoint }, envelope };
599
+ }
600
+ catch (error) {
601
+ if (error instanceof AccountTrustError)
602
+ throw error;
603
+ return fail('account_enrollment_invalid');
604
+ }
605
+ }
606
+ export async function openAccountApproval(candidate, link, bundle, history, now = Date.now()) {
607
+ try {
608
+ const parsed = await parseAccountEnrollmentLink(link, now);
609
+ const received = bundleShape(bundle);
610
+ const request = parsed.request;
611
+ if (!equal(candidate.endpoint, request.candidate) || received.requestId !== request.requestId || received.approver.ownerId !== received.ownerId)
612
+ return fail();
613
+ const wire = received.envelope;
614
+ if (wire.messageId !== request.requestId || wire.receiverEpoch !== null || wire.createdAt > now + ACCOUNT_TRUST_LIMITS.maxFutureSkewMs
615
+ || wire.expiresAt <= now || wire.expiresAt - wire.createdAt > 120000 || wire.expiresAt > Date.parse(request.expiresAt))
616
+ return fail();
617
+ const identity = { ...candidate, endpoint: normalizedCandidate(request, received.ownerId) };
618
+ const data = await open(identity, received.approver, wire, { ownerId: received.ownerId, bridgeId: request.kind === 'bridge' ? request.bridgeId : `account_${received.ownerId}`,
619
+ channel: `account-enrollment:${request.requestId}`, purpose: 'enrolled', recipientId: request.candidate.id });
620
+ if (data.byteLength > MAX_LINK_BYTES)
621
+ return fail();
622
+ const raw = exact(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(data)), ['type', 'version', 'request', 'ownerId', 'head', 'approvedAt', 'secretProof']);
623
+ if (raw.type !== 'ftown_account_approval' || raw.version !== 1)
624
+ return fail();
625
+ const plaintext = { type: 'ftown_account_approval', version: 1, request: enrollmentShape(raw.request), ownerId: owner(raw.ownerId), head: head(raw.head), approvedAt: timestamp(raw.approvedAt) };
626
+ if (!equal(plaintext.request, request) || plaintext.ownerId !== received.ownerId || Date.parse(plaintext.approvedAt) > now + ACCOUNT_TRUST_LIMITS.maxFutureSkewMs
627
+ || Date.parse(plaintext.approvedAt) > wire.createdAt || Date.parse(plaintext.approvedAt) >= Date.parse(request.expiresAt))
628
+ return fail();
629
+ if (!await crypto.subtle.verify('HMAC', await secretKey(parsed.secret, 'verify'), bytes(raw.secretProof, 32), proofBytes(plaintext, received.approver)))
630
+ return fail();
631
+ const verified = await verifyHistory(history, now);
632
+ const atApproval = verified.states[plaintext.head.revision];
633
+ if (!atApproval || atApproval.ownerId !== received.ownerId || !equal({ rootId: atApproval.rootId, revision: atApproval.revision, head: atApproval.head }, plaintext.head))
634
+ return fail('account_trust_rollback');
635
+ const approverAtApproval = atApproval.devices.find(item => item.endpoint.id === received.approver.id);
636
+ if (!approverAtApproval || !equal(approverAtApproval.endpoint, received.approver))
637
+ return fail();
638
+ member(atApproval, request);
639
+ const latest = verified.states[verified.states.length - 1];
640
+ member(latest, request);
641
+ return { history: verified.history, checkpoint: latest };
642
+ }
643
+ catch (error) {
644
+ if (error instanceof AccountTrustError && ['account_enrollment_expired', 'account_trust_rollback', 'account_device_revoked', 'account_bridge_revoked', 'account_capacity_exceeded'].includes(error.code))
645
+ throw error;
646
+ return fail('account_enrollment_invalid');
647
+ }
648
+ }
649
+ //# sourceMappingURL=account-crypto.js.map