@forgesworn/fold-kit 0.10.0 → 0.13.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/CHANGELOG.md +371 -0
- package/EXTRACTION.md +549 -0
- package/README.md +178 -17
- package/dist/epoch.d.ts +42 -1
- package/dist/epoch.js +137 -70
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -2
- package/dist/invitation-decline.d.ts +26 -0
- package/dist/invitation-decline.js +74 -0
- package/dist/invitation.d.ts +56 -6
- package/dist/invitation.js +289 -67
- package/dist/mls-admission.d.ts +79 -0
- package/dist/mls-admission.js +400 -0
- package/dist/persistent-invitation-evidence.d.ts +83 -0
- package/dist/persistent-invitation-evidence.js +381 -0
- package/dist/quiet-reply.d.ts +45 -0
- package/dist/quiet-reply.js +82 -0
- package/dist/seal.d.ts +20 -0
- package/dist/seal.js +25 -0
- package/docs/extraction-plan-excerpt.md +56 -0
- package/docs/live-persistent-admission.md +192 -0
- package/docs/member-epoch-catch-up.md +346 -0
- package/docs/mls-admission-v4.md +84 -0
- package/docs/persistent-invitation-evidence.md +135 -0
- package/docs/quiet-replies.md +81 -0
- package/docs/room-destruct.md +142 -0
- package/docs/scheduled-rekey.md +142 -0
- package/docs/seal-key.md +143 -0
- package/llms.txt +98 -29
- package/package.json +22 -4
- package/vectors/fold-vectors.json +894 -0
- package/vectors/invitation-account-vectors.json +134 -0
- package/vectors/invitation-decline-vectors.json +110 -0
- package/vectors/mls-admission-v4.json +25 -0
- package/vectors/seal-vectors.json +304 -0
package/dist/invitation.js
CHANGED
|
@@ -10,8 +10,11 @@ import { KINDS } from './kinds.js';
|
|
|
10
10
|
import { verifyEventUncached } from './verify.js';
|
|
11
11
|
import { withExpiration } from './expiration.js';
|
|
12
12
|
import { deriveRoom } from './room.js';
|
|
13
|
+
import { decodeInvitationDecline, InvitationDeclinedError } from './invitation-decline.js';
|
|
14
|
+
import { DEFAULT_REPLY_DELAY_MS, defaultReplySchedule, replyDelay } from './quiet-reply.js';
|
|
13
15
|
const INVITATION_ID_INFO = 'kithmoot/v2/invitation-id';
|
|
14
16
|
const INVITATION_REQUEST_KEY_INFO = 'kithmoot/v2/invitation-request-key';
|
|
17
|
+
const INVITATION_ACCOUNT_PROOF = 'kithmoot/v2/invitation-account-proof';
|
|
15
18
|
const INVITATION_MAX_AGE_SECONDS = 90;
|
|
16
19
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
17
20
|
const DEFAULT_RETRY_MS = 2_000;
|
|
@@ -56,6 +59,40 @@ function requestKey(invitation) {
|
|
|
56
59
|
/** How much of a name an admission request carries. The same bound as a
|
|
57
60
|
* display name on the roster. */
|
|
58
61
|
const MAX_REQUEST_NAME_LENGTH = 64;
|
|
62
|
+
/** Prove control of an account for one ephemeral admission device. This is
|
|
63
|
+
* deliberately separate from a room device credential: a guest does not yet
|
|
64
|
+
* know the room or its traffic key, and this proof grants neither. */
|
|
65
|
+
export async function encodeInvitationAccountProof(opts) {
|
|
66
|
+
if (!Number.isSafeInteger(opts.now) || opts.now < 0)
|
|
67
|
+
throw new Error('invalid invitation account proof time');
|
|
68
|
+
const device = requirePubkey(opts.device), participant = requirePubkey(opts.identity.pubkey);
|
|
69
|
+
const proof = await opts.identity.signEvent({
|
|
70
|
+
kind: KINDS.INVITATION_REQUEST,
|
|
71
|
+
created_at: opts.now,
|
|
72
|
+
tags: [['t', INVITATION_ACCOUNT_PROOF], ['d', deriveInvitationId(opts.invitation)], ['p', requirePubkey(opts.invitation.inviter)]],
|
|
73
|
+
content: JSON.stringify({ v: 1, device }),
|
|
74
|
+
});
|
|
75
|
+
if (!accountProofMatches(proof, { invitation: opts.invitation, device, participant, now: opts.now }))
|
|
76
|
+
throw new Error('invalid invitation account proof from signer');
|
|
77
|
+
return proof;
|
|
78
|
+
}
|
|
79
|
+
function accountProofMatches(raw, opts) {
|
|
80
|
+
try {
|
|
81
|
+
if (!raw || typeof raw !== 'object' || !opts.participant)
|
|
82
|
+
return false;
|
|
83
|
+
const proof = raw;
|
|
84
|
+
if (proof.kind !== KINDS.INVITATION_REQUEST || proof.created_at !== opts.now || !hexEquals(proof.pubkey, opts.participant))
|
|
85
|
+
return false;
|
|
86
|
+
if (JSON.stringify(proof.tags) !== JSON.stringify([['t', INVITATION_ACCOUNT_PROOF], ['d', deriveInvitationId(opts.invitation)], ['p', requirePubkey(opts.invitation.inviter)]]))
|
|
87
|
+
return false;
|
|
88
|
+
if (proof.content !== JSON.stringify({ v: 1, device: requirePubkey(opts.device) }))
|
|
89
|
+
return false;
|
|
90
|
+
return verifyEventUncached(proof);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
59
96
|
/** Prove possession of the bearer without putting it, or a traffic key, on a relay. */
|
|
60
97
|
export function encodeInvitationRequest(opts) {
|
|
61
98
|
require32(opts.requesterSk, 'requester secret key');
|
|
@@ -66,6 +103,11 @@ export function encodeInvitationRequest(opts) {
|
|
|
66
103
|
body.name = name;
|
|
67
104
|
if (opts.participant !== undefined)
|
|
68
105
|
body.participant = requirePubkey(opts.participant);
|
|
106
|
+
if (opts.accountProof !== undefined) {
|
|
107
|
+
if (!accountProofMatches(opts.accountProof, { invitation: opts.invitation, device, participant: body.participant, now: opts.now }))
|
|
108
|
+
throw new Error('invalid invitation account proof');
|
|
109
|
+
body.accountProof = opts.accountProof;
|
|
110
|
+
}
|
|
69
111
|
return finalizeEvent({
|
|
70
112
|
kind: KINDS.INVITATION_REQUEST,
|
|
71
113
|
created_at: opts.now,
|
|
@@ -105,6 +147,8 @@ export function decodeInvitationRequest(event, opts) {
|
|
|
105
147
|
}
|
|
106
148
|
if (typeof body.participant === 'string' && /^[0-9a-f]{64}$/i.test(body.participant))
|
|
107
149
|
decoded.participant = body.participant.toLowerCase();
|
|
150
|
+
if (accountProofMatches(body.accountProof, { invitation: opts.invitation, device: decoded.device, participant: decoded.participant, now: event.created_at }))
|
|
151
|
+
decoded.verifiedParticipant = decoded.participant;
|
|
108
152
|
return decoded;
|
|
109
153
|
}
|
|
110
154
|
catch {
|
|
@@ -339,16 +383,38 @@ export function hostRoomInvitation(opts) {
|
|
|
339
383
|
}
|
|
340
384
|
const responder = getPublicKey(opts.inviterSk);
|
|
341
385
|
const invitationId = deriveInvitationId(opts.invitation);
|
|
342
|
-
|
|
386
|
+
// Keep each live decision, including refusals and pending approval, until
|
|
387
|
+
// its authenticated request expires. Evicting a live entry could re-prompt
|
|
388
|
+
// or mint fresh authority for the same signed request.
|
|
389
|
+
const requests = new Map();
|
|
390
|
+
const delayRange = opts.grantDelayMs ?? (opts.coordinate ? DEFAULT_REPLY_DELAY_MS : undefined);
|
|
391
|
+
const random = opts.random ?? Math.random;
|
|
392
|
+
const schedule = opts.schedule ?? defaultReplySchedule;
|
|
393
|
+
/** Grants waiting out their delay, each as how to cancel it. */
|
|
394
|
+
const waits = new Set();
|
|
395
|
+
/** Requesting devices another responder's grant was seen for since their
|
|
396
|
+
* request reached this one. Only with `coordinate`. */
|
|
397
|
+
const grantSeen = new Set();
|
|
398
|
+
const bound = (set) => {
|
|
399
|
+
if (set.size > 256)
|
|
400
|
+
set.delete(set.values().next().value);
|
|
401
|
+
};
|
|
343
402
|
let closed = false;
|
|
344
403
|
let unsubRequests = () => { };
|
|
345
404
|
let unsubRetirement = () => { };
|
|
405
|
+
let unsubGrants = () => { };
|
|
346
406
|
const close = () => {
|
|
347
407
|
if (closed)
|
|
348
408
|
return;
|
|
349
409
|
closed = true;
|
|
410
|
+
for (const cancel of waits)
|
|
411
|
+
cancel();
|
|
412
|
+
waits.clear();
|
|
413
|
+
requests.clear();
|
|
414
|
+
grantSeen.clear();
|
|
350
415
|
unsubRequests();
|
|
351
416
|
unsubRetirement();
|
|
417
|
+
unsubGrants();
|
|
352
418
|
};
|
|
353
419
|
// Subscribe to the durable tombstone before accepting requests. Real
|
|
354
420
|
// relays replay stored regular events, so a responder returning from an
|
|
@@ -363,26 +429,74 @@ export function hostRoomInvitation(opts) {
|
|
|
363
429
|
unsubRetirement();
|
|
364
430
|
return { close };
|
|
365
431
|
}
|
|
432
|
+
if (opts.coordinate) {
|
|
433
|
+
unsubGrants = opts.transport.subscribe([{ kinds: [KINDS.INVITATION_GRANT], '#d': [invitationId] }], (event) => {
|
|
434
|
+
if (closed || hexEquals(event.pubkey, responder))
|
|
435
|
+
return;
|
|
436
|
+
const to = event.tags.find((t) => t[0] === 'p')?.[1];
|
|
437
|
+
if (to === undefined || !/^[0-9a-f]{64}$/i.test(to))
|
|
438
|
+
return;
|
|
439
|
+
grantSeen.add(normaliseHex(to));
|
|
440
|
+
bound(grantSeen);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
366
443
|
unsubRequests = opts.transport.subscribe([{ kinds: [KINDS.INVITATION_REQUEST], '#d': [invitationId], '#p': [opts.invitation.inviter] }], (event) => {
|
|
367
444
|
if (closed)
|
|
368
445
|
return;
|
|
369
446
|
const request = decodeInvitationRequest(event, { invitation: opts.invitation, now: now() });
|
|
370
447
|
// Some relays incorrectly retain and replay ephemeral requests. A newly
|
|
371
448
|
// admitted delegate must not answer the request that admitted itself.
|
|
372
|
-
if (!request || hexEquals(request.device, responder)
|
|
449
|
+
if (!request || hexEquals(request.device, responder))
|
|
450
|
+
return;
|
|
451
|
+
const existing = requests.get(request.request);
|
|
452
|
+
if (existing) {
|
|
453
|
+
existing.retry();
|
|
373
454
|
return;
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
455
|
+
}
|
|
456
|
+
const at = now();
|
|
457
|
+
for (const [id, state] of requests) {
|
|
458
|
+
if (state.idle() && at - state.createdAt > INVITATION_MAX_AGE_SECONDS)
|
|
459
|
+
requests.delete(id);
|
|
460
|
+
}
|
|
461
|
+
// Saturation refuses new work rather than forgetting a live decision.
|
|
462
|
+
if (requests.size >= 256)
|
|
463
|
+
return;
|
|
464
|
+
let approved = !opts.admit;
|
|
465
|
+
let deciding = !approved;
|
|
466
|
+
let busy = false;
|
|
467
|
+
let stoodDown = false;
|
|
468
|
+
let acknowledged = false;
|
|
469
|
+
let grant;
|
|
470
|
+
const state = { createdAt: event.created_at, idle: () => !deciding && !busy, retry: () => { if (approved && !busy)
|
|
471
|
+
grantLater(); } };
|
|
472
|
+
requests.set(request.request, state);
|
|
473
|
+
grantSeen.delete(normaliseHex(request.device));
|
|
474
|
+
const current = () => !closed && requests.get(request.request) === state;
|
|
475
|
+
const fresh = () => {
|
|
476
|
+
if (!current())
|
|
477
|
+
return false;
|
|
478
|
+
if (!decodeInvitationRequest(event, { invitation: opts.invitation, now: now() })) {
|
|
479
|
+
try {
|
|
480
|
+
opts.onGrantFailed?.(request, new Error('the invitation request expired before admission'));
|
|
481
|
+
}
|
|
482
|
+
catch { /* Observer only. */ }
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
// Reusing a signed grant must not extend the responder's authority.
|
|
486
|
+
const authority = verifyInvitationDelegation(opts.invitation, delegation, now());
|
|
487
|
+
if (authority === null || !hexEquals(authority, responder)) {
|
|
488
|
+
close();
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
return true;
|
|
492
|
+
};
|
|
380
493
|
const grantNow = () => {
|
|
381
|
-
if (
|
|
494
|
+
if (!fresh()) {
|
|
495
|
+
busy = false;
|
|
382
496
|
return;
|
|
383
|
-
|
|
497
|
+
}
|
|
384
498
|
try {
|
|
385
|
-
grant
|
|
499
|
+
grant ??= encodeInvitationGrant({
|
|
386
500
|
invitation: opts.invitation,
|
|
387
501
|
inviterSk: opts.inviterSk,
|
|
388
502
|
requester: request.device,
|
|
@@ -399,16 +513,17 @@ export function hostRoomInvitation(opts) {
|
|
|
399
513
|
close();
|
|
400
514
|
return;
|
|
401
515
|
}
|
|
402
|
-
// The
|
|
403
|
-
//
|
|
516
|
+
// The first ephemeral grant may precede the guest's subscription.
|
|
517
|
+
// A retry sends this exact envelope, never new delegation or approval.
|
|
404
518
|
Promise.resolve()
|
|
405
|
-
.then(() => {
|
|
406
|
-
|
|
407
|
-
.then(() => {
|
|
408
|
-
if (closed)
|
|
519
|
+
.then(async () => {
|
|
520
|
+
if (!fresh())
|
|
409
521
|
return;
|
|
410
|
-
|
|
411
|
-
|
|
522
|
+
await opts.transport.publish(grant);
|
|
523
|
+
if (!current() || acknowledged)
|
|
524
|
+
return;
|
|
525
|
+
acknowledged = true;
|
|
526
|
+
// Observer acknowledgement is once per request, not per retry.
|
|
412
527
|
try {
|
|
413
528
|
opts.onGrantPublished?.(request);
|
|
414
529
|
}
|
|
@@ -417,64 +532,78 @@ export function hostRoomInvitation(opts) {
|
|
|
417
532
|
opts.onAdmitted?.(request.device);
|
|
418
533
|
}
|
|
419
534
|
catch { /* Observer only. */ }
|
|
420
|
-
}, error => {
|
|
421
|
-
if (!closed)
|
|
422
|
-
opts.onGrantFailed?.(request, error);
|
|
423
535
|
})
|
|
424
|
-
.catch(
|
|
536
|
+
.catch(error => {
|
|
537
|
+
if (current()) {
|
|
538
|
+
try {
|
|
539
|
+
opts.onGrantFailed?.(request, error);
|
|
540
|
+
}
|
|
541
|
+
catch { /* Observer only. */ }
|
|
542
|
+
}
|
|
543
|
+
})
|
|
544
|
+
.finally(() => { busy = false; });
|
|
425
545
|
};
|
|
426
|
-
|
|
546
|
+
const grantAfterWait = () => {
|
|
547
|
+
if (!fresh()) {
|
|
548
|
+
busy = false;
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (opts.coordinate && grantSeen.has(normaliseHex(request.device)) && !stoodDown) {
|
|
552
|
+
// Stand down at most once. The retry retains the original approval.
|
|
553
|
+
stoodDown = true;
|
|
554
|
+
busy = false;
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
427
557
|
grantNow();
|
|
558
|
+
};
|
|
559
|
+
const grantLater = () => {
|
|
560
|
+
if (!current() || busy)
|
|
561
|
+
return;
|
|
562
|
+
busy = true;
|
|
563
|
+
const delay = delayRange ? replyDelay(delayRange, random) : 0;
|
|
564
|
+
if (delay === 0) {
|
|
565
|
+
grantAfterWait();
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (waits.size >= 256) {
|
|
569
|
+
busy = false;
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
let cancel = () => { };
|
|
573
|
+
let done = false;
|
|
574
|
+
cancel = schedule(() => { done = true; waits.delete(cancel); grantAfterWait(); }, delay);
|
|
575
|
+
if (!done)
|
|
576
|
+
waits.add(cancel);
|
|
577
|
+
};
|
|
578
|
+
if (approved) {
|
|
579
|
+
grantLater();
|
|
428
580
|
return;
|
|
429
581
|
}
|
|
430
|
-
//
|
|
431
|
-
//
|
|
582
|
+
// Pending and declined decisions stay cached. Duplicates cannot ask
|
|
583
|
+
// again, race approval, or publish before the original decision.
|
|
432
584
|
Promise.resolve()
|
|
433
|
-
.then(() => opts.admit(request))
|
|
434
|
-
.then((yes) => { if (yes)
|
|
435
|
-
|
|
436
|
-
|
|
585
|
+
.then(() => current() ? opts.admit(request) : false)
|
|
586
|
+
.then((yes) => { deciding = false; if (yes && current()) {
|
|
587
|
+
approved = true;
|
|
588
|
+
grantLater();
|
|
589
|
+
} })
|
|
590
|
+
.catch(() => { deciding = false; });
|
|
437
591
|
});
|
|
438
592
|
return { close };
|
|
439
593
|
}
|
|
440
|
-
/** Resolve the room and a bounded responder delegation
|
|
441
|
-
*
|
|
442
|
-
*
|
|
594
|
+
/** Resolve the room and a bounded responder delegation. Anonymous requests
|
|
595
|
+
* need no account. Supplying an identity signs a proof for this request; its
|
|
596
|
+
* signer may ask the person to approve. Retaining the delegation removes the
|
|
597
|
+
* creator as an availability dependency for the next arrival. */
|
|
443
598
|
export function requestRoomAdmissionCapability(opts) {
|
|
444
599
|
const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
|
|
445
|
-
const requesterSk = opts.requesterSk ?? generateSecretKey();
|
|
446
|
-
const requester = getPublicKey(requesterSk);
|
|
447
|
-
const request = encodeInvitationRequest({
|
|
448
|
-
invitation: opts.invitation, requesterSk, now: now(),
|
|
449
|
-
...(opts.name !== undefined ? { name: opts.name } : {}),
|
|
450
|
-
...(opts.participant !== undefined ? { participant: opts.participant } : {}),
|
|
451
|
-
});
|
|
452
600
|
return new Promise((resolve, reject) => {
|
|
453
601
|
let settled = false;
|
|
602
|
+
let ownedKey;
|
|
603
|
+
let request;
|
|
454
604
|
let retry;
|
|
455
605
|
let expiry;
|
|
456
|
-
const invitationId = deriveInvitationId(opts.invitation);
|
|
457
606
|
let unsub = () => { };
|
|
458
|
-
unsub = opts.transport.subscribe([
|
|
459
|
-
{ kinds: [KINDS.INVITATION_GRANT], '#d': [invitationId], '#p': [requester] },
|
|
460
|
-
{ kinds: [KINDS.INVITATION_RETIREMENT], '#d': [invitationId], authors: [opts.invitation.inviter] },
|
|
461
|
-
], (event) => {
|
|
462
|
-
const retired = decodeInvitationRetirementNotice(event, opts.invitation);
|
|
463
|
-
if (retired) {
|
|
464
|
-
finish(() => reject(retirementError(retired)));
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
const admission = decodeRoomAdmissionGrant(event, {
|
|
468
|
-
invitation: opts.invitation,
|
|
469
|
-
requesterSk,
|
|
470
|
-
request: request.id,
|
|
471
|
-
now: now(),
|
|
472
|
-
});
|
|
473
|
-
if (admission)
|
|
474
|
-
finish(() => resolve(admission));
|
|
475
|
-
});
|
|
476
|
-
if (settled)
|
|
477
|
-
unsub();
|
|
478
607
|
function finish(settle) {
|
|
479
608
|
if (settled)
|
|
480
609
|
return;
|
|
@@ -483,16 +612,108 @@ export function requestRoomAdmissionCapability(opts) {
|
|
|
483
612
|
clearInterval(retry);
|
|
484
613
|
if (expiry !== undefined)
|
|
485
614
|
clearTimeout(expiry);
|
|
486
|
-
|
|
615
|
+
opts.signal?.removeEventListener('abort', abort);
|
|
616
|
+
try {
|
|
617
|
+
unsub();
|
|
618
|
+
}
|
|
619
|
+
catch { /* A broken observer cannot keep the request live. */ }
|
|
620
|
+
ownedKey?.fill(0);
|
|
487
621
|
settle();
|
|
488
622
|
}
|
|
623
|
+
function abort() {
|
|
624
|
+
const error = new Error('invitation request cancelled');
|
|
625
|
+
error.name = 'AbortError';
|
|
626
|
+
finish(() => reject(error));
|
|
627
|
+
}
|
|
489
628
|
function ask() {
|
|
490
|
-
if (!settled)
|
|
491
|
-
|
|
629
|
+
if (!settled && request) {
|
|
630
|
+
// Keep the same signed request on retries; a late publish result
|
|
631
|
+
// never revives a cancelled or expired exchange.
|
|
632
|
+
try {
|
|
633
|
+
void opts.transport.publish(request).catch(() => { });
|
|
634
|
+
}
|
|
635
|
+
catch { /* Retried within the same deadline. */ }
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
if (opts.signal?.aborted) {
|
|
640
|
+
abort();
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const requestedTimeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, retryMs = opts.retryMs ?? DEFAULT_RETRY_MS;
|
|
644
|
+
if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0 || !Number.isFinite(retryMs) || retryMs <= 0)
|
|
645
|
+
throw new Error('invalid invitation request timing');
|
|
646
|
+
const participant = opts.participant === undefined
|
|
647
|
+
? (opts.identity ? requirePubkey(opts.identity.pubkey) : undefined)
|
|
648
|
+
: requirePubkey(opts.participant);
|
|
649
|
+
if (opts.identity && (!participant || !hexEquals(requirePubkey(opts.identity.pubkey), participant)))
|
|
650
|
+
throw new Error('invitation account does not match the requested participant');
|
|
651
|
+
// Clone caller-owned keys before asynchronous signing, and erase only
|
|
652
|
+
// our copy on every outcome. The caller retains ownership of its key.
|
|
653
|
+
if (opts.requesterSk)
|
|
654
|
+
require32(opts.requesterSk, 'requester secret key');
|
|
655
|
+
ownedKey = opts.requesterSk?.slice() ?? generateSecretKey();
|
|
656
|
+
const requester = getPublicKey(ownedKey), invitationId = deriveInvitationId(opts.invitation), requestedAt = now();
|
|
657
|
+
if (!Number.isSafeInteger(requestedAt) || requestedAt < 0)
|
|
658
|
+
throw new Error('invalid invitation request time');
|
|
659
|
+
// Signing and relay waiting share one deadline, no longer than the
|
|
660
|
+
// request's authenticated freshness window. Approval does not renew it.
|
|
661
|
+
expiry = setTimeout(() => finish(() => reject(new Error(opts.identity && !request
|
|
662
|
+
? 'the account did not finish signing the invitation request'
|
|
663
|
+
: 'the room is not answering this invitation'))), Math.min(requestedTimeout, INVITATION_MAX_AGE_SECONDS * 1000));
|
|
664
|
+
opts.signal?.addEventListener('abort', abort, { once: true });
|
|
665
|
+
if (opts.signal?.aborted) {
|
|
666
|
+
abort();
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
unsub = opts.transport.subscribe([
|
|
670
|
+
{ kinds: [KINDS.INVITATION_GRANT], '#d': [invitationId], '#p': [requester] },
|
|
671
|
+
{ kinds: [KINDS.INVITATION_RETIREMENT], '#d': [invitationId], authors: [opts.invitation.inviter] },
|
|
672
|
+
], event => {
|
|
673
|
+
if (settled)
|
|
674
|
+
return;
|
|
675
|
+
const retired = decodeInvitationRetirementNotice(event, opts.invitation);
|
|
676
|
+
if (retired) {
|
|
677
|
+
finish(() => reject(retirementError(retired)));
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (!request)
|
|
681
|
+
return;
|
|
682
|
+
const declined = decodeInvitationDecline(event, { invitation: opts.invitation, requesterSk: ownedKey, request: request.id, now: now() });
|
|
683
|
+
if (declined) {
|
|
684
|
+
finish(() => reject(new InvitationDeclinedError()));
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const admission = decodeRoomAdmissionGrant(event, { invitation: opts.invitation, requesterSk: ownedKey, request: request.id, now: now() });
|
|
688
|
+
if (admission) {
|
|
689
|
+
// The delegation owns its responder key after admission. Erasing
|
|
690
|
+
// our exchange key must not erase that independently retained key.
|
|
691
|
+
const retained = { ...admission, delegate: { ...admission.delegate, delegateSk: admission.delegate.delegateSk.slice() } };
|
|
692
|
+
finish(() => resolve(retained));
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
if (settled) {
|
|
696
|
+
unsub();
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const prepare = async () => {
|
|
700
|
+
const accountProof = opts.identity ? await encodeInvitationAccountProof({ invitation: opts.invitation, device: requester, identity: opts.identity, now: requestedAt }) : undefined;
|
|
701
|
+
if (settled)
|
|
702
|
+
return;
|
|
703
|
+
const at = now();
|
|
704
|
+
if (!Number.isSafeInteger(at) || Math.abs(at - requestedAt) > INVITATION_MAX_AGE_SECONDS)
|
|
705
|
+
throw new Error('the invitation request expired while signing');
|
|
706
|
+
request = encodeInvitationRequest({ invitation: opts.invitation, requesterSk: ownedKey, now: requestedAt,
|
|
707
|
+
...(opts.name !== undefined ? { name: opts.name } : {}), ...(participant !== undefined ? { participant } : {}),
|
|
708
|
+
...(accountProof ? { accountProof } : {}) });
|
|
709
|
+
retry = setInterval(ask, retryMs);
|
|
710
|
+
ask();
|
|
711
|
+
};
|
|
712
|
+
void prepare().catch(error => finish(() => reject(error)));
|
|
713
|
+
}
|
|
714
|
+
catch (error) {
|
|
715
|
+
finish(() => reject(error));
|
|
492
716
|
}
|
|
493
|
-
expiry = setTimeout(() => finish(() => reject(new Error('the room is not answering this invitation'))), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
494
|
-
retry = setInterval(ask, opts.retryMs ?? DEFAULT_RETRY_MS);
|
|
495
|
-
ask();
|
|
496
717
|
});
|
|
497
718
|
}
|
|
498
719
|
/** Compatibility wrapper for non-interactive consumers. It joins correctly,
|
|
@@ -505,6 +726,7 @@ export async function requestRoomAdmission(opts) {
|
|
|
505
726
|
* list rather than scanning file text for matching comments. Pure data -
|
|
506
727
|
* adding this export changes no runtime behaviour. */
|
|
507
728
|
export const INVITATION_LABELS = [
|
|
729
|
+
"kithmoot/v2/invitation-account-proof",
|
|
508
730
|
"kithmoot/v2/invitation-delegation:",
|
|
509
731
|
"kithmoot/v2/invitation-id",
|
|
510
732
|
"kithmoot/v2/invitation-request-key",
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { UnsignedEvent } from './identity.js';
|
|
2
|
+
export declare const MLS_ADMISSION_LABELS: readonly string[];
|
|
3
|
+
export declare const MLS_ADMISSION_PLAINTEXT_BYTES = 16384;
|
|
4
|
+
export declare const MLS_ADMISSION_CIPHERTEXT_CHARACTERS = 21936;
|
|
5
|
+
/** Evidence only. No admission/capability constructor is exported. */
|
|
6
|
+
export interface VerifiedMlsAdmissionRequest {
|
|
7
|
+
readonly __verifiedMlsAdmissionRequest: unique symbol;
|
|
8
|
+
}
|
|
9
|
+
export interface VerifiedAdmissionAnswer {
|
|
10
|
+
readonly __verifiedAdmissionAnswer: unique symbol;
|
|
11
|
+
}
|
|
12
|
+
/** Link fragment only; caller owns CSPRNG generation and secret lifetime. */
|
|
13
|
+
export declare function encodeMlsInvitationV4(root: string, bearer: Uint8Array): string;
|
|
14
|
+
export declare function readMlsInvitationV4(fragment: string): {
|
|
15
|
+
root: string;
|
|
16
|
+
bearer: Uint8Array;
|
|
17
|
+
invitationId: string;
|
|
18
|
+
};
|
|
19
|
+
/** Templates only. Account/device signers remain caller-owned and settle under
|
|
20
|
+
* the application's actual custody. Returned templates never confer authority.
|
|
21
|
+
* core is the exact canonical request array or, with request, Allow array. */
|
|
22
|
+
export declare function mlsAdmissionProofTemplatesV4(opts: {
|
|
23
|
+
fragment: string;
|
|
24
|
+
core: string;
|
|
25
|
+
now: number;
|
|
26
|
+
request?: VerifiedMlsAdmissionRequest;
|
|
27
|
+
}): {
|
|
28
|
+
pubkey: string;
|
|
29
|
+
event: UnsignedEvent;
|
|
30
|
+
}[];
|
|
31
|
+
/** proofs is canonical JSON of the three seven-field signed proof arrays. */
|
|
32
|
+
export declare function encodeMlsAdmissionRequestV4(opts: {
|
|
33
|
+
fragment: string;
|
|
34
|
+
core: string;
|
|
35
|
+
proofs: string;
|
|
36
|
+
requestSecretKey: Uint8Array;
|
|
37
|
+
now: number;
|
|
38
|
+
}): string;
|
|
39
|
+
/** Either endpoint may decode its exact request; only this decoder issues the
|
|
40
|
+
* nominal result, after fresh outer/inner checks. Caller secrets are not kept. */
|
|
41
|
+
export declare function decodeMlsAdmissionRequestV4(opts: {
|
|
42
|
+
fragment: string;
|
|
43
|
+
event: string;
|
|
44
|
+
localSecretKey: Uint8Array;
|
|
45
|
+
side: 'root' | 'joiner';
|
|
46
|
+
now: number;
|
|
47
|
+
}): VerifiedMlsAdmissionRequest;
|
|
48
|
+
/** Returned strings are copies of evidence, not a rehydration format. */
|
|
49
|
+
export declare function readMlsAdmissionRequestV4(handle: VerifiedMlsAdmissionRequest): Readonly<{
|
|
50
|
+
core: string;
|
|
51
|
+
digest: string;
|
|
52
|
+
eventId: string;
|
|
53
|
+
}>;
|
|
54
|
+
/** Root correlation and signed keeper consent only. This encoder cannot attest
|
|
55
|
+
* current keeper role; actual vault/box custody and fresh checks stay external. */
|
|
56
|
+
export declare function encodeMlsAdmissionAnswerV4(opts: {
|
|
57
|
+
fragment: string;
|
|
58
|
+
request: VerifiedMlsAdmissionRequest;
|
|
59
|
+
core: string;
|
|
60
|
+
proofs: string;
|
|
61
|
+
rootSecretKey: Uint8Array;
|
|
62
|
+
now: number;
|
|
63
|
+
}): string;
|
|
64
|
+
export declare function decodeMlsAdmissionAnswerV4(opts: {
|
|
65
|
+
fragment: string;
|
|
66
|
+
request: VerifiedMlsAdmissionRequest;
|
|
67
|
+
event: string;
|
|
68
|
+
requestSecretKey: Uint8Array;
|
|
69
|
+
now: number;
|
|
70
|
+
}): VerifiedAdmissionAnswer;
|
|
71
|
+
/** Cryptographic evidence only: no usable capability, child or admission. The
|
|
72
|
+
* application must obtain current role witness and consume the operation once. */
|
|
73
|
+
export declare function readMlsAdmissionAnswerV4(handle: VerifiedAdmissionAnswer): Readonly<{
|
|
74
|
+
decision: 'allow' | 'deny';
|
|
75
|
+
core: string;
|
|
76
|
+
digest: string;
|
|
77
|
+
eventId: string;
|
|
78
|
+
requestEventId: string;
|
|
79
|
+
}>;
|