ftown 0.19.54 → 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.
@@ -0,0 +1,716 @@
1
+ import { exportPublicKey, importPublicKey, open, parseSecureEnvelope, seal } from './crypto.js';
2
+ import { AccountTrustError, ACCOUNT_TRUST_LIMITS, openAccountApproval, parseAccountEnrollmentRequest, parseAccountHistory, sealAccountApproval, verifyAccountHistory, } from './account-crypto.js';
3
+ export const PENDING_APPROVAL_LIMITS = Object.freeze({
4
+ lifetimeMs: 300000, pollIntervalMs: 2000, requestTimeoutMs: 15000,
5
+ maxPendingPerTarget: 128, maxPendingGlobal: 4096, maxWireBytes: 65536,
6
+ maxInboxBytes: 524288, maxFutureSkewMs: 30000,
7
+ });
8
+ const encoder = new TextEncoder();
9
+ const utf8 = (value) => encoder.encode(value);
10
+ const invalid = () => { throw new AccountTrustError('account_enrollment_invalid'); };
11
+ async function safe(run) {
12
+ try {
13
+ return await run();
14
+ }
15
+ catch (error) {
16
+ if (error instanceof AccountTrustError && error.code !== 'invalid_account_trust')
17
+ throw error;
18
+ return invalid();
19
+ }
20
+ }
21
+ function object(value) {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value)
23
+ || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null))
24
+ return invalid();
25
+ return value;
26
+ }
27
+ function exact(value, fields) {
28
+ const parsed = object(value);
29
+ const keys = Object.keys(parsed);
30
+ if (keys.length !== fields.length || keys.some(key => !fields.includes(key)))
31
+ return invalid();
32
+ return parsed;
33
+ }
34
+ function canonical(value, depth = 0) {
35
+ if (depth > 32)
36
+ return invalid();
37
+ if (typeof value === 'string') {
38
+ if (value.length > PENDING_APPROVAL_LIMITS.maxWireBytes)
39
+ throw new AccountTrustError('account_capacity_exceeded');
40
+ return JSON.stringify(value);
41
+ }
42
+ if (value === null || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)))
43
+ return JSON.stringify(value);
44
+ if (Array.isArray(value)) {
45
+ if (value.length > 4096)
46
+ throw new AccountTrustError('account_capacity_exceeded');
47
+ return '[' + value.map(item => canonical(item, depth + 1)).join(',') + ']';
48
+ }
49
+ const record = object(value);
50
+ const keys = Object.keys(record).sort();
51
+ if (keys.length > 128)
52
+ throw new AccountTrustError('account_capacity_exceeded');
53
+ return '{' + keys.map(key => JSON.stringify(key) + ':' + canonical(record[key], depth + 1)).join(',') + '}';
54
+ }
55
+ function wire(value) {
56
+ if (utf8(canonical(value)).byteLength > PENDING_APPROVAL_LIMITS.maxWireBytes)
57
+ throw new AccountTrustError('account_capacity_exceeded');
58
+ }
59
+ const same = (left, right) => canonical(left) === canonical(right);
60
+ const signedBytes = (domain, value) => utf8(domain + '\n' + canonical(value));
61
+ function b64(value) {
62
+ let text = '';
63
+ for (const byte of value)
64
+ text += String.fromCharCode(byte);
65
+ return btoa(text).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
66
+ }
67
+ function bytes(value, size) {
68
+ if (typeof value !== 'string' || value.length !== Math.ceil(size * 4 / 3) || !/^[A-Za-z0-9_-]+$/.test(value))
69
+ return invalid();
70
+ let decoded;
71
+ try {
72
+ decoded = atob(value.replaceAll('-', '+').replaceAll('_', '/'));
73
+ }
74
+ catch {
75
+ return invalid();
76
+ }
77
+ const raw = Uint8Array.from(decoded, character => character.charCodeAt(0));
78
+ if (raw.length !== size || b64(raw) !== value)
79
+ return invalid();
80
+ return raw;
81
+ }
82
+ const fingerprint = (value) => { bytes(value, 32); return value; };
83
+ const signature = (value) => { bytes(value, 64); return value; };
84
+ function point(value) {
85
+ if (bytes(value, 65)[0] !== 4)
86
+ return invalid();
87
+ return value;
88
+ }
89
+ function owner(value) {
90
+ if (typeof value !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value))
91
+ return invalid();
92
+ return value;
93
+ }
94
+ function identifier(value) {
95
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(value))
96
+ return invalid();
97
+ return value;
98
+ }
99
+ function timestamp(value) {
100
+ if (typeof value !== 'string' || value.length !== 24 || !Number.isFinite(Date.parse(value))
101
+ || Date.parse(value) < 0 || new Date(Date.parse(value)).toISOString() !== value)
102
+ return invalid();
103
+ return value;
104
+ }
105
+ function time(now) {
106
+ if (!Number.isSafeInteger(now) || now < 0 || now > 253402300799999)
107
+ return invalid();
108
+ return now;
109
+ }
110
+ function integer(value, max = Number.MAX_SAFE_INTEGER) {
111
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0 || value > max)
112
+ return invalid();
113
+ return value;
114
+ }
115
+ function label(value) {
116
+ if (typeof value !== 'string' || value.length > ACCOUNT_TRUST_LIMITS.maxLabelLength
117
+ || /[\u0000-\u001f\u007f-\u009f\ud800-\udfff]/u.test(value))
118
+ return invalid();
119
+ return value;
120
+ }
121
+ function head(value) {
122
+ const r = exact(value, ['rootId', 'revision', 'head']);
123
+ return { rootId: fingerprint(r.rootId), revision: integer(r.revision, ACCOUNT_TRUST_LIMITS.maxTransitions), head: fingerprint(r.head) };
124
+ }
125
+ const checkpointHead = (state) => ({ rootId: state.rootId, revision: state.revision, head: state.head });
126
+ function endpoint(value, kind) {
127
+ const r = exact(value, ['version', 'id', 'ownerId', 'kind', 'publicKey']);
128
+ if (r.version !== 1 || r.kind !== kind)
129
+ return invalid();
130
+ return { version: 1, id: fingerprint(r.id), ownerId: owner(r.ownerId), kind, publicKey: point(r.publicKey) };
131
+ }
132
+ async function validateEndpoint(value) {
133
+ await importPublicKey(value.publicKey);
134
+ if (b64(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes(value.publicKey, 65)))) !== value.id)
135
+ return invalid();
136
+ }
137
+ async function verificationKey(value) {
138
+ return crypto.subtle.importKey('raw', bytes(value, 65), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
139
+ }
140
+ async function verify(domain, payload, sig, publicKey) {
141
+ if (!await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, await verificationKey(publicKey), bytes(sig, 64), signedBytes(domain, payload)))
142
+ return invalid();
143
+ }
144
+ async function sign(domain, payload, signing) {
145
+ const key = signing.keyPair;
146
+ if (key.privateKey.type !== 'private' || key.privateKey.algorithm.name !== 'ECDSA' || !key.privateKey.usages.includes('sign')
147
+ || key.publicKey.type !== 'public' || key.publicKey.algorithm.name !== 'ECDSA'
148
+ || point(b64(new Uint8Array(await crypto.subtle.exportKey('raw', key.publicKey)))) !== signing.publicKey)
149
+ return invalid();
150
+ const result = b64(new Uint8Array(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key.privateKey, signedBytes(domain, payload))));
151
+ signature(result);
152
+ await verify(domain, payload, result, signing.publicKey);
153
+ return result;
154
+ }
155
+ async function hash(domain, value) {
156
+ return b64(new Uint8Array(await crypto.subtle.digest('SHA-256', signedBytes(domain, value))));
157
+ }
158
+ const random = () => b64(crypto.getRandomValues(new Uint8Array(32)));
159
+ async function request(value, now) {
160
+ wire(value);
161
+ // Timeless transcript hashing/terminal parsing validates syntax and proof,
162
+ // not liveness. Every operation granting/creating trust supplies a real now.
163
+ const validationTime = now === undefined ? Date.parse(timestamp(object(value).expiresAt)) - 1 : time(now);
164
+ return parseAccountEnrollmentRequest(value, validationTime);
165
+ }
166
+ function core(payload) {
167
+ const { commitment: _commitment, ...value } = payload;
168
+ return value;
169
+ }
170
+ async function readOffer(value, expected, now) {
171
+ wire(value);
172
+ const r = exact(value, ['payload', 'signature']);
173
+ const p = exact(r.payload, ['type', 'version', 'suite', 'request', 'ownerId', 'anchor', 'approver', 'signingPublicKey', 'offerId', 'expiresAt', 'commitment']);
174
+ if (p.type !== 'ftown_pending_offer' || p.version !== 1 || p.suite !== 'hpke-p256-sha256-commit-v1')
175
+ return invalid();
176
+ const req = await request(p.request, now);
177
+ if (expected && !same(req, await request(expected, now)))
178
+ return invalid();
179
+ const payload = {
180
+ type: 'ftown_pending_offer', version: 1, suite: 'hpke-p256-sha256-commit-v1',
181
+ request: req, ownerId: owner(p.ownerId), anchor: head(p.anchor), approver: endpoint(p.approver, 'browser'),
182
+ signingPublicKey: point(p.signingPublicKey), offerId: fingerprint(p.offerId),
183
+ expiresAt: timestamp(p.expiresAt), commitment: fingerprint(p.commitment),
184
+ };
185
+ if (payload.approver.ownerId !== payload.ownerId || payload.approver.publicKey === payload.signingPublicKey
186
+ || (req.kind === 'browser' && req.candidate.ownerId !== payload.ownerId) || payload.expiresAt !== req.expiresAt)
187
+ return invalid();
188
+ await validateEndpoint(payload.approver);
189
+ const sig = signature(r.signature);
190
+ await verify('ftown-pending-offer-v1', payload, sig, payload.signingPublicKey);
191
+ return { payload, signature: sig };
192
+ }
193
+ async function localOffer(value, now) {
194
+ const r = exact(value, ['offer', 'contribution']);
195
+ const offer = await readOffer(r.offer, undefined, now);
196
+ const contribution = fingerprint(r.contribution);
197
+ if (await hash('ftown-pending-commit-v1', { core: core(offer.payload), contribution }) !== offer.payload.commitment)
198
+ return invalid();
199
+ return { offer, contribution };
200
+ }
201
+ function candidateEndpoint(offer) {
202
+ return { ...offer.payload.request.candidate, ownerId: offer.payload.ownerId };
203
+ }
204
+ function candidateIdentity(candidate, offer) {
205
+ if (!same(candidate.endpoint, offer.payload.request.candidate))
206
+ return invalid();
207
+ return { ...candidate, endpoint: candidateEndpoint(offer) };
208
+ }
209
+ const bridgeContext = (offer) => offer.payload.request.kind === 'bridge'
210
+ ? offer.payload.request.bridgeId : 'account_' + offer.payload.ownerId;
211
+ function hello(value) {
212
+ wire(value);
213
+ const r = exact(value, ['version', 'requestId', 'offerHash', 'envelope']);
214
+ if (r.version !== 1)
215
+ return invalid();
216
+ return { version: 1, requestId: fingerprint(r.requestId), offerHash: fingerprint(r.offerHash), envelope: parseSecureEnvelope(r.envelope) };
217
+ }
218
+ function context(offer, envelope, purpose, now, fresh) {
219
+ const req = offer.payload.request;
220
+ const sender = purpose === 'enroll' ? req.candidate.id : offer.payload.approver.id;
221
+ const recipient = purpose === 'enroll' ? offer.payload.approver.id : req.candidate.id;
222
+ const channel = (purpose === 'enroll' ? 'pending-approval:' : 'account-enrollment:') + req.requestId;
223
+ if (envelope.ownerId !== offer.payload.ownerId || envelope.bridgeId !== bridgeContext(offer) || envelope.purpose !== purpose
224
+ || envelope.senderId !== sender || envelope.recipientId !== recipient || envelope.messageId !== req.requestId
225
+ || envelope.channel !== channel || envelope.receiverEpoch !== null
226
+ || envelope.createdAt > time(now) + PENDING_APPROVAL_LIMITS.maxFutureSkewMs
227
+ || envelope.expiresAt > Date.parse(req.expiresAt) || envelope.expiresAt - envelope.createdAt > 120000)
228
+ return invalid();
229
+ if (fresh && envelope.expiresAt <= now)
230
+ throw new AccountTrustError('account_enrollment_expired');
231
+ }
232
+ async function readHello(offer, value, now, fresh) {
233
+ const parsed = hello(value);
234
+ if (parsed.requestId !== offer.payload.request.requestId || parsed.offerHash !== await hash('ftown-pending-offer-hash-v1', offer.payload))
235
+ return invalid();
236
+ context(offer, parsed.envelope, 'enroll', now, fresh);
237
+ return parsed;
238
+ }
239
+ function confirmation(value) {
240
+ const r = exact(value, ['offerHash', 'mac']);
241
+ return { offerHash: fingerprint(r.offerHash), mac: fingerprint(r.mac) };
242
+ }
243
+ async function reveal(offer, value) {
244
+ const r = exact(value, ['offerHash', 'contribution']);
245
+ const parsed = { offerHash: fingerprint(r.offerHash), contribution: fingerprint(r.contribution) };
246
+ if (parsed.offerHash !== await hash('ftown-pending-offer-hash-v1', offer.payload)
247
+ || await hash('ftown-pending-commit-v1', { core: core(offer.payload), contribution: parsed.contribution }) !== offer.payload.commitment)
248
+ return invalid();
249
+ return parsed;
250
+ }
251
+ function secret(value) {
252
+ const r = exact(value, ['key']);
253
+ const key = r.key;
254
+ if (!key || key.type !== 'secret' || key.extractable !== false || key.algorithm?.name !== 'HKDF' || !key.usages.includes('deriveBits'))
255
+ return invalid();
256
+ return key;
257
+ }
258
+ async function importSecret(raw) {
259
+ return { key: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveBits']) };
260
+ }
261
+ async function derive(offer, value, disclosure, name) {
262
+ const parsed = await reveal(offer, disclosure);
263
+ const salt = bytes(await hash('ftown-pending-salt-v1', { offerHash: parsed.offerHash, contribution: parsed.contribution }), 32);
264
+ return new Uint8Array(await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt,
265
+ info: utf8('ftown-pending-v1/' + name) }, secret(value), name === 'sas' ? 48 : 256));
266
+ }
267
+ function candidateProof(offer, offerHash) {
268
+ return { role: 'candidate', requestId: offer.payload.request.requestId, offerHash };
269
+ }
270
+ async function mac(key, domain, payload) {
271
+ const imported = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
272
+ return b64(new Uint8Array(await crypto.subtle.sign('HMAC', imported, signedBytes(domain, payload))));
273
+ }
274
+ async function verifyMac(key, domain, payload, value) {
275
+ const imported = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
276
+ if (!await crypto.subtle.verify('HMAC', imported, bytes(value, 32), signedBytes(domain, payload)))
277
+ return invalid();
278
+ }
279
+ function approvedMember(state, req) {
280
+ const expected = { ...req.candidate, ownerId: state.ownerId };
281
+ if (req.kind === 'browser') {
282
+ return state.devices.some(member => same(member.endpoint, expected) && member.label === req.label && member.signingPublicKey === req.signingPublicKey);
283
+ }
284
+ return state.bridges.some(member => member.bridgeId === req.bridgeId && same(member.endpoint, expected) && member.label === req.label);
285
+ }
286
+ function requireMember(state, req) {
287
+ if (!approvedMember(state, req))
288
+ throw new AccountTrustError(req.kind === 'browser' ? 'account_device_revoked' : 'account_bridge_revoked');
289
+ }
290
+ function requireApprover(state, offer) {
291
+ const found = state.devices.find(member => member.endpoint.id === offer.payload.approver.id);
292
+ if (state.ownerId !== offer.payload.ownerId || !found || !same(found.endpoint, offer.payload.approver)
293
+ || found.signingPublicKey !== offer.payload.signingPublicKey)
294
+ throw new AccountTrustError('account_device_revoked');
295
+ }
296
+ function prefix(history, revision) {
297
+ if (history.transitions.length < revision)
298
+ throw new AccountTrustError('account_trust_rollback');
299
+ return { genesis: history.genesis, transitions: history.transitions.slice(0, revision) };
300
+ }
301
+ function addedCandidate(operation, req, ownerId) {
302
+ const normalized = { ...req.candidate, ownerId };
303
+ if (req.kind === 'browser' && operation.type === 'add_device')
304
+ return same(operation.device.endpoint, normalized)
305
+ && operation.device.signingPublicKey === req.signingPublicKey && operation.device.label === req.label;
306
+ if (req.kind === 'bridge' && operation.type === 'add_bridge')
307
+ return operation.bridge.bridgeId === req.bridgeId
308
+ && same(operation.bridge.endpoint, normalized) && operation.bridge.label === req.label;
309
+ return false;
310
+ }
311
+ async function approvalPrefix(offer, history, now) {
312
+ const parsed = parseAccountHistory(history);
313
+ const anchorHistory = prefix(parsed, offer.payload.anchor.revision);
314
+ const anchor = await verifyAccountHistory(anchorHistory, null, now);
315
+ if (!same(checkpointHead(anchor), offer.payload.anchor) || anchor.ownerId !== offer.payload.ownerId)
316
+ throw new AccountTrustError('account_trust_rollback');
317
+ requireApprover(anchor, offer);
318
+ const existing = approvedMember(anchor, offer.payload.request);
319
+ if (parsed.transitions.length !== anchor.revision + (existing ? 0 : 1))
320
+ throw new AccountTrustError('account_trust_conflict');
321
+ if (!existing) {
322
+ const addition = parsed.transitions[anchor.revision];
323
+ if (!addition || addition.payload.signerId !== offer.payload.approver.id
324
+ || !addedCandidate(addition.payload.operation, offer.payload.request, offer.payload.ownerId))
325
+ return invalid();
326
+ }
327
+ const current = await verifyAccountHistory(parsed, anchor, now);
328
+ requireMember(current, offer.payload.request);
329
+ return { history: parsed, checkpoint: current };
330
+ }
331
+ function bundle(value) {
332
+ const r = exact(value, ['version', 'requestId', 'ownerId', 'approver', 'envelope']);
333
+ if (r.version !== 1)
334
+ return invalid();
335
+ return { version: 1, requestId: fingerprint(r.requestId), ownerId: owner(r.ownerId),
336
+ approver: endpoint(r.approver, 'browser'), envelope: parseSecureEnvelope(r.envelope) };
337
+ }
338
+ function result(value) {
339
+ wire(value);
340
+ const r = exact(value, ['version', 'offerHash', 'head', 'bundle', 'mac']);
341
+ if (r.version !== 1)
342
+ return invalid();
343
+ return { version: 1, offerHash: fingerprint(r.offerHash), head: head(r.head), bundle: bundle(r.bundle), mac: fingerprint(r.mac) };
344
+ }
345
+ async function readResult(offer, value, now) {
346
+ const parsed = result(value);
347
+ if (parsed.offerHash !== await hash('ftown-pending-offer-hash-v1', offer.payload)
348
+ || parsed.bundle.requestId !== offer.payload.request.requestId || parsed.bundle.ownerId !== offer.payload.ownerId
349
+ || !same(parsed.bundle.approver, offer.payload.approver) || parsed.head.rootId !== offer.payload.anchor.rootId
350
+ || (parsed.head.revision !== offer.payload.anchor.revision && parsed.head.revision !== offer.payload.anchor.revision + 1))
351
+ return invalid();
352
+ context(offer, parsed.bundle.envelope, 'enrolled', now, false);
353
+ return parsed;
354
+ }
355
+ async function resultProof(offer, value) {
356
+ return { role: 'approver', requestId: offer.payload.request.requestId, offerHash: value.offerHash,
357
+ head: value.head, bundleHash: await hash('ftown-pending-bundle-v1', value.bundle) };
358
+ }
359
+ export function normalizePendingAccountHint(value) {
360
+ if (typeof value !== 'string' || value.length > PENDING_APPROVAL_LIMITS.maxWireBytes)
361
+ return invalid();
362
+ const parsed = value.replace(/^[\u0009-\u000d\u0020]+|[\u0009-\u000d\u0020]+$/g, '')
363
+ .replace(/[A-Z]/g, character => String.fromCharCode(character.charCodeAt(0) + 32));
364
+ const at = parsed.indexOf('@');
365
+ if (parsed.length > 254 || at <= 0 || at !== parsed.lastIndexOf('@') || at === parsed.length - 1
366
+ || /[\s\u0000-\u001f\u007f-\u009f\ud800-\udfff]/u.test(parsed))
367
+ return invalid();
368
+ return parsed;
369
+ }
370
+ export async function createPendingOffer(req, approver, signing, history, now = Date.now()) {
371
+ return safe(async () => {
372
+ const parsed = await request(req, now);
373
+ const current = await verifyAccountHistory(history, null, now);
374
+ const member = current.devices.find(item => item.endpoint.id === approver.endpoint.id);
375
+ if (!member || !same(member.endpoint, approver.endpoint) || member.signingPublicKey !== signing.publicKey
376
+ || (parsed.kind === 'browser' && parsed.candidate.ownerId !== current.ownerId))
377
+ return invalid();
378
+ if (await exportPublicKey(approver.keyPair.publicKey) !== approver.endpoint.publicKey)
379
+ return invalid();
380
+ const contribution = random();
381
+ const payloadCore = { type: 'ftown_pending_offer', version: 1, suite: 'hpke-p256-sha256-commit-v1',
382
+ request: parsed, ownerId: current.ownerId, anchor: checkpointHead(current), approver: { ...approver.endpoint },
383
+ signingPublicKey: signing.publicKey, offerId: random(), expiresAt: parsed.expiresAt };
384
+ const payload = { ...payloadCore, commitment: await hash('ftown-pending-commit-v1', { core: payloadCore, contribution }) };
385
+ const offer = { payload, signature: await sign('ftown-pending-offer-v1', payload, signing) };
386
+ wire(offer);
387
+ return { offer, contribution };
388
+ });
389
+ }
390
+ export async function parsePendingOffer(value, req, now = Date.now()) {
391
+ return safe(() => readOffer(value, req, now));
392
+ }
393
+ export async function pendingOfferHash(value) {
394
+ return safe(async () => hash('ftown-pending-offer-hash-v1', (await readOffer(value)).payload));
395
+ }
396
+ export async function pendingHelloHash(value) {
397
+ return safe(async () => hash('ftown-pending-hello-hash-v1', hello(value)));
398
+ }
399
+ export async function createPendingHello(candidate, value, now = Date.now()) {
400
+ return safe(async () => {
401
+ const offer = await readOffer(value, undefined, now);
402
+ const identity = candidateIdentity(candidate, offer);
403
+ const offerHash = await hash('ftown-pending-offer-hash-v1', offer.payload);
404
+ const raw = crypto.getRandomValues(new Uint8Array(32));
405
+ try {
406
+ const secret = await importSecret(raw);
407
+ const req = offer.payload.request;
408
+ const envelope = await seal(identity, offer.payload.approver, { type: 'ftown_encrypted', version: 1,
409
+ ownerId: offer.payload.ownerId, bridgeId: bridgeContext(offer), channel: 'pending-approval:' + req.requestId,
410
+ purpose: 'enroll', senderId: req.candidate.id, recipientId: offer.payload.approver.id, messageId: req.requestId,
411
+ createdAt: time(now), expiresAt: Math.min(now + 120000, Date.parse(req.expiresAt)), receiverEpoch: null,
412
+ }, utf8(JSON.stringify({ type: 'ftown_pending_secret', version: 1, requestId: req.requestId, offerHash, secret: b64(raw) })));
413
+ const hello = { version: 1, requestId: req.requestId, offerHash, envelope };
414
+ wire(hello);
415
+ return { hello, secret };
416
+ }
417
+ finally {
418
+ raw.fill(0);
419
+ }
420
+ });
421
+ }
422
+ export async function openPendingHello(approver, value, packet, now = Date.now()) {
423
+ return safe(async () => {
424
+ const local = await localOffer(value, now);
425
+ if (!same(approver.endpoint, local.offer.payload.approver))
426
+ return invalid();
427
+ const parsed = await readHello(local.offer, packet, now, true);
428
+ const req = local.offer.payload.request;
429
+ const plaintext = await open(approver, candidateEndpoint(local.offer), parsed.envelope, {
430
+ ownerId: local.offer.payload.ownerId, bridgeId: bridgeContext(local.offer), channel: 'pending-approval:' + req.requestId,
431
+ purpose: 'enroll', recipientId: approver.endpoint.id,
432
+ });
433
+ if (plaintext.byteLength > PENDING_APPROVAL_LIMITS.maxWireBytes)
434
+ return invalid();
435
+ const r = exact(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(plaintext)), ['type', 'version', 'requestId', 'offerHash', 'secret']);
436
+ if (r.type !== 'ftown_pending_secret' || r.version !== 1 || r.requestId !== req.requestId || r.offerHash !== parsed.offerHash)
437
+ return invalid();
438
+ const raw = bytes(r.secret, 32);
439
+ try {
440
+ return await importSecret(raw);
441
+ }
442
+ finally {
443
+ raw.fill(0);
444
+ plaintext.fill(0);
445
+ }
446
+ });
447
+ }
448
+ export async function pendingComparisonCode(value, key, disclosure) {
449
+ return safe(async () => {
450
+ const raw = await derive(await readOffer(value), key, disclosure, 'sas');
451
+ return Array.from(raw, byte => byte.toString(16).padStart(2, '0')).join('').toUpperCase().match(/.{4}/g).join('-');
452
+ });
453
+ }
454
+ export async function createPendingConfirmation(value, key, disclosure, confirmedOfferHash) {
455
+ return safe(async () => {
456
+ const offer = await readOffer(value);
457
+ const offerHash = await hash('ftown-pending-offer-hash-v1', offer.payload);
458
+ if (confirmedOfferHash !== offerHash)
459
+ return invalid();
460
+ return { offerHash, mac: await mac(await derive(offer, key, disclosure, 'candidate-confirm'), 'ftown-pending-candidate-confirm-v1', candidateProof(offer, offerHash)) };
461
+ });
462
+ }
463
+ export async function verifyPendingConfirmation(value, key, disclosure, proof) {
464
+ return safe(async () => {
465
+ const offer = await readOffer(value);
466
+ const parsed = confirmation(proof);
467
+ if (parsed.offerHash !== await hash('ftown-pending-offer-hash-v1', offer.payload))
468
+ return invalid();
469
+ await verifyMac(await derive(offer, key, disclosure, 'candidate-confirm'), 'ftown-pending-candidate-confirm-v1', candidateProof(offer, parsed.offerHash), parsed.mac);
470
+ });
471
+ }
472
+ export async function pendingBootstrapLink(value, key, disclosure) {
473
+ return safe(async () => {
474
+ const offer = await readOffer(value);
475
+ return { request: offer.payload.request, secret: b64(await derive(offer, key, disclosure, 'bootstrap')) };
476
+ });
477
+ }
478
+ export async function createPendingResult(approver, value, key, proof, confirmedOfferHash, history, now = Date.now()) {
479
+ return safe(async () => {
480
+ const local = await localOffer(value, now);
481
+ const offerHash = await hash('ftown-pending-offer-hash-v1', local.offer.payload);
482
+ if (confirmedOfferHash !== offerHash || !same(approver.endpoint, local.offer.payload.approver))
483
+ return invalid();
484
+ const disclosure = { offerHash, contribution: local.contribution };
485
+ await verifyPendingConfirmation(local.offer, key, disclosure, proof);
486
+ const approved = await approvalPrefix(local.offer, history, now);
487
+ const delivered = await sealAccountApproval(approver, await pendingBootstrapLink(local.offer, key, disclosure), approved.history, now);
488
+ const unsigned = { version: 1, offerHash, head: checkpointHead(approved.checkpoint), bundle: delivered };
489
+ const parsed = { ...unsigned, mac: await mac(await derive(local.offer, key, disclosure, 'approver-result'), 'ftown-pending-result-v1', await resultProof(local.offer, { ...unsigned, mac: '' })) };
490
+ wire(parsed);
491
+ return parsed;
492
+ });
493
+ }
494
+ export async function openPendingResult(candidate, value, key, disclosure, confirmedOfferHash, received, history, now = Date.now()) {
495
+ return safe(async () => {
496
+ const offer = await readOffer(value, undefined, now);
497
+ const identity = candidateIdentity(candidate, offer);
498
+ const parsed = await readResult(offer, received, now);
499
+ if (confirmedOfferHash !== parsed.offerHash)
500
+ return invalid();
501
+ await verifyMac(await derive(offer, key, disclosure, 'approver-result'), 'ftown-pending-result-v1', await resultProof(offer, parsed), parsed.mac);
502
+ const currentHistory = parseAccountHistory(history);
503
+ const approved = await approvalPrefix(offer, prefix(currentHistory, parsed.head.revision), now);
504
+ if (!same(checkpointHead(approved.checkpoint), parsed.head))
505
+ throw new AccountTrustError('account_trust_rollback');
506
+ const current = await verifyAccountHistory(currentHistory, approved.checkpoint, now);
507
+ requireMember(current, offer.payload.request);
508
+ if (parsed.bundle.envelope.expiresAt <= now)
509
+ return { status: 'awaiting-delivery-renewal' };
510
+ // Existing account opening verifies HMAC/membership. Also bind its encrypted
511
+ // approval head to this result, rather than accepting an older valid prefix.
512
+ const plaintext = await open(identity, offer.payload.approver, parsed.bundle.envelope, {
513
+ ownerId: offer.payload.ownerId, bridgeId: bridgeContext(offer),
514
+ channel: 'account-enrollment:' + offer.payload.request.requestId, purpose: 'enrolled', recipientId: candidate.endpoint.id,
515
+ });
516
+ if (plaintext.byteLength > 8192)
517
+ return invalid();
518
+ if (!same(head(object(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(plaintext))).head), parsed.head))
519
+ return invalid();
520
+ await openAccountApproval(candidate, await pendingBootstrapLink(offer, key, disclosure), parsed.bundle, approved.history, now);
521
+ return { status: 'verified', trust: { history: currentHistory, checkpoint: current } };
522
+ });
523
+ }
524
+ function operation(value) {
525
+ const r = object(value);
526
+ if (r.type === 'add_device') {
527
+ exact(r, ['type', 'device']);
528
+ const d = exact(r.device, ['endpoint', 'signingPublicKey', 'label', 'approvedAt']);
529
+ const parsed = { endpoint: endpoint(d.endpoint, 'browser'), signingPublicKey: point(d.signingPublicKey), label: label(d.label), approvedAt: timestamp(d.approvedAt) };
530
+ if (parsed.endpoint.publicKey === parsed.signingPublicKey)
531
+ return invalid();
532
+ return { type: 'add_device', device: parsed };
533
+ }
534
+ if (r.type === 'add_bridge') {
535
+ exact(r, ['type', 'bridge']);
536
+ const b = exact(r.bridge, ['bridgeId', 'endpoint', 'label', 'approvedAt']);
537
+ return { type: 'add_bridge', bridge: { bridgeId: identifier(b.bridgeId), endpoint: endpoint(b.endpoint, 'bridge'), label: label(b.label), approvedAt: timestamp(b.approvedAt) } };
538
+ }
539
+ return invalid();
540
+ }
541
+ function transition(value) {
542
+ const r = exact(value, ['payload', 'signature']);
543
+ const p = exact(r.payload, ['type', 'version', 'ownerId', 'rootId', 'revision', 'previousHead', 'issuedAt', 'signerId', 'operation']);
544
+ const rev = integer(p.revision, ACCOUNT_TRUST_LIMITS.maxTransitions);
545
+ if (p.type !== 'ftown_account_transition' || p.version !== 1 || rev === 0)
546
+ return invalid();
547
+ return { signature: signature(r.signature), payload: { type: 'ftown_account_transition', version: 1,
548
+ ownerId: owner(p.ownerId), rootId: fingerprint(p.rootId), revision: rev, previousHead: fingerprint(p.previousHead),
549
+ issuedAt: timestamp(p.issuedAt), signerId: fingerprint(p.signerId), operation: operation(p.operation) } };
550
+ }
551
+ async function action(value) {
552
+ const r = object(value);
553
+ switch (r.type) {
554
+ case 'review':
555
+ exact(r, ['type', 'offer']);
556
+ return { type: 'review', offer: await readOffer(r.offer) };
557
+ case 'reveal': {
558
+ exact(r, ['type', 'offerHash', 'helloHash', 'reveal']);
559
+ const d = exact(r.reveal, ['offerHash', 'contribution']);
560
+ return { type: 'reveal', offerHash: fingerprint(r.offerHash), helloHash: fingerprint(r.helloHash),
561
+ reveal: { offerHash: fingerprint(d.offerHash), contribution: fingerprint(d.contribution) } };
562
+ }
563
+ case 'reject':
564
+ exact(r, ['type']);
565
+ return { type: 'reject' };
566
+ case 'finish':
567
+ exact(r, ['type', 'offerHash', 'transition', 'result']);
568
+ return {
569
+ type: 'finish', offerHash: fingerprint(r.offerHash), transition: r.transition === null ? null : transition(r.transition), result: result(r.result),
570
+ };
571
+ case 'renew':
572
+ exact(r, ['type', 'offerHash', 'result']);
573
+ return { type: 'renew', offerHash: fingerprint(r.offerHash), result: result(r.result) };
574
+ default: return invalid();
575
+ }
576
+ }
577
+ async function reviewerPayload(value) {
578
+ wire(value);
579
+ const r = exact(value, ['type', 'version', 'requestId', 'ownerId', 'signerId', 'head', 'expectedRevision', 'issuedAt', 'action']);
580
+ if (r.type !== 'ftown_pending_action' || r.version !== 1)
581
+ return invalid();
582
+ const parsed = { type: 'ftown_pending_action', version: 1, requestId: fingerprint(r.requestId),
583
+ ownerId: owner(r.ownerId), signerId: fingerprint(r.signerId), head: head(r.head),
584
+ expectedRevision: integer(r.expectedRevision), issuedAt: timestamp(r.issuedAt), action: await action(r.action) };
585
+ const operation = parsed.action;
586
+ if (operation.type === 'review' && (operation.offer.payload.request.requestId !== parsed.requestId
587
+ || operation.offer.payload.ownerId !== parsed.ownerId || operation.offer.payload.approver.id !== parsed.signerId
588
+ || !same(operation.offer.payload.anchor, parsed.head)))
589
+ return invalid();
590
+ if (operation.type === 'reveal' && operation.reveal.offerHash !== operation.offerHash)
591
+ return invalid();
592
+ if ((operation.type === 'finish' || operation.type === 'renew') && operation.result.offerHash !== operation.offerHash)
593
+ return invalid();
594
+ return parsed;
595
+ }
596
+ export async function signPendingAction(payload, signing) {
597
+ return safe(async () => {
598
+ const parsed = await reviewerPayload(payload);
599
+ return { payload: parsed, signature: await sign('ftown-pending-action-v1', parsed, signing) };
600
+ });
601
+ }
602
+ export async function verifyPendingAction(value, req, history, now = Date.now()) {
603
+ return safe(async () => {
604
+ wire(value);
605
+ const raw = exact(value, ['payload', 'signature']);
606
+ const parsed = await reviewerPayload(raw.payload);
607
+ const sig = signature(raw.signature);
608
+ const enrollment = await request(req, now);
609
+ const current = await verifyAccountHistory(history, null, now);
610
+ if (parsed.requestId !== enrollment.requestId || parsed.ownerId !== current.ownerId
611
+ || (enrollment.kind === 'browser' && enrollment.candidate.ownerId !== parsed.ownerId)
612
+ || !same(parsed.head, checkpointHead(current)))
613
+ throw new AccountTrustError('account_trust_conflict');
614
+ const issued = Date.parse(parsed.issuedAt);
615
+ if (issued > time(now) + PENDING_APPROVAL_LIMITS.maxFutureSkewMs || issued >= Date.parse(enrollment.expiresAt)
616
+ || issued < Date.parse(enrollment.expiresAt) - PENDING_APPROVAL_LIMITS.lifetimeMs)
617
+ return invalid();
618
+ const signer = current.devices.find(item => item.endpoint.id === parsed.signerId);
619
+ if (!signer)
620
+ throw new AccountTrustError('account_device_revoked');
621
+ await verify('ftown-pending-action-v1', parsed, sig, signer.signingPublicKey);
622
+ const op = parsed.action;
623
+ if (op.type === 'review') {
624
+ const offer = await readOffer(op.offer, enrollment, now);
625
+ if (!same(offer.payload.approver, signer.endpoint) || offer.payload.signingPublicKey !== signer.signingPublicKey)
626
+ return invalid();
627
+ }
628
+ else if (op.type === 'finish' || op.type === 'renew') {
629
+ const receipt = op.result;
630
+ if (receipt.bundle.requestId !== enrollment.requestId || receipt.bundle.ownerId !== current.ownerId
631
+ || !same(receipt.bundle.approver, signer.endpoint))
632
+ return invalid();
633
+ let approved = current;
634
+ if (op.type === 'finish') {
635
+ if (op.transition === null)
636
+ requireMember(current, enrollment);
637
+ else {
638
+ const addition = op.transition;
639
+ if (approvedMember(current, enrollment) || addition.payload.signerId !== parsed.signerId
640
+ || !addedCandidate(addition.payload.operation, enrollment, current.ownerId))
641
+ return invalid();
642
+ approved = await verifyAccountHistory({ genesis: history.genesis, transitions: [...history.transitions, addition] }, current, now);
643
+ }
644
+ if (!same(receipt.head, checkpointHead(approved)))
645
+ return invalid();
646
+ }
647
+ else {
648
+ const atApproval = await verifyAccountHistory(prefix(parseAccountHistory(history), receipt.head.revision), null, now);
649
+ if (!same(receipt.head, checkpointHead(atApproval)))
650
+ return invalid();
651
+ requireMember(atApproval, enrollment);
652
+ requireMember(current, enrollment);
653
+ if (!atApproval.devices.some(member => same(member.endpoint, signer.endpoint)))
654
+ return invalid();
655
+ }
656
+ // Server checks stored offer/hello/confirmation and immutable renewal head;
657
+ // this method authenticates the actor and prospective membership.
658
+ const packet = receipt.bundle.envelope;
659
+ if (packet.ownerId !== current.ownerId || packet.bridgeId !== (enrollment.kind === 'bridge' ? enrollment.bridgeId : 'account_' + current.ownerId)
660
+ || packet.purpose !== 'enrolled' || packet.channel !== 'account-enrollment:' + enrollment.requestId
661
+ || packet.senderId !== signer.endpoint.id || packet.recipientId !== enrollment.candidate.id
662
+ || packet.messageId !== enrollment.requestId || packet.receiverEpoch !== null
663
+ || packet.createdAt > now + PENDING_APPROVAL_LIMITS.maxFutureSkewMs || packet.expiresAt <= now
664
+ || packet.expiresAt > Date.parse(enrollment.expiresAt) || packet.expiresAt - packet.createdAt > 120000)
665
+ return invalid();
666
+ }
667
+ return { payload: parsed, signature: sig };
668
+ });
669
+ }
670
+ export async function parsePendingState(value, now = Date.now()) {
671
+ return safe(async () => {
672
+ wire(value);
673
+ const raw = object(value);
674
+ const terminal = ['rejected', 'cancelled', 'expired', 'conflict'].includes(String(raw.status));
675
+ const enrollment = await request(raw.request, terminal ? undefined : now);
676
+ const base = { request: enrollment, revision: integer(raw.revision) };
677
+ const shared = ['request', 'revision', 'status'];
678
+ if (raw.status === 'pending') {
679
+ exact(raw, shared);
680
+ return { ...base, status: 'pending' };
681
+ }
682
+ if (raw.status === 'rejected' || raw.status === 'cancelled' || raw.status === 'expired' || raw.status === 'conflict') {
683
+ exact(raw, shared);
684
+ return { ...base, status: raw.status };
685
+ }
686
+ const fields = [...shared, 'offer'];
687
+ if (raw.status !== 'offered')
688
+ fields.push('hello');
689
+ if (raw.status === 'comparing' || raw.status === 'confirmed' || raw.status === 'approved')
690
+ fields.push('reveal');
691
+ if (raw.status === 'confirmed' || raw.status === 'approved')
692
+ fields.push('confirmation');
693
+ if (raw.status === 'approved')
694
+ fields.push('result');
695
+ exact(raw, fields);
696
+ const offer = await readOffer(raw.offer, enrollment, now);
697
+ if (raw.status === 'offered')
698
+ return { ...base, status: 'offered', offer };
699
+ const packet = await readHello(offer, raw.hello, now, false);
700
+ if (raw.status === 'submitted')
701
+ return { ...base, status: 'submitted', offer, hello: packet };
702
+ const disclosure = await reveal(offer, raw.reveal);
703
+ if (raw.status === 'comparing')
704
+ return { ...base, status: 'comparing', offer, hello: packet, reveal: disclosure };
705
+ const proof = confirmation(raw.confirmation);
706
+ if (proof.offerHash !== packet.offerHash)
707
+ return invalid();
708
+ if (raw.status === 'confirmed')
709
+ return { ...base, status: 'confirmed', offer, hello: packet, reveal: disclosure, confirmation: proof };
710
+ if (raw.status === 'approved')
711
+ return { ...base, status: 'approved', offer, hello: packet, reveal: disclosure, confirmation: proof,
712
+ result: await readResult(offer, raw.result, now) };
713
+ return invalid();
714
+ });
715
+ }
716
+ //# sourceMappingURL=pending-approval-crypto.js.map