@forgesworn/fold-kit 0.6.0 → 0.8.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/README.md +16 -0
- package/dist/credential.d.ts +5 -0
- package/dist/credential.js +4 -0
- package/dist/epoch.d.ts +103 -6
- package/dist/epoch.js +197 -18
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/member-epoch.d.ts +20 -2
- package/dist/member-epoch.js +27 -10
- package/dist/seal.d.ts +77 -0
- package/dist/seal.js +128 -0
- package/llms.txt +2 -0
- package/package.json +5 -1
- package/vectors/schedule-vectors.json +882 -0
- package/vectors/seal-vectors.json +629 -0
package/README.md
CHANGED
|
@@ -160,6 +160,22 @@ the rest).
|
|
|
160
160
|
authority answers `refused: 'unknown'`, which `requestRoomEpoch` waits
|
|
161
161
|
through). `encodeRekeyEvent({ members })` and `hostRoomEpoch({ members })`
|
|
162
162
|
carry the authority's member list (`readMemberList` reads one)
|
|
163
|
+
- Seal keys (`docs/seal-key.md`): a credential may name a seal key
|
|
164
|
+
(`createDeviceCredential({ seal })`, `generateSealKey`), and rekeys and
|
|
165
|
+
both grants are sealed to the newest one the sender holds for a device
|
|
166
|
+
instead of its device key, so a copied device heals once its credential
|
|
167
|
+
lapses and the room rekeys. Recipients as `{ device, credential }`;
|
|
168
|
+
`sealSks` on the readers; `credentialFor` on both desks;
|
|
169
|
+
`credentialSeal`, `sealTarget`, `sealTo`, `openSealed`, `newerCredential`,
|
|
170
|
+
`sealCredential`. Vectors: `vectors/seal-vectors.json`
|
|
171
|
+
- Scheduled rekeys and the history window (`docs/scheduled-rekey.md`):
|
|
172
|
+
`encodeRekeyEvent({ scheduled: true })` marks a turn of the key that
|
|
173
|
+
removes nobody, read back as `RekeyNotice.scheduled` and
|
|
174
|
+
`RekeyEvidence.scheduled`; `HISTORY_WINDOW_SECONDS`, `MAX_HISTORY_EPOCHS`
|
|
175
|
+
and `epochsInWindow` are the rule for which left epochs a member reads;
|
|
176
|
+
`encodeEpochGrant({ passed })` and `hostRoomEpoch({ past })` hand those
|
|
177
|
+
epochs (`LeftEpoch`) to every device the authority grants, read back as
|
|
178
|
+
`EpochGrant.passed`. Vectors: `vectors/schedule-vectors.json`
|
|
163
179
|
- `canonicalAdmins`, `signAdmins`, `verifyAdmins` - the authority's signed
|
|
164
180
|
admin list
|
|
165
181
|
- `canonicalChannels`, `signChannels`, `verifyChannels`, `CHANNEL_NAME`,
|
package/dist/credential.d.ts
CHANGED
|
@@ -25,6 +25,11 @@ export interface CreateCredentialOptions {
|
|
|
25
25
|
expiresAt: number;
|
|
26
26
|
/** Injectable clock, in unix seconds. Defaults to the real one. */
|
|
27
27
|
now?: () => number;
|
|
28
|
+
/** The device's seal key for this credential: an x-only public key,
|
|
29
|
+
* minted fresh for each credential, to which rekeys and epoch grants are
|
|
30
|
+
* then sealed instead of the device key. See `seal.ts`. Omitted, the
|
|
31
|
+
* credential is byte-identical to one minted before seal keys. */
|
|
32
|
+
seal?: string;
|
|
28
33
|
}
|
|
29
34
|
/** The longest a person-scoped credential may run. A phone that leaves the house is better at seven days. */
|
|
30
35
|
export declare const PERSON_CREDENTIAL_MAX_SECONDS: number;
|
package/dist/credential.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { KINDS } from './kinds.js';
|
|
2
2
|
import { verifyEventUncached } from './verify.js';
|
|
3
3
|
import { hexEquals, normaliseHex } from './hex.js';
|
|
4
|
+
import { isSealPubkey, SEAL_TAG } from './seal.js';
|
|
4
5
|
/** The longest a person-scoped credential may run. A phone that leaves the house is better at seven days. */
|
|
5
6
|
export const PERSON_CREDENTIAL_MAX_SECONDS = 30 * 24 * 60 * 60;
|
|
6
7
|
/**
|
|
@@ -43,6 +44,8 @@ export async function createDeviceCredential(opts) {
|
|
|
43
44
|
throw new Error('a room credential needs a room');
|
|
44
45
|
if (person && opts.expiresAt - now > PERSON_CREDENTIAL_MAX_SECONDS)
|
|
45
46
|
throw new Error('a person credential may not run more than 30 days');
|
|
47
|
+
if (opts.seal !== undefined && !isSealPubkey(opts.seal))
|
|
48
|
+
throw new Error('a seal key must be an x-only public key on the curve');
|
|
46
49
|
const unsigned = {
|
|
47
50
|
kind: KINDS.CREDENTIAL,
|
|
48
51
|
created_at: now,
|
|
@@ -52,6 +55,7 @@ export async function createDeviceCredential(opts) {
|
|
|
52
55
|
['expiration', String(opts.expiresAt)],
|
|
53
56
|
...(person ? [['scope', 'person']] : []),
|
|
54
57
|
...(opts.label !== undefined ? [['label', opts.label]] : []),
|
|
58
|
+
...(opts.seal !== undefined ? [[SEAL_TAG, opts.seal.toLowerCase()]] : []),
|
|
55
59
|
],
|
|
56
60
|
content: '',
|
|
57
61
|
};
|
package/dist/epoch.d.ts
CHANGED
|
@@ -98,8 +98,11 @@ export interface EncodeRekeyOptions {
|
|
|
98
98
|
/** The epoch being entered. Must be `current.epoch + 1`. */
|
|
99
99
|
next: RoomEpoch;
|
|
100
100
|
/** Devices to seal the new secret for. Everybody still in the room,
|
|
101
|
-
* except the removed participants' devices.
|
|
102
|
-
|
|
101
|
+
* except the removed participants' devices. A recipient given with its
|
|
102
|
+
* newest credential is sealed to that credential's seal key, when it
|
|
103
|
+
* names one; a bare device, to the device key (see `seal.ts`). The
|
|
104
|
+
* `keys` map is keyed by device either way. */
|
|
105
|
+
recipients: ReadonlyArray<string | RekeyRecipient>;
|
|
103
106
|
/** Participants removed at this step. Empty on a rekey that only turns
|
|
104
107
|
* the key over. */
|
|
105
108
|
removed: string[];
|
|
@@ -108,6 +111,11 @@ export interface EncodeRekeyOptions {
|
|
|
108
111
|
/** True when the room is being closed: nobody is kept, and the event
|
|
109
112
|
* says so rather than leaving everybody to wonder. */
|
|
110
113
|
closed?: boolean;
|
|
114
|
+
/** Mark this as a scheduled turn of the key rather than a removal, so
|
|
115
|
+
* clients can let it pass quietly. Refused with a removal or a close: a
|
|
116
|
+
* rekey that removes somebody is never quiet. Omitted, the event is
|
|
117
|
+
* byte-identical to before. */
|
|
118
|
+
scheduled?: boolean;
|
|
111
119
|
/** Write the epoch commitment into the body, so any current member can
|
|
112
120
|
* bring a device that missed this rekey up to date (see
|
|
113
121
|
* `member-epoch.ts`). Omitted, the event is byte-identical to before. */
|
|
@@ -122,6 +130,12 @@ export interface EncodeRekeyOptions {
|
|
|
122
130
|
* NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
|
|
123
131
|
expiresAt?: number;
|
|
124
132
|
}
|
|
133
|
+
/** A rekey recipient: a device, and the newest credential the sender holds
|
|
134
|
+
* for it. */
|
|
135
|
+
export interface RekeyRecipient {
|
|
136
|
+
device: string;
|
|
137
|
+
credential?: DeviceCredential;
|
|
138
|
+
}
|
|
125
139
|
/** Announce the next epoch. See the module comment for what this is. */
|
|
126
140
|
export declare function encodeRekeyEvent(opts: EncodeRekeyOptions): Event;
|
|
127
141
|
/** What a rekey says, once read. */
|
|
@@ -130,6 +144,10 @@ export interface RekeyNotice {
|
|
|
130
144
|
removed: string[];
|
|
131
145
|
by?: string;
|
|
132
146
|
closed: boolean;
|
|
147
|
+
/** True for a scheduled turn of the key: nobody removed, the room still
|
|
148
|
+
* open. What lets a client move on without saying so. Absent when the
|
|
149
|
+
* body contradicts itself, so a removal is always announced. */
|
|
150
|
+
scheduled?: true;
|
|
133
151
|
/** The new secret, when a copy was sealed for this device. Absent for a
|
|
134
152
|
* device that was removed, or that was not in the room when the
|
|
135
153
|
* authority rekeyed. */
|
|
@@ -160,6 +178,9 @@ export interface DecodeRekeyOptions extends PeekRekeyOptions {
|
|
|
160
178
|
current: EpochKeys;
|
|
161
179
|
/** This device's key, to open its own copy. */
|
|
162
180
|
deviceSk: Uint8Array;
|
|
181
|
+
/** The seal key secrets this device holds, newest first, tried before
|
|
182
|
+
* the device key (see `seal.ts`). */
|
|
183
|
+
sealSks?: readonly Uint8Array[];
|
|
163
184
|
}
|
|
164
185
|
/**
|
|
165
186
|
* Read a rekey. Null for anything that does not check out, and for a rekey
|
|
@@ -167,6 +188,33 @@ export interface DecodeRekeyOptions extends PeekRekeyOptions {
|
|
|
167
188
|
* already past (a replay), or one sealed to an epoch it is not at.
|
|
168
189
|
*/
|
|
169
190
|
export declare function decodeRekeyEvent(event: Event, opts: DecodeRekeyOptions): RekeyNotice | null;
|
|
191
|
+
/** How long a member goes on reading an epoch the room has left: 30 days,
|
|
192
|
+
* as long as a chat log keeps a message. */
|
|
193
|
+
export declare const HISTORY_WINDOW_SECONDS: number;
|
|
194
|
+
/** The most left epochs a member goes on reading, and an authority's grant
|
|
195
|
+
* hands over, however many fall inside the window. A weekly schedule puts
|
|
196
|
+
* about five in it; removals are what push it higher. Not
|
|
197
|
+
* `MAX_MEMBER_EPOCH_CHAIN`, which bounds how far behind a member desk will
|
|
198
|
+
* bring somebody. */
|
|
199
|
+
export declare const MAX_HISTORY_EPOCHS = 16;
|
|
200
|
+
/** An epoch the room has moved past, and when. */
|
|
201
|
+
export interface LeftEpoch extends RoomEpoch {
|
|
202
|
+
/** When the room left it, in unix seconds: the `created_at` of the rekey
|
|
203
|
+
* out of it. */
|
|
204
|
+
leftAt: number;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The left epochs still read at `now`: those left within
|
|
208
|
+
* `HISTORY_WINDOW_SECONDS` (one left exactly that long ago is kept), newest
|
|
209
|
+
* first, one per epoch number (the first given), at most
|
|
210
|
+
* `MAX_HISTORY_EPOCHS`. Every client applies the same rule, so what an
|
|
211
|
+
* authority hands over is what a member goes on reading. Pure, and never
|
|
212
|
+
* throws: an entry with no usable number or time is dropped.
|
|
213
|
+
*/
|
|
214
|
+
export declare function epochsInWindow<T extends {
|
|
215
|
+
epoch: number;
|
|
216
|
+
leftAt: number;
|
|
217
|
+
}>(left: readonly T[], now: number): T[];
|
|
170
218
|
/**
|
|
171
219
|
* The key an epoch request's admission proof is computed under: the epoch-0
|
|
172
220
|
* room key, expanded under its own info string. Taken from the room key
|
|
@@ -257,7 +305,19 @@ export interface EncodeEpochGrantOptions {
|
|
|
257
305
|
/** The participants the authority knows, so the requester's own member
|
|
258
306
|
* desk knows them too. Omitted, the body is as before. */
|
|
259
307
|
members?: string[];
|
|
308
|
+
/**
|
|
309
|
+
* The epochs the room has left that are still read (`epochsInWindow`),
|
|
310
|
+
* so a newcomer, or a device back after missing several rekeys, reads
|
|
311
|
+
* the last month and not only the current epoch. Each is after epoch 0,
|
|
312
|
+
* whose secret is the room secret and is never handed over, and before
|
|
313
|
+
* `epoch`; at most `MAX_HISTORY_EPOCHS`, each once. Omitted or empty,
|
|
314
|
+
* the body is as before.
|
|
315
|
+
*/
|
|
316
|
+
passed?: readonly LeftEpoch[];
|
|
260
317
|
refused?: EpochRefusal;
|
|
318
|
+
/** The newest credential the authority holds for the device: the grant
|
|
319
|
+
* is sealed to its seal key when it names one (see `seal.ts`). */
|
|
320
|
+
credential?: DeviceCredential;
|
|
261
321
|
/** A conference room's end, in unix seconds: the event carries it as a
|
|
262
322
|
* NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
|
|
263
323
|
expiresAt?: number;
|
|
@@ -269,6 +329,9 @@ export interface DecodeEpochGrantOptions {
|
|
|
269
329
|
roomId: string;
|
|
270
330
|
authority: string;
|
|
271
331
|
deviceSk: Uint8Array;
|
|
332
|
+
/** The seal key secrets this device holds: see
|
|
333
|
+
* `DecodeRekeyOptions.sealSks`. */
|
|
334
|
+
sealSks?: readonly Uint8Array[];
|
|
272
335
|
request: string;
|
|
273
336
|
now: number;
|
|
274
337
|
maxAgeSeconds?: number;
|
|
@@ -278,10 +341,15 @@ export type EpochGrant = {
|
|
|
278
341
|
epoch: 0;
|
|
279
342
|
};
|
|
280
343
|
removed: string[];
|
|
281
|
-
/**
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
|
|
344
|
+
/** Earlier epochs handed over with `epoch`, oldest first. A member's
|
|
345
|
+
* answer through `requestRoomEpoch({ members })` carries the epochs
|
|
346
|
+
* between the requester's and `epoch` (see `MemberEpochGrant.passed`);
|
|
347
|
+
* the authority's carries the window's (`EncodeEpochGrantOptions.passed`),
|
|
348
|
+
* when its desk was given them. `leftAt` is when the room left each,
|
|
349
|
+
* when the answer said. */
|
|
350
|
+
passed?: Array<RoomEpoch & {
|
|
351
|
+
leftAt?: number;
|
|
352
|
+
}>;
|
|
285
353
|
/** The participants the room knows, when the answer carried them. */
|
|
286
354
|
members?: string[];
|
|
287
355
|
refused?: undefined;
|
|
@@ -314,6 +382,23 @@ export interface HostRoomEpochOptions {
|
|
|
314
382
|
/** The participants to tell a granted device about, so its own member
|
|
315
383
|
* desk knows them too. Asked on every grant. */
|
|
316
384
|
members?: () => readonly string[];
|
|
385
|
+
/**
|
|
386
|
+
* The epochs the room has left, with their secrets and when it left
|
|
387
|
+
* each: every grant carries those still in the window, so a newcomer
|
|
388
|
+
* reads the last month as a returning member does. Asked on every grant.
|
|
389
|
+
* Whatever a grant could not carry (epoch 0, an epoch not before the
|
|
390
|
+
* current one, a bad secret) is left out rather than costing the grant.
|
|
391
|
+
*/
|
|
392
|
+
past?: () => readonly LeftEpoch[];
|
|
393
|
+
/**
|
|
394
|
+
* The newest credential the authority has seen for a device, from the
|
|
395
|
+
* roster. An answer is sealed to the newer of this and the one the
|
|
396
|
+
* request carries, so a thief replaying a device's older, still-live
|
|
397
|
+
* credential is answered under the seal key the device minted since.
|
|
398
|
+
* Keep it monotone: a roster entry carrying an older credential than one
|
|
399
|
+
* already seen must not replace it, or the thief can publish one.
|
|
400
|
+
*/
|
|
401
|
+
credentialFor?: (device: string) => DeviceCredential | undefined;
|
|
317
402
|
/** Somebody the room does not know asked, after a removal: what an app
|
|
318
403
|
* turns into "let them in?". Called once per participant, and again
|
|
319
404
|
* every `reportUnknownEvery` seconds while they keep asking. Letting them in
|
|
@@ -330,6 +415,15 @@ export interface HostRoomEpochOptions {
|
|
|
330
415
|
/** A conference room's end: every grant carries it as an expiration. */
|
|
331
416
|
expiresAt?: number;
|
|
332
417
|
}
|
|
418
|
+
/**
|
|
419
|
+
* The credential to seal an answer to: the one the request carries, unless
|
|
420
|
+
* the room has seen a newer one for the same device and participant that
|
|
421
|
+
* still verifies. Shared by both desks.
|
|
422
|
+
*/
|
|
423
|
+
export declare function sealCredential(request: {
|
|
424
|
+
device: string;
|
|
425
|
+
participant: string;
|
|
426
|
+
}, presented: DeviceCredential, credentialFor: ((device: string) => DeviceCredential | undefined) | undefined, roomId: string, now: number): DeviceCredential;
|
|
333
427
|
/**
|
|
334
428
|
* Answer epoch requests for as long as the handle is open. What a keeper
|
|
335
429
|
* runs beside its invitation desk: a member arriving, or returning, after
|
|
@@ -370,6 +464,9 @@ export interface RequestRoomEpochOptions {
|
|
|
370
464
|
/** The authority said the room does not know this participant yet. Not
|
|
371
465
|
* final: the ask goes on, and a member letting them in settles it. */
|
|
372
466
|
onUnknown?: () => void;
|
|
467
|
+
/** The seal key secrets this device holds. Asked on every answer,
|
|
468
|
+
* because a credential may renew while the ask is out. */
|
|
469
|
+
sealSks?: () => readonly Uint8Array[];
|
|
373
470
|
}
|
|
374
471
|
/** Thrown when the authority answered, and the answer was no. */
|
|
375
472
|
export declare class EpochRefusedError extends Error {
|
package/dist/epoch.js
CHANGED
|
@@ -12,6 +12,7 @@ import { deriveRoom } from './room.js';
|
|
|
12
12
|
import { verifyDeviceCredential } from './credential.js';
|
|
13
13
|
import { evaluateAccess } from './access.js';
|
|
14
14
|
import { verifyEventUncached } from './verify.js';
|
|
15
|
+
import { newerCredential, openSealed, sealTo } from './seal.js';
|
|
15
16
|
import { withExpiration } from './expiration.js';
|
|
16
17
|
import { epochCommitment } from './epoch-commit.js';
|
|
17
18
|
/**
|
|
@@ -142,12 +143,15 @@ export function encodeRekeyEvent(opts) {
|
|
|
142
143
|
if (epoch !== opts.current.epoch + 1)
|
|
143
144
|
throw new Error('a rekey moves the room forward by exactly one epoch');
|
|
144
145
|
const removed = [...new Set(opts.removed.map((p) => requireHex32(p, 'removed participant')))].sort();
|
|
146
|
+
if (opts.scheduled && (removed.length > 0 || opts.closed))
|
|
147
|
+
throw new Error('a scheduled rekey removes nobody and does not close the room');
|
|
145
148
|
const keys = {};
|
|
146
149
|
const sealed = { v: 1, secret: base64urlnopad.encode(opts.next.secret) };
|
|
147
150
|
const plaintext = JSON.stringify(sealed);
|
|
148
151
|
for (const raw of opts.closed ? [] : opts.recipients) {
|
|
149
|
-
const
|
|
150
|
-
|
|
152
|
+
const recipient = typeof raw === 'string' ? { device: raw } : raw;
|
|
153
|
+
const device = requireHex32(recipient.device, 'recipient device');
|
|
154
|
+
keys[device] = sealTo(plaintext, opts.authoritySk, device, recipient.credential);
|
|
151
155
|
}
|
|
152
156
|
const body = {
|
|
153
157
|
v: 1,
|
|
@@ -155,6 +159,7 @@ export function encodeRekeyEvent(opts) {
|
|
|
155
159
|
removed,
|
|
156
160
|
...(opts.by !== undefined ? { by: requireHex32(opts.by, 'admin') } : {}),
|
|
157
161
|
...(opts.closed ? { closed: true } : {}),
|
|
162
|
+
...(opts.scheduled ? { scheduled: true } : {}),
|
|
158
163
|
...(opts.commit ? { commit: epochCommitment(roomId, epoch, opts.next.secret) } : {}),
|
|
159
164
|
...(opts.members !== undefined ? { members: memberListOf(opts.members, removed) } : {}),
|
|
160
165
|
keys,
|
|
@@ -221,17 +226,28 @@ export function decodeRekeyEvent(event, opts) {
|
|
|
221
226
|
};
|
|
222
227
|
if (typeof body.by === 'string' && HEX64.test(body.by))
|
|
223
228
|
notice.by = normaliseHex(body.by);
|
|
229
|
+
if (body.scheduled === true && notice.removed.length === 0 && !notice.closed)
|
|
230
|
+
notice.scheduled = true;
|
|
224
231
|
const members = readMemberList(body.members);
|
|
225
232
|
if (members)
|
|
226
233
|
notice.members = members;
|
|
227
234
|
const device = getPublicKey(opts.deviceSk);
|
|
228
235
|
const mine = Object.entries(body.keys).find(([to]) => hexEquals(to, device))?.[1];
|
|
229
236
|
if (typeof mine === 'string') {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
237
|
+
// A copy none of this device's keys opens is no copy: sealed to a
|
|
238
|
+
// seal key it has since erased, or one it never had. The rest of the
|
|
239
|
+
// rekey still says who was removed, so it is read without the secret,
|
|
240
|
+
// and the device asks for the epoch.
|
|
241
|
+
try {
|
|
242
|
+
const sealed = JSON.parse(openSealed(mine, event.pubkey, opts.deviceSk, opts.sealSks));
|
|
243
|
+
if (sealed.v === 1 && typeof sealed.secret === 'string') {
|
|
244
|
+
const secret = base64urlnopad.decode(sealed.secret);
|
|
245
|
+
if (secret.length === 32)
|
|
246
|
+
notice.secret = secret;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// No secret, as above.
|
|
235
251
|
}
|
|
236
252
|
}
|
|
237
253
|
return notice;
|
|
@@ -241,6 +257,41 @@ export function decodeRekeyEvent(event, opts) {
|
|
|
241
257
|
}
|
|
242
258
|
}
|
|
243
259
|
// ---------------------------------------------------------------------------
|
|
260
|
+
// The history window
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
/** How long a member goes on reading an epoch the room has left: 30 days,
|
|
263
|
+
* as long as a chat log keeps a message. */
|
|
264
|
+
export const HISTORY_WINDOW_SECONDS = 30 * 86_400;
|
|
265
|
+
/** The most left epochs a member goes on reading, and an authority's grant
|
|
266
|
+
* hands over, however many fall inside the window. A weekly schedule puts
|
|
267
|
+
* about five in it; removals are what push it higher. Not
|
|
268
|
+
* `MAX_MEMBER_EPOCH_CHAIN`, which bounds how far behind a member desk will
|
|
269
|
+
* bring somebody. */
|
|
270
|
+
export const MAX_HISTORY_EPOCHS = 16;
|
|
271
|
+
/**
|
|
272
|
+
* The left epochs still read at `now`: those left within
|
|
273
|
+
* `HISTORY_WINDOW_SECONDS` (one left exactly that long ago is kept), newest
|
|
274
|
+
* first, one per epoch number (the first given), at most
|
|
275
|
+
* `MAX_HISTORY_EPOCHS`. Every client applies the same rule, so what an
|
|
276
|
+
* authority hands over is what a member goes on reading. Pure, and never
|
|
277
|
+
* throws: an entry with no usable number or time is dropped.
|
|
278
|
+
*/
|
|
279
|
+
export function epochsInWindow(left, now) {
|
|
280
|
+
const since = now - HISTORY_WINDOW_SECONDS;
|
|
281
|
+
const usable = left.filter((e) => typeof e === 'object' && e !== null && Number.isSafeInteger(e.epoch) && e.epoch >= 0 && Number.isFinite(e.leftAt) && e.leftAt >= since);
|
|
282
|
+
const seen = new Set();
|
|
283
|
+
const out = [];
|
|
284
|
+
for (const e of usable.sort((a, b) => b.epoch - a.epoch)) {
|
|
285
|
+
if (seen.has(e.epoch))
|
|
286
|
+
continue;
|
|
287
|
+
seen.add(e.epoch);
|
|
288
|
+
out.push(e);
|
|
289
|
+
if (out.length === MAX_HISTORY_EPOCHS)
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
244
295
|
// Asking the authority for the current epoch
|
|
245
296
|
// ---------------------------------------------------------------------------
|
|
246
297
|
/**
|
|
@@ -314,6 +365,12 @@ export function encodeEpochRequest(opts) {
|
|
|
314
365
|
* cannot prove which participant it speaks for in this room, or from one
|
|
315
366
|
* that cannot prove it was admitted to the room at all. */
|
|
316
367
|
export function decodeEpochRequest(event, opts) {
|
|
368
|
+
const read = readEpochRequest(event, opts);
|
|
369
|
+
return read && read.request;
|
|
370
|
+
}
|
|
371
|
+
/** `decodeEpochRequest`, keeping the credential the request was made
|
|
372
|
+
* under: what the desk seals its answer by. */
|
|
373
|
+
function readEpochRequest(event, opts) {
|
|
317
374
|
try {
|
|
318
375
|
if (event.kind !== KINDS.EPOCH_REQUEST)
|
|
319
376
|
return null;
|
|
@@ -358,7 +415,7 @@ export function decodeEpochRequest(event, opts) {
|
|
|
358
415
|
if (!evaluateAccess(opts.policy, verdict.participant, proof, opts.now, opts.roomId).admitted)
|
|
359
416
|
return null;
|
|
360
417
|
}
|
|
361
|
-
return { device: verdict.device, participant: verdict.participant, request: event.id };
|
|
418
|
+
return { request: { device: verdict.device, participant: verdict.participant, request: event.id }, credential: body.credential };
|
|
362
419
|
}
|
|
363
420
|
catch {
|
|
364
421
|
return null;
|
|
@@ -368,6 +425,56 @@ export function decodeEpochRequest(event, opts) {
|
|
|
368
425
|
* asking: often enough that a missed "let them in?" comes back, rarely
|
|
369
426
|
* enough that it is not a nag. */
|
|
370
427
|
export const REPORT_UNKNOWN_EVERY_SECONDS = 60;
|
|
428
|
+
function passedListOf(passed, granted) {
|
|
429
|
+
if (passed.length > MAX_HISTORY_EPOCHS)
|
|
430
|
+
throw new Error(`a grant carries at most ${MAX_HISTORY_EPOCHS} passed epochs`);
|
|
431
|
+
const out = passed
|
|
432
|
+
.map((e) => {
|
|
433
|
+
const epoch = requireEpochNumber(e.epoch);
|
|
434
|
+
if (epoch < 1 || epoch >= granted)
|
|
435
|
+
throw new Error('a passed epoch comes after epoch 0 and before the one granted');
|
|
436
|
+
require32(e.secret, 'epoch secret');
|
|
437
|
+
if (!Number.isSafeInteger(e.leftAt) || e.leftAt < 0)
|
|
438
|
+
throw new Error('leftAt must be a non-negative integer');
|
|
439
|
+
return { epoch, secret: base64urlnopad.encode(e.secret), left: e.leftAt };
|
|
440
|
+
})
|
|
441
|
+
.sort((a, b) => a.epoch - b.epoch);
|
|
442
|
+
for (let i = 1; i < out.length; i += 1) {
|
|
443
|
+
if (out[i].epoch === out[i - 1].epoch)
|
|
444
|
+
throw new Error('a passed epoch is listed once');
|
|
445
|
+
}
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
448
|
+
/** A grant's passed epochs, or undefined unless every entry is in the form
|
|
449
|
+
* `encodeEpochGrant` writes. One bad entry costs the history, not the
|
|
450
|
+
* grant: the current epoch still stands. */
|
|
451
|
+
function readPassedList(raw, granted) {
|
|
452
|
+
if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_HISTORY_EPOCHS)
|
|
453
|
+
return undefined;
|
|
454
|
+
const out = [];
|
|
455
|
+
for (const entry of raw) {
|
|
456
|
+
if (typeof entry !== 'object' || entry === null)
|
|
457
|
+
return undefined;
|
|
458
|
+
const { epoch, secret, left } = entry;
|
|
459
|
+
if (!Number.isSafeInteger(epoch) || epoch < 1 || epoch >= granted)
|
|
460
|
+
return undefined;
|
|
461
|
+
if (out.length > 0 && epoch <= out[out.length - 1].epoch)
|
|
462
|
+
return undefined;
|
|
463
|
+
if (typeof secret !== 'string' || !Number.isSafeInteger(left) || left < 0)
|
|
464
|
+
return undefined;
|
|
465
|
+
let bytes;
|
|
466
|
+
try {
|
|
467
|
+
bytes = base64urlnopad.decode(secret);
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
if (bytes.length !== 32)
|
|
473
|
+
return undefined;
|
|
474
|
+
out.push({ epoch: epoch, secret: bytes, leftAt: left });
|
|
475
|
+
}
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
371
478
|
/** Answer one request: the current epoch sealed to the asking device, or a
|
|
372
479
|
* refusal it alone can read. */
|
|
373
480
|
export function encodeEpochGrant(opts) {
|
|
@@ -390,6 +497,8 @@ export function encodeEpochGrant(opts) {
|
|
|
390
497
|
body.removed = [...new Set((opts.removed ?? []).map((p) => requireHex32(p, 'removed participant')))].sort();
|
|
391
498
|
if (opts.members !== undefined)
|
|
392
499
|
body.members = memberListOf(opts.members, body.removed);
|
|
500
|
+
if (opts.passed !== undefined && opts.passed.length > 0)
|
|
501
|
+
body.passed = passedListOf(opts.passed, body.epoch);
|
|
393
502
|
}
|
|
394
503
|
return finalizeEvent({
|
|
395
504
|
kind: KINDS.EPOCH_GRANT,
|
|
@@ -398,7 +507,7 @@ export function encodeEpochGrant(opts) {
|
|
|
398
507
|
['d', roomId],
|
|
399
508
|
['p', device],
|
|
400
509
|
], opts.expiresAt),
|
|
401
|
-
content:
|
|
510
|
+
content: sealTo(JSON.stringify(body), opts.authoritySk, device, opts.credential),
|
|
402
511
|
}, opts.authoritySk);
|
|
403
512
|
}
|
|
404
513
|
/** Read the authority's answer to this device's own request. Null for
|
|
@@ -420,7 +529,7 @@ export function decodeEpochGrant(event, opts) {
|
|
|
420
529
|
const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
|
|
421
530
|
if (addressed === undefined || !hexEquals(addressed, device))
|
|
422
531
|
return null;
|
|
423
|
-
const body = JSON.parse(
|
|
532
|
+
const body = JSON.parse(openSealed(event.content, event.pubkey, opts.deviceSk, opts.sealSks));
|
|
424
533
|
if (body.v !== 1 || typeof body.request !== 'string' || !hexEquals(body.request, opts.request))
|
|
425
534
|
return null;
|
|
426
535
|
if (body.refused === 'removed' || body.refused === 'closed' || body.refused === 'unknown')
|
|
@@ -439,12 +548,59 @@ export function decodeEpochGrant(event, opts) {
|
|
|
439
548
|
const secret = base64urlnopad.decode(body.secret);
|
|
440
549
|
if (secret.length !== 32)
|
|
441
550
|
return null;
|
|
442
|
-
|
|
551
|
+
const passed = readPassedList(body.passed, body.epoch);
|
|
552
|
+
return { epoch: { epoch: body.epoch, secret }, removed, ...listed, ...(passed ? { passed } : {}) };
|
|
443
553
|
}
|
|
444
554
|
catch {
|
|
445
555
|
return null;
|
|
446
556
|
}
|
|
447
557
|
}
|
|
558
|
+
/** What a desk hands over of `past()`: the window's epochs below the one it
|
|
559
|
+
* grants, leaving out anything `encodeEpochGrant` would refuse, so a bad
|
|
560
|
+
* entry costs the history and never the grant. */
|
|
561
|
+
function passedFor(past, granted, now) {
|
|
562
|
+
if (!past)
|
|
563
|
+
return [];
|
|
564
|
+
let given;
|
|
565
|
+
try {
|
|
566
|
+
given = past();
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
return [];
|
|
570
|
+
}
|
|
571
|
+
if (!Array.isArray(given))
|
|
572
|
+
return [];
|
|
573
|
+
const carried = given.filter((e) => typeof e === 'object' &&
|
|
574
|
+
e !== null &&
|
|
575
|
+
Number.isSafeInteger(e.epoch) &&
|
|
576
|
+
e.epoch >= 1 &&
|
|
577
|
+
e.epoch < granted &&
|
|
578
|
+
e.secret instanceof Uint8Array &&
|
|
579
|
+
e.secret.length === 32 &&
|
|
580
|
+
Number.isSafeInteger(e.leftAt) &&
|
|
581
|
+
e.leftAt >= 0);
|
|
582
|
+
return epochsInWindow(carried, now);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* The credential to seal an answer to: the one the request carries, unless
|
|
586
|
+
* the room has seen a newer one for the same device and participant that
|
|
587
|
+
* still verifies. Shared by both desks.
|
|
588
|
+
*/
|
|
589
|
+
export function sealCredential(request, presented, credentialFor, roomId, now) {
|
|
590
|
+
let known;
|
|
591
|
+
try {
|
|
592
|
+
known = credentialFor?.(request.device);
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
known = undefined;
|
|
596
|
+
}
|
|
597
|
+
if (!known)
|
|
598
|
+
return presented;
|
|
599
|
+
const verdict = verifyDeviceCredential(known, { roomId, now });
|
|
600
|
+
if (!verdict.ok || verdict.device !== normaliseHex(request.device) || verdict.participant !== normaliseHex(request.participant))
|
|
601
|
+
return presented;
|
|
602
|
+
return newerCredential(presented, known);
|
|
603
|
+
}
|
|
448
604
|
/**
|
|
449
605
|
* Answer epoch requests for as long as the handle is open. What a keeper
|
|
450
606
|
* runs beside its invitation desk: a member arriving, or returning, after
|
|
@@ -483,7 +639,7 @@ export function hostRoomEpoch(opts) {
|
|
|
483
639
|
const unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_REQUEST], '#d': [roomId], '#p': [authority] }], (event) => {
|
|
484
640
|
if (closed)
|
|
485
641
|
return;
|
|
486
|
-
const
|
|
642
|
+
const read = readEpochRequest(event, {
|
|
487
643
|
roomId,
|
|
488
644
|
authoritySk: opts.authoritySk,
|
|
489
645
|
roomKey: opts.roomKey,
|
|
@@ -491,8 +647,9 @@ export function hostRoomEpoch(opts) {
|
|
|
491
647
|
policy: opts.policy,
|
|
492
648
|
legacyParticipants: opts.legacyParticipants,
|
|
493
649
|
});
|
|
494
|
-
if (!
|
|
650
|
+
if (!read || answered.has(read.request.request))
|
|
495
651
|
return;
|
|
652
|
+
const request = read.request;
|
|
496
653
|
let refused;
|
|
497
654
|
if (opts.closed?.())
|
|
498
655
|
refused = 'closed';
|
|
@@ -513,19 +670,34 @@ export function hostRoomEpoch(opts) {
|
|
|
513
670
|
}
|
|
514
671
|
let grant;
|
|
515
672
|
try {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
: encodeEpochGrant({
|
|
673
|
+
if (refused) {
|
|
674
|
+
grant = encodeEpochGrant({
|
|
519
675
|
roomId,
|
|
520
676
|
authoritySk: opts.authoritySk,
|
|
521
677
|
device: request.device,
|
|
522
678
|
request: request.request,
|
|
523
679
|
now: now(),
|
|
524
|
-
|
|
680
|
+
refused,
|
|
681
|
+
credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
|
|
682
|
+
expiresAt: opts.expiresAt,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
const epoch = opts.current();
|
|
687
|
+
grant = encodeEpochGrant({
|
|
688
|
+
roomId,
|
|
689
|
+
authoritySk: opts.authoritySk,
|
|
690
|
+
device: request.device,
|
|
691
|
+
request: request.request,
|
|
692
|
+
now: now(),
|
|
693
|
+
epoch,
|
|
525
694
|
removed: [...opts.removed()],
|
|
526
695
|
...(opts.members ? { members: [...opts.members()] } : {}),
|
|
696
|
+
...(opts.past ? { passed: passedFor(opts.past, epoch.epoch, now()) } : {}),
|
|
697
|
+
credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
|
|
527
698
|
expiresAt: opts.expiresAt,
|
|
528
699
|
});
|
|
700
|
+
}
|
|
529
701
|
}
|
|
530
702
|
catch {
|
|
531
703
|
return;
|
|
@@ -604,7 +776,14 @@ export function requestRoomEpoch(opts) {
|
|
|
604
776
|
settle();
|
|
605
777
|
};
|
|
606
778
|
unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_GRANT], '#d': [roomId], '#p': [device] }], (event) => {
|
|
607
|
-
const grant = decodeEpochGrant(event, {
|
|
779
|
+
const grant = decodeEpochGrant(event, {
|
|
780
|
+
roomId,
|
|
781
|
+
authority: opts.authority,
|
|
782
|
+
deviceSk: opts.deviceSk,
|
|
783
|
+
sealSks: opts.sealSks?.(),
|
|
784
|
+
request: request.id,
|
|
785
|
+
now: now(),
|
|
786
|
+
});
|
|
608
787
|
if (!grant)
|
|
609
788
|
return;
|
|
610
789
|
if (grant.refused === 'unknown') {
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export type { DeviceCredential, AccessTier, AgentRule, RoomPolicy, KindredProof
|
|
|
7
7
|
export type { RelayTransport } from './transport.js';
|
|
8
8
|
export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, RestampedCredentialExpiryError, } from './credential.js';
|
|
9
9
|
export type { CreateCredentialOptions, VerifyResult } from './credential.js';
|
|
10
|
+
export { SEAL_TAG, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
|
|
11
|
+
export type { SealKey } from './seal.js';
|
|
10
12
|
export { deriveScoped, SCOPED_LABEL_PATTERN } from './scoped.js';
|
|
11
13
|
export type { ScopedKeys } from './scoped.js';
|
|
12
14
|
export { createSubKeyCertificate, verifySubKeyCertificate, SUB_KEY_CERTIFICATE_SCOPE, } from './sub-cert.js';
|
|
@@ -24,8 +26,8 @@ export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } fr
|
|
|
24
26
|
export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
|
|
25
27
|
export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
|
|
26
28
|
export type { RoomLink } from './link.js';
|
|
27
|
-
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
|
|
28
|
-
export type { RoomEpoch, EpochKeys, EncodeRekeyOptions, RekeyNotice, PeekRekeyOptions, DecodeRekeyOptions, EpochRequestAdmissionOptions, EncodeEpochRequestOptions, DecodeEpochRequestOptions, EpochRequest, EpochRefusal, EncodeEpochGrantOptions, DecodeEpochGrantOptions, EpochGrant, HostRoomEpochOptions, RequestRoomEpochOptions, SignAdminsOptions, SignChannelsOptions, VerifyChannelsOptions, VerifyAdminsOptions, } from './epoch.js';
|
|
29
|
+
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, HISTORY_WINDOW_SECONDS, MAX_HISTORY_EPOCHS, epochsInWindow, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, sealCredential, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
|
|
30
|
+
export type { RoomEpoch, EpochKeys, EncodeRekeyOptions, RekeyRecipient, RekeyNotice, PeekRekeyOptions, DecodeRekeyOptions, LeftEpoch, EpochRequestAdmissionOptions, EncodeEpochRequestOptions, DecodeEpochRequestOptions, EpochRequest, EpochRefusal, EncodeEpochGrantOptions, DecodeEpochGrantOptions, EpochGrant, HostRoomEpochOptions, RequestRoomEpochOptions, SignAdminsOptions, SignChannelsOptions, VerifyChannelsOptions, VerifyAdminsOptions, } from './epoch.js';
|
|
29
31
|
export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
|
|
30
32
|
export { MEMBER_EPOCH_KINDS, MEMBER_EPOCH_REQUEST_KEY_INFO, MAX_MEMBER_EPOCH_CHAIN, deriveMemberEpochRequestKey, readRekeyEvidence, encodeMemberEpochRequest, decodeMemberEpochRequest, encodeMemberEpochGrant, decodeMemberEpochGrant, hostMemberEpochDesk, memberEpochSource, requestMemberEpoch, MEMBER_EPOCH_LABELS, } from './member-epoch.js';
|
|
31
33
|
export type { RekeyEvidence, EncodeMemberEpochRequestOptions, DecodeMemberEpochRequestOptions, MemberEpochRequest, EncodeMemberEpochGrantOptions, DecodeMemberEpochGrantOptions, MemberEpochGrant, HostMemberEpochDeskOptions, MemberEpochRequestOptions, MemberEpochSource, } from './member-epoch.js';
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { verifyEventUncached, boundedEventVerifier } from './verify.js';
|
|
|
5
5
|
export { localIdentity } from './identity.js';
|
|
6
6
|
export { KINDS } from './kinds.js';
|
|
7
7
|
export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, RestampedCredentialExpiryError, } from './credential.js';
|
|
8
|
+
export { SEAL_TAG, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
|
|
8
9
|
export { deriveScoped, SCOPED_LABEL_PATTERN } from './scoped.js';
|
|
9
10
|
export { createSubKeyCertificate, verifySubKeyCertificate, SUB_KEY_CERTIFICATE_SCOPE, } from './sub-cert.js';
|
|
10
11
|
export { generateRoomSecret, deriveRoom, encodeJoinUrl, decodeJoinUrl, parseRoomPolicy, ROOM_LABELS, } from './room.js';
|
|
@@ -16,7 +17,7 @@ export { encodePersistentInvitation, decodePersistentInvitation, requestPersiste
|
|
|
16
17
|
export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
|
|
17
18
|
export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
|
|
18
19
|
export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
|
|
19
|
-
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
|
|
20
|
+
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, HISTORY_WINDOW_SECONDS, MAX_HISTORY_EPOCHS, epochsInWindow, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, sealCredential, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
|
|
20
21
|
export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
|
|
21
22
|
export { MEMBER_EPOCH_KINDS, MEMBER_EPOCH_REQUEST_KEY_INFO, MAX_MEMBER_EPOCH_CHAIN, deriveMemberEpochRequestKey, readRekeyEvidence, encodeMemberEpochRequest, decodeMemberEpochRequest, encodeMemberEpochGrant, decodeMemberEpochGrant, hostMemberEpochDesk, memberEpochSource, requestMemberEpoch, MEMBER_EPOCH_LABELS, } from './member-epoch.js';
|
|
22
23
|
export { deriveChannel, CHANNEL_ID_INFO, CHANNEL_KEY_INFO, MAX_CHANNEL_NAME_LENGTH, CHANNEL_LABELS } from './channel.js';
|