@forgesworn/fold-kit 0.5.0 → 0.6.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 +6 -0
- package/dist/epoch.d.ts +59 -3
- package/dist/epoch.js +83 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/member-epoch.d.ts +28 -2
- package/dist/member-epoch.js +44 -7
- package/llms.txt +2 -1
- package/package.json +1 -1
- package/vectors/member-epoch-vectors.json +64 -0
package/README.md
CHANGED
|
@@ -154,6 +154,12 @@ the rest).
|
|
|
154
154
|
`encodeMemberEpochRequest`, `decodeMemberEpochRequest`,
|
|
155
155
|
`encodeMemberEpochGrant`, `decodeMemberEpochGrant`, `readRekeyEvidence`,
|
|
156
156
|
`deriveMemberEpochRequestKey`. Vectors: `vectors/member-epoch-vectors.json`
|
|
157
|
+
- The known-members gate (#207, `docs/member-epoch-catch-up.md`): once a
|
|
158
|
+
room has removed anybody, both desks grant only to participants the room
|
|
159
|
+
knows (`known`), and send anybody else to approval (`onUnknown`; the
|
|
160
|
+
authority answers `refused: 'unknown'`, which `requestRoomEpoch` waits
|
|
161
|
+
through). `encodeRekeyEvent({ members })` and `hostRoomEpoch({ members })`
|
|
162
|
+
carry the authority's member list (`readMemberList` reads one)
|
|
157
163
|
- `canonicalAdmins`, `signAdmins`, `verifyAdmins` - the authority's signed
|
|
158
164
|
admin list
|
|
159
165
|
- `canonicalChannels`, `signChannels`, `verifyChannels`, `CHANNEL_NAME`,
|
package/dist/epoch.d.ts
CHANGED
|
@@ -73,6 +73,10 @@ export interface EpochKeys {
|
|
|
73
73
|
/** The cipher key. The room key in epoch 0. */
|
|
74
74
|
key: Uint8Array;
|
|
75
75
|
}
|
|
76
|
+
/** A participant list as a body carries it: lower-case, deduplicated and
|
|
77
|
+
* sorted, or undefined when the field is absent or is not a list of keys.
|
|
78
|
+
* Undefined is the safe reading, since it makes nobody known. */
|
|
79
|
+
export declare function readMemberList(raw: unknown): string[] | undefined;
|
|
76
80
|
/** A fresh secret for the next epoch. */
|
|
77
81
|
export declare function generateEpochSecret(): Uint8Array;
|
|
78
82
|
/**
|
|
@@ -108,6 +112,11 @@ export interface EncodeRekeyOptions {
|
|
|
108
112
|
* bring a device that missed this rekey up to date (see
|
|
109
113
|
* `member-epoch.ts`). Omitted, the event is byte-identical to before. */
|
|
110
114
|
commit?: boolean;
|
|
115
|
+
/** Every participant the authority knows to be in the room, not only the
|
|
116
|
+
* ones present now: a member who is offline as the room rekeys is still
|
|
117
|
+
* a member. Removed participants are dropped. Omitted, the event is
|
|
118
|
+
* byte-identical to before. */
|
|
119
|
+
members?: string[];
|
|
111
120
|
now: number;
|
|
112
121
|
/** A conference room's end, in unix seconds: the event carries it as a
|
|
113
122
|
* NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
|
|
@@ -125,6 +134,9 @@ export interface RekeyNotice {
|
|
|
125
134
|
* device that was removed, or that was not in the room when the
|
|
126
135
|
* authority rekeyed. */
|
|
127
136
|
secret?: Uint8Array;
|
|
137
|
+
/** The participants the authority listed as in the room. Absent from a
|
|
138
|
+
* rekey written without the list. */
|
|
139
|
+
members?: string[];
|
|
128
140
|
/** True for a current-state grant, whose removed list is cumulative.
|
|
129
141
|
* It synchronises this session; it does not describe a new removal. */
|
|
130
142
|
catchUp?: true;
|
|
@@ -225,8 +237,14 @@ export interface EpochRequest {
|
|
|
225
237
|
* cannot prove which participant it speaks for in this room, or from one
|
|
226
238
|
* that cannot prove it was admitted to the room at all. */
|
|
227
239
|
export declare function decodeEpochRequest(event: Event, opts: DecodeEpochRequestOptions): EpochRequest | null;
|
|
228
|
-
/**
|
|
229
|
-
|
|
240
|
+
/** How often a desk reports the same unknown participant while they keep
|
|
241
|
+
* asking: often enough that a missed "let them in?" comes back, rarely
|
|
242
|
+
* enough that it is not a nag. */
|
|
243
|
+
export declare const REPORT_UNKNOWN_EVERY_SECONDS = 60;
|
|
244
|
+
/** Why the authority would not hand an epoch over. `unknown` is not final:
|
|
245
|
+
* the room has removed somebody, and does not yet know this participant,
|
|
246
|
+
* so it waits for a member to let them in (#207). */
|
|
247
|
+
export type EpochRefusal = 'removed' | 'closed' | 'unknown';
|
|
230
248
|
export interface EncodeEpochGrantOptions {
|
|
231
249
|
roomId: string;
|
|
232
250
|
authoritySk: Uint8Array;
|
|
@@ -236,6 +254,9 @@ export interface EncodeEpochGrantOptions {
|
|
|
236
254
|
/** The current epoch. Omitted when refusing. */
|
|
237
255
|
epoch?: RoomEpoch;
|
|
238
256
|
removed?: string[];
|
|
257
|
+
/** The participants the authority knows, so the requester's own member
|
|
258
|
+
* desk knows them too. Omitted, the body is as before. */
|
|
259
|
+
members?: string[];
|
|
239
260
|
refused?: EpochRefusal;
|
|
240
261
|
/** A conference room's end, in unix seconds: the event carries it as a
|
|
241
262
|
* NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
|
|
@@ -257,6 +278,12 @@ export type EpochGrant = {
|
|
|
257
278
|
epoch: 0;
|
|
258
279
|
};
|
|
259
280
|
removed: string[];
|
|
281
|
+
/** Present on a member's answer through `requestRoomEpoch({ members })`:
|
|
282
|
+
* the epochs it carried between the requester's and `epoch`. See
|
|
283
|
+
* `MemberEpochGrant.passed`. The authority's own answer has none. */
|
|
284
|
+
passed?: RoomEpoch[];
|
|
285
|
+
/** The participants the room knows, when the answer carried them. */
|
|
286
|
+
members?: string[];
|
|
260
287
|
refused?: undefined;
|
|
261
288
|
} | {
|
|
262
289
|
refused: EpochRefusal;
|
|
@@ -277,6 +304,24 @@ export interface HostRoomEpochOptions {
|
|
|
277
304
|
removed: () => ReadonlySet<string>;
|
|
278
305
|
/** True once the room has been closed: every request is refused. */
|
|
279
306
|
closed?: () => boolean;
|
|
307
|
+
/**
|
|
308
|
+
* Whether the room knows this participant: on the authority's member
|
|
309
|
+
* list, in the room now, or let in. Consulted only once somebody has been
|
|
310
|
+
* removed, and then everybody else is answered `unknown` (#207). Without
|
|
311
|
+
* it, after a removal, nobody is known. Asked on every request.
|
|
312
|
+
*/
|
|
313
|
+
known?: (participant: string) => boolean;
|
|
314
|
+
/** The participants to tell a granted device about, so its own member
|
|
315
|
+
* desk knows them too. Asked on every grant. */
|
|
316
|
+
members?: () => readonly string[];
|
|
317
|
+
/** Somebody the room does not know asked, after a removal: what an app
|
|
318
|
+
* turns into "let them in?". Called once per participant, and again
|
|
319
|
+
* every `reportUnknownEvery` seconds while they keep asking. Letting them in
|
|
320
|
+
* is making `known` say yes; the requester's next ask is then granted. */
|
|
321
|
+
onUnknown?: (request: EpochRequest) => void;
|
|
322
|
+
/** Seconds before a participant still unknown and still asking is
|
|
323
|
+
* reported again. Default 60. */
|
|
324
|
+
reportUnknownEvery?: number;
|
|
280
325
|
policy?: RoomPolicy;
|
|
281
326
|
legacyParticipants?: ReadonlySet<string>;
|
|
282
327
|
now?: () => number;
|
|
@@ -294,6 +339,13 @@ export interface HostRoomEpochOptions {
|
|
|
294
339
|
* *secret*, which opens epoch 0 and nothing after it. A request with no
|
|
295
340
|
* admission proof, or one made under some other key, is not answered at
|
|
296
341
|
* all: a stranger learns nothing, not even that a desk is here.
|
|
342
|
+
*
|
|
343
|
+
* The admission proof is made under the epoch-0 room key, which a removed
|
|
344
|
+
* member still holds, so removal by participant alone is undone by a fresh
|
|
345
|
+
* key (#207). Once anyone has been removed the desk therefore grants only
|
|
346
|
+
* to participants the room knows (`known`), and answers anybody else
|
|
347
|
+
* `unknown` until a member lets them in. A room that has never removed
|
|
348
|
+
* anybody is answered exactly as before.
|
|
297
349
|
*/
|
|
298
350
|
export declare function hostRoomEpoch(opts: HostRoomEpochOptions): {
|
|
299
351
|
close(): void;
|
|
@@ -315,6 +367,9 @@ export interface RequestRoomEpochOptions {
|
|
|
315
367
|
/** Ask the room's current members too (`memberEpochSource`): the first
|
|
316
368
|
* answer that checks out, the authority's or a member's, settles it. */
|
|
317
369
|
members?: MemberEpochSource;
|
|
370
|
+
/** The authority said the room does not know this participant yet. Not
|
|
371
|
+
* final: the ask goes on, and a member letting them in settles it. */
|
|
372
|
+
onUnknown?: () => void;
|
|
318
373
|
}
|
|
319
374
|
/** Thrown when the authority answered, and the answer was no. */
|
|
320
375
|
export declare class EpochRefusedError extends Error {
|
|
@@ -323,7 +378,8 @@ export declare class EpochRefusedError extends Error {
|
|
|
323
378
|
}
|
|
324
379
|
/** Ask the authority where the room is, and wait for the answer. Rejects
|
|
325
380
|
* with `EpochRefusedError` on a refusal, and with a plain error when
|
|
326
|
-
* nobody answers inside the timeout.
|
|
381
|
+
* nobody answers inside the timeout. `unknown` is not final: the ask goes
|
|
382
|
+
* on, and only a timeout after it rejects, with `EpochRefusedError`. */
|
|
327
383
|
export declare function requestRoomEpoch(opts: RequestRoomEpochOptions): Promise<Exclude<EpochGrant, {
|
|
328
384
|
refused: EpochRefusal;
|
|
329
385
|
}>>;
|
package/dist/epoch.js
CHANGED
|
@@ -80,6 +80,14 @@ function require32(bytes, what) {
|
|
|
80
80
|
if (bytes.length !== 32)
|
|
81
81
|
throw new Error(`${what} must be 32 bytes`);
|
|
82
82
|
}
|
|
83
|
+
/** A participant list as a body carries it: lower-case, deduplicated and
|
|
84
|
+
* sorted, or undefined when the field is absent or is not a list of keys.
|
|
85
|
+
* Undefined is the safe reading, since it makes nobody known. */
|
|
86
|
+
export function readMemberList(raw) {
|
|
87
|
+
if (!Array.isArray(raw) || !raw.every((p) => typeof p === 'string' && HEX64.test(p)))
|
|
88
|
+
return undefined;
|
|
89
|
+
return [...new Set(raw.map(normaliseHex))].sort();
|
|
90
|
+
}
|
|
83
91
|
function requireHex32(value, what) {
|
|
84
92
|
if (!HEX64.test(value))
|
|
85
93
|
throw new Error(`${what} must be 32-byte hex`);
|
|
@@ -119,6 +127,12 @@ export function deriveEpoch(epoch) {
|
|
|
119
127
|
key: hkdf(sha256, epoch.secret, undefined, `${EPOCH_KEY_INFO}/${n}`, 32),
|
|
120
128
|
};
|
|
121
129
|
}
|
|
130
|
+
function memberListOf(members, removed) {
|
|
131
|
+
const out = new Set(members.map((p) => requireHex32(p, 'member participant')));
|
|
132
|
+
for (const p of removed)
|
|
133
|
+
out.delete(normaliseHex(p));
|
|
134
|
+
return [...out].sort();
|
|
135
|
+
}
|
|
122
136
|
/** Announce the next epoch. See the module comment for what this is. */
|
|
123
137
|
export function encodeRekeyEvent(opts) {
|
|
124
138
|
require32(opts.authoritySk, 'authority secret key');
|
|
@@ -142,6 +156,7 @@ export function encodeRekeyEvent(opts) {
|
|
|
142
156
|
...(opts.by !== undefined ? { by: requireHex32(opts.by, 'admin') } : {}),
|
|
143
157
|
...(opts.closed ? { closed: true } : {}),
|
|
144
158
|
...(opts.commit ? { commit: epochCommitment(roomId, epoch, opts.next.secret) } : {}),
|
|
159
|
+
...(opts.members !== undefined ? { members: memberListOf(opts.members, removed) } : {}),
|
|
145
160
|
keys,
|
|
146
161
|
};
|
|
147
162
|
return finalizeEvent({
|
|
@@ -206,6 +221,9 @@ export function decodeRekeyEvent(event, opts) {
|
|
|
206
221
|
};
|
|
207
222
|
if (typeof body.by === 'string' && HEX64.test(body.by))
|
|
208
223
|
notice.by = normaliseHex(body.by);
|
|
224
|
+
const members = readMemberList(body.members);
|
|
225
|
+
if (members)
|
|
226
|
+
notice.members = members;
|
|
209
227
|
const device = getPublicKey(opts.deviceSk);
|
|
210
228
|
const mine = Object.entries(body.keys).find(([to]) => hexEquals(to, device))?.[1];
|
|
211
229
|
if (typeof mine === 'string') {
|
|
@@ -346,6 +364,10 @@ export function decodeEpochRequest(event, opts) {
|
|
|
346
364
|
return null;
|
|
347
365
|
}
|
|
348
366
|
}
|
|
367
|
+
/** How often a desk reports the same unknown participant while they keep
|
|
368
|
+
* asking: often enough that a missed "let them in?" comes back, rarely
|
|
369
|
+
* enough that it is not a nag. */
|
|
370
|
+
export const REPORT_UNKNOWN_EVERY_SECONDS = 60;
|
|
349
371
|
/** Answer one request: the current epoch sealed to the asking device, or a
|
|
350
372
|
* refusal it alone can read. */
|
|
351
373
|
export function encodeEpochGrant(opts) {
|
|
@@ -366,6 +388,8 @@ export function encodeEpochGrant(opts) {
|
|
|
366
388
|
body.secret = base64urlnopad.encode(opts.epoch.secret);
|
|
367
389
|
}
|
|
368
390
|
body.removed = [...new Set((opts.removed ?? []).map((p) => requireHex32(p, 'removed participant')))].sort();
|
|
391
|
+
if (opts.members !== undefined)
|
|
392
|
+
body.members = memberListOf(opts.members, body.removed);
|
|
369
393
|
}
|
|
370
394
|
return finalizeEvent({
|
|
371
395
|
kind: KINDS.EPOCH_GRANT,
|
|
@@ -399,21 +423,23 @@ export function decodeEpochGrant(event, opts) {
|
|
|
399
423
|
const body = JSON.parse(nip44.v2.decrypt(event.content, nip44.v2.utils.getConversationKey(opts.deviceSk, event.pubkey)));
|
|
400
424
|
if (body.v !== 1 || typeof body.request !== 'string' || !hexEquals(body.request, opts.request))
|
|
401
425
|
return null;
|
|
402
|
-
if (body.refused === 'removed' || body.refused === 'closed')
|
|
426
|
+
if (body.refused === 'removed' || body.refused === 'closed' || body.refused === 'unknown')
|
|
403
427
|
return { refused: body.refused };
|
|
404
428
|
if (!Number.isSafeInteger(body.epoch) || body.epoch < 0 || body.epoch > MAX_EPOCH)
|
|
405
429
|
return null;
|
|
406
430
|
const removed = Array.isArray(body.removed)
|
|
407
431
|
? [...new Set(body.removed.filter((p) => typeof p === 'string' && HEX64.test(p)).map(normaliseHex))].sort()
|
|
408
432
|
: [];
|
|
433
|
+
const members = readMemberList(body.members);
|
|
434
|
+
const listed = members ? { members } : {};
|
|
409
435
|
if (body.epoch === 0)
|
|
410
|
-
return { epoch: { epoch: 0 }, removed };
|
|
436
|
+
return { epoch: { epoch: 0 }, removed, ...listed };
|
|
411
437
|
if (typeof body.secret !== 'string')
|
|
412
438
|
return null;
|
|
413
439
|
const secret = base64urlnopad.decode(body.secret);
|
|
414
440
|
if (secret.length !== 32)
|
|
415
441
|
return null;
|
|
416
|
-
return { epoch: { epoch: body.epoch, secret }, removed };
|
|
442
|
+
return { epoch: { epoch: body.epoch, secret }, removed, ...listed };
|
|
417
443
|
}
|
|
418
444
|
catch {
|
|
419
445
|
return null;
|
|
@@ -428,6 +454,13 @@ export function decodeEpochGrant(event, opts) {
|
|
|
428
454
|
* *secret*, which opens epoch 0 and nothing after it. A request with no
|
|
429
455
|
* admission proof, or one made under some other key, is not answered at
|
|
430
456
|
* all: a stranger learns nothing, not even that a desk is here.
|
|
457
|
+
*
|
|
458
|
+
* The admission proof is made under the epoch-0 room key, which a removed
|
|
459
|
+
* member still holds, so removal by participant alone is undone by a fresh
|
|
460
|
+
* key (#207). Once anyone has been removed the desk therefore grants only
|
|
461
|
+
* to participants the room knows (`known`), and answers anybody else
|
|
462
|
+
* `unknown` until a member lets them in. A room that has never removed
|
|
463
|
+
* anybody is answered exactly as before.
|
|
431
464
|
*/
|
|
432
465
|
export function hostRoomEpoch(opts) {
|
|
433
466
|
require32(opts.authoritySk, 'authority secret key');
|
|
@@ -436,6 +469,16 @@ export function hostRoomEpoch(opts) {
|
|
|
436
469
|
const authority = getPublicKey(opts.authoritySk);
|
|
437
470
|
const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
|
|
438
471
|
const answered = new Set();
|
|
472
|
+
/** Requests answered `unknown`, which the requester sends again while it
|
|
473
|
+
* waits: answered again only once they are known. */
|
|
474
|
+
const waiting = new Set();
|
|
475
|
+
/** When each unknown participant was last reported through `onUnknown`,
|
|
476
|
+
* so one still waiting is reported again only after `reportUnknownEvery`. */
|
|
477
|
+
const reported = new Map();
|
|
478
|
+
const bound = (set) => {
|
|
479
|
+
if (set.size > 256)
|
|
480
|
+
set.delete(set.values().next().value);
|
|
481
|
+
};
|
|
439
482
|
let closed = false;
|
|
440
483
|
const unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_REQUEST], '#d': [roomId], '#p': [authority] }], (event) => {
|
|
441
484
|
if (closed)
|
|
@@ -450,14 +493,24 @@ export function hostRoomEpoch(opts) {
|
|
|
450
493
|
});
|
|
451
494
|
if (!request || answered.has(request.request))
|
|
452
495
|
return;
|
|
453
|
-
answered.add(request.request);
|
|
454
|
-
if (answered.size > 256)
|
|
455
|
-
answered.delete(answered.values().next().value);
|
|
456
496
|
let refused;
|
|
457
497
|
if (opts.closed?.())
|
|
458
498
|
refused = 'closed';
|
|
459
499
|
else if (opts.removed().has(request.participant))
|
|
460
500
|
refused = 'removed';
|
|
501
|
+
else if (opts.removed().size > 0 && !opts.known?.(request.participant))
|
|
502
|
+
refused = 'unknown';
|
|
503
|
+
if (refused === 'unknown') {
|
|
504
|
+
if (waiting.has(request.request))
|
|
505
|
+
return;
|
|
506
|
+
waiting.add(request.request);
|
|
507
|
+
bound(waiting);
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
waiting.delete(request.request);
|
|
511
|
+
answered.add(request.request);
|
|
512
|
+
bound(answered);
|
|
513
|
+
}
|
|
461
514
|
let grant;
|
|
462
515
|
try {
|
|
463
516
|
grant = refused
|
|
@@ -470,6 +523,7 @@ export function hostRoomEpoch(opts) {
|
|
|
470
523
|
now: now(),
|
|
471
524
|
epoch: opts.current(),
|
|
472
525
|
removed: [...opts.removed()],
|
|
526
|
+
...(opts.members ? { members: [...opts.members()] } : {}),
|
|
473
527
|
expiresAt: opts.expiresAt,
|
|
474
528
|
});
|
|
475
529
|
}
|
|
@@ -477,6 +531,14 @@ export function hostRoomEpoch(opts) {
|
|
|
477
531
|
return;
|
|
478
532
|
}
|
|
479
533
|
opts.transport.publish(grant).catch(() => { });
|
|
534
|
+
if (refused === 'unknown' && now() - (reported.get(request.participant) ?? -Infinity) >= (opts.reportUnknownEvery ?? REPORT_UNKNOWN_EVERY_SECONDS)) {
|
|
535
|
+
reported.set(request.participant, now());
|
|
536
|
+
if (reported.size > 256)
|
|
537
|
+
reported.delete(reported.keys().next().value);
|
|
538
|
+
opts.onUnknown?.(request);
|
|
539
|
+
}
|
|
540
|
+
if (!refused)
|
|
541
|
+
reported.delete(request.participant);
|
|
480
542
|
if (refused)
|
|
481
543
|
opts.onRefused?.(request, refused);
|
|
482
544
|
else
|
|
@@ -495,14 +557,19 @@ export function hostRoomEpoch(opts) {
|
|
|
495
557
|
export class EpochRefusedError extends Error {
|
|
496
558
|
refused;
|
|
497
559
|
constructor(refused) {
|
|
498
|
-
super(refused === 'removed'
|
|
560
|
+
super(refused === 'removed'
|
|
561
|
+
? 'you were removed from this room'
|
|
562
|
+
: refused === 'unknown'
|
|
563
|
+
? 'nobody in this room has let you in yet'
|
|
564
|
+
: 'this room has been closed');
|
|
499
565
|
this.name = 'EpochRefusedError';
|
|
500
566
|
this.refused = refused;
|
|
501
567
|
}
|
|
502
568
|
}
|
|
503
569
|
/** Ask the authority where the room is, and wait for the answer. Rejects
|
|
504
570
|
* with `EpochRefusedError` on a refusal, and with a plain error when
|
|
505
|
-
* nobody answers inside the timeout.
|
|
571
|
+
* nobody answers inside the timeout. `unknown` is not final: the ask goes
|
|
572
|
+
* on, and only a timeout after it rejects, with `EpochRefusedError`. */
|
|
506
573
|
export function requestRoomEpoch(opts) {
|
|
507
574
|
const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
|
|
508
575
|
const roomId = requireHex32(opts.roomId, 'room id');
|
|
@@ -523,6 +590,7 @@ export function requestRoomEpoch(opts) {
|
|
|
523
590
|
let expiry;
|
|
524
591
|
let unsub = () => { };
|
|
525
592
|
let stopMembers = () => { };
|
|
593
|
+
let unknown = false;
|
|
526
594
|
const finish = (settle) => {
|
|
527
595
|
if (settled)
|
|
528
596
|
return;
|
|
@@ -539,7 +607,12 @@ export function requestRoomEpoch(opts) {
|
|
|
539
607
|
const grant = decodeEpochGrant(event, { roomId, authority: opts.authority, deviceSk: opts.deviceSk, request: request.id, now: now() });
|
|
540
608
|
if (!grant)
|
|
541
609
|
return;
|
|
542
|
-
if (grant.refused)
|
|
610
|
+
if (grant.refused === 'unknown') {
|
|
611
|
+
if (!unknown)
|
|
612
|
+
opts.onUnknown?.();
|
|
613
|
+
unknown = true;
|
|
614
|
+
}
|
|
615
|
+
else if (grant.refused)
|
|
543
616
|
finish(() => reject(new EpochRefusedError(grant.refused)));
|
|
544
617
|
else
|
|
545
618
|
finish(() => resolve(grant));
|
|
@@ -555,7 +628,7 @@ export function requestRoomEpoch(opts) {
|
|
|
555
628
|
if (!settled)
|
|
556
629
|
opts.transport.publish(request).catch(() => { });
|
|
557
630
|
};
|
|
558
|
-
expiry = setTimeout(() => finish(() => reject(new Error('the room has moved to a newer epoch and its keeper is not answering'))), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
631
|
+
expiry = setTimeout(() => finish(() => reject(unknown ? new EpochRefusedError('unknown') : new Error('the room has moved to a newer epoch and its keeper is not answering'))), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
559
632
|
expiry.unref?.();
|
|
560
633
|
retry = setInterval(ask, opts.retryMs ?? DEFAULT_RETRY_MS);
|
|
561
634
|
retry.unref?.();
|
package/dist/index.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } fr
|
|
|
24
24
|
export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
|
|
25
25
|
export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
|
|
26
26
|
export type { RoomLink } from './link.js';
|
|
27
|
-
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, 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';
|
|
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
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
29
|
export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
|
|
30
30
|
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';
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ export { encodePersistentInvitation, decodePersistentInvitation, requestPersiste
|
|
|
16
16
|
export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
|
|
17
17
|
export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
|
|
18
18
|
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, 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';
|
|
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
20
|
export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
|
|
21
21
|
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
22
|
export { deriveChannel, CHANNEL_ID_INFO, CHANNEL_KEY_INFO, MAX_CHANNEL_NAME_LENGTH, CHANNEL_LABELS } from './channel.js';
|
package/dist/member-epoch.d.ts
CHANGED
|
@@ -61,6 +61,8 @@ export interface RekeyEvidence {
|
|
|
61
61
|
closed: boolean;
|
|
62
62
|
/** The epoch commitment, when the authority wrote one. */
|
|
63
63
|
commit?: string;
|
|
64
|
+
/** The authority's member list, when it wrote one. */
|
|
65
|
+
members?: string[];
|
|
64
66
|
}
|
|
65
67
|
/**
|
|
66
68
|
* Read an authority-signed rekey with the key of the epoch it leaves,
|
|
@@ -156,6 +158,13 @@ export interface MemberEpochGrant {
|
|
|
156
158
|
epoch: RoomEpoch;
|
|
157
159
|
/** Cumulative: `opts.removed` plus every removal in the chain. */
|
|
158
160
|
removed: string[];
|
|
161
|
+
/** The epochs between the requester's and `epoch`, oldest first, each
|
|
162
|
+
* proven by the next rekey in the chain decrypting under it. Empty for a
|
|
163
|
+
* grant one epoch ahead. Kept, they let the requester read what was said
|
|
164
|
+
* in the epochs it skipped, and hand them on from its own member desk. */
|
|
165
|
+
passed: RoomEpoch[];
|
|
166
|
+
/** The newest member list in the chain, when any rekey in it had one. */
|
|
167
|
+
members?: string[];
|
|
159
168
|
}
|
|
160
169
|
/**
|
|
161
170
|
* Verify a member's answer to one of this device's member requests. Null
|
|
@@ -185,6 +194,21 @@ export interface HostMemberEpochDeskOptions {
|
|
|
185
194
|
removed: () => ReadonlySet<string>;
|
|
186
195
|
/** True once the room has been closed. */
|
|
187
196
|
closed?: () => boolean;
|
|
197
|
+
/**
|
|
198
|
+
* Whether the room knows this participant: on the authority's latest
|
|
199
|
+
* member list, in the room's current roster, or let in from this device.
|
|
200
|
+
* Consulted only once somebody has been removed; then nobody else is
|
|
201
|
+
* answered (#207). Without it, after a removal, nobody is known.
|
|
202
|
+
*/
|
|
203
|
+
known?: (participant: string) => boolean;
|
|
204
|
+
/** Somebody the room does not know asked, after a removal. Called once
|
|
205
|
+
* per participant, and again every `reportUnknownEvery` seconds while
|
|
206
|
+
* they keep asking; nothing is published. Letting them in is making `known`
|
|
207
|
+
* say yes, and their next ask is answered. */
|
|
208
|
+
onUnknown?: (request: MemberEpochRequest) => void;
|
|
209
|
+
/** Seconds before a participant still unknown and still asking is
|
|
210
|
+
* reported again. Default 60 (`REPORT_UNKNOWN_EVERY_SECONDS`). */
|
|
211
|
+
reportUnknownEvery?: number;
|
|
188
212
|
policy?: RoomPolicy;
|
|
189
213
|
now?: () => number;
|
|
190
214
|
/** Upper bound of the random wait before answering, so several members do
|
|
@@ -207,8 +231,10 @@ export interface HostMemberEpochDeskOptions {
|
|
|
207
231
|
* Answer member epoch requests for as long as the handle is open: what any
|
|
208
232
|
* member at the current epoch runs beside its session. It answers only an
|
|
209
233
|
* admitted, credentialled device that is not removed, in a room that is not
|
|
210
|
-
* closed,
|
|
211
|
-
*
|
|
234
|
+
* closed, from a participant the room knows once anybody has been removed
|
|
235
|
+
* (see `hostRoomEpoch` for why), and only when it can hand over the whole
|
|
236
|
+
* chain from the requester's epoch to its own with the authority's rekeys
|
|
237
|
+
* to prove it.
|
|
212
238
|
*
|
|
213
239
|
* Anti-amplification: each answer waits a random `[0, jitterMs)`, and is
|
|
214
240
|
* dropped if another member's grant to the same device appears meanwhile.
|
package/dist/member-epoch.js
CHANGED
|
@@ -10,7 +10,7 @@ import { verifyEventUncached } from './verify.js';
|
|
|
10
10
|
import { withExpiration } from './expiration.js';
|
|
11
11
|
import { epochCommitment } from './epoch-commit.js';
|
|
12
12
|
import { KINDS } from './kinds.js';
|
|
13
|
-
import { MAX_EPOCH, deriveEpoch, epochRequestAdmission, peekRekeyEvent, } from './epoch.js';
|
|
13
|
+
import { MAX_EPOCH, REPORT_UNKNOWN_EVERY_SECONDS, deriveEpoch, epochRequestAdmission, peekRekeyEvent, readMemberList, } from './epoch.js';
|
|
14
14
|
/**
|
|
15
15
|
* Member-to-member epoch catch-up: any current member can bring an
|
|
16
16
|
* admitted, non-removed member up to date.
|
|
@@ -118,6 +118,9 @@ export function readRekeyEvidence(event, opts) {
|
|
|
118
118
|
};
|
|
119
119
|
if (typeof body.commit === 'string' && HEX64.test(body.commit))
|
|
120
120
|
evidence.commit = normaliseHex(body.commit);
|
|
121
|
+
const members = readMemberList(body.members);
|
|
122
|
+
if (members)
|
|
123
|
+
evidence.members = members;
|
|
121
124
|
return evidence;
|
|
122
125
|
}
|
|
123
126
|
catch {
|
|
@@ -278,6 +281,8 @@ export function decodeMemberEpochGrant(event, opts) {
|
|
|
278
281
|
const removed = new Set([...(opts.removed ?? [])].map(normaliseHex));
|
|
279
282
|
let previous = opts.current;
|
|
280
283
|
let secret;
|
|
284
|
+
const passed = [];
|
|
285
|
+
let members;
|
|
281
286
|
for (let i = 0; i < length; i += 1) {
|
|
282
287
|
const evidence = readRekeyEvidence(body.rekeys[i], { roomId, authority: opts.authority, previous });
|
|
283
288
|
if (!evidence)
|
|
@@ -288,6 +293,8 @@ export function decodeMemberEpochGrant(event, opts) {
|
|
|
288
293
|
return null;
|
|
289
294
|
for (const p of evidence.removed)
|
|
290
295
|
removed.add(p);
|
|
296
|
+
if (evidence.members)
|
|
297
|
+
members = evidence.members;
|
|
291
298
|
const raw = body.secrets[i];
|
|
292
299
|
if (typeof raw !== 'string')
|
|
293
300
|
return null;
|
|
@@ -304,8 +311,10 @@ export function decodeMemberEpochGrant(event, opts) {
|
|
|
304
311
|
return null;
|
|
305
312
|
}
|
|
306
313
|
previous = deriveEpoch({ epoch: evidence.epoch, secret });
|
|
314
|
+
if (i < length - 1)
|
|
315
|
+
passed.push({ epoch: evidence.epoch, secret });
|
|
307
316
|
}
|
|
308
|
-
return { epoch: { epoch: top, secret: secret }, removed: [...removed].sort() };
|
|
317
|
+
return { epoch: { epoch: top, secret: secret }, removed: [...removed].sort(), passed, ...(members ? { members } : {}) };
|
|
309
318
|
}
|
|
310
319
|
catch {
|
|
311
320
|
return null;
|
|
@@ -315,8 +324,10 @@ export function decodeMemberEpochGrant(event, opts) {
|
|
|
315
324
|
* Answer member epoch requests for as long as the handle is open: what any
|
|
316
325
|
* member at the current epoch runs beside its session. It answers only an
|
|
317
326
|
* admitted, credentialled device that is not removed, in a room that is not
|
|
318
|
-
* closed,
|
|
319
|
-
*
|
|
327
|
+
* closed, from a participant the room knows once anybody has been removed
|
|
328
|
+
* (see `hostRoomEpoch` for why), and only when it can hand over the whole
|
|
329
|
+
* chain from the requester's epoch to its own with the authority's rekeys
|
|
330
|
+
* to prove it.
|
|
320
331
|
*
|
|
321
332
|
* Anti-amplification: each answer waits a random `[0, jitterMs)`, and is
|
|
322
333
|
* dropped if another member's grant to the same device appears meanwhile.
|
|
@@ -341,6 +352,10 @@ export function hostMemberEpochDesk(opts) {
|
|
|
341
352
|
const grantSeen = new Set();
|
|
342
353
|
/** Requesting devices this desk has already stood down for once. */
|
|
343
354
|
const stoodDown = new Set();
|
|
355
|
+
/** When each unknown participant was last reported. A requester asks
|
|
356
|
+
* afresh every `retryMs` while it waits, so this is by participant, not
|
|
357
|
+
* by request: one "let them in?" a `reportUnknownEvery`, not one a retry. */
|
|
358
|
+
const reported = new Map();
|
|
344
359
|
/** Ids of the grants this desk published: each is signed by a one-time
|
|
345
360
|
* key, so its own grants are told apart by id, not by signer. */
|
|
346
361
|
const mine = new Set();
|
|
@@ -383,6 +398,12 @@ export function hostMemberEpochDesk(opts) {
|
|
|
383
398
|
}
|
|
384
399
|
return { epochs, rekeys };
|
|
385
400
|
};
|
|
401
|
+
const admissible = (request) => {
|
|
402
|
+
const removed = opts.removed();
|
|
403
|
+
if ([...removed].some((p) => hexEquals(p, request.participant)))
|
|
404
|
+
return false;
|
|
405
|
+
return removed.size === 0 || opts.known?.(request.participant) === true;
|
|
406
|
+
};
|
|
386
407
|
const answer = (request) => {
|
|
387
408
|
if (closed)
|
|
388
409
|
return;
|
|
@@ -395,7 +416,7 @@ export function hostMemberEpochDesk(opts) {
|
|
|
395
416
|
// Asked again, now that the wait is over: the room may have moved.
|
|
396
417
|
if (opts.closed?.())
|
|
397
418
|
return;
|
|
398
|
-
if (
|
|
419
|
+
if (!admissible(request))
|
|
399
420
|
return;
|
|
400
421
|
const current = opts.current();
|
|
401
422
|
if (!current || request.have >= current.epoch)
|
|
@@ -440,16 +461,32 @@ export function hostMemberEpochDesk(opts) {
|
|
|
440
461
|
const request = decodeMemberEpochRequest(event, { roomId, authority, roomKey: opts.roomKey, now: now(), policy: opts.policy });
|
|
441
462
|
if (!request || answered.has(request.request))
|
|
442
463
|
return;
|
|
443
|
-
answered.add(request.request);
|
|
444
|
-
bound(answered);
|
|
445
464
|
if (opts.closed?.()) {
|
|
465
|
+
answered.add(request.request);
|
|
466
|
+
bound(answered);
|
|
446
467
|
opts.onRefused?.(request, 'closed');
|
|
447
468
|
return;
|
|
448
469
|
}
|
|
449
470
|
if ([...opts.removed()].some((p) => hexEquals(p, request.participant))) {
|
|
471
|
+
answered.add(request.request);
|
|
472
|
+
bound(answered);
|
|
450
473
|
opts.onRefused?.(request, 'removed');
|
|
451
474
|
return;
|
|
452
475
|
}
|
|
476
|
+
if (!admissible(request)) {
|
|
477
|
+
const last = reported.get(request.participant);
|
|
478
|
+
if (last !== undefined && now() - last < (opts.reportUnknownEvery ?? REPORT_UNKNOWN_EVERY_SECONDS))
|
|
479
|
+
return;
|
|
480
|
+
reported.set(request.participant, now());
|
|
481
|
+
if (reported.size > 256)
|
|
482
|
+
reported.delete(reported.keys().next().value);
|
|
483
|
+
opts.onUnknown?.(request);
|
|
484
|
+
opts.onRefused?.(request, 'unknown');
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
reported.delete(request.participant);
|
|
488
|
+
answered.add(request.request);
|
|
489
|
+
bound(answered);
|
|
453
490
|
const current = opts.current();
|
|
454
491
|
if (!current || request.have >= current.epoch)
|
|
455
492
|
return;
|
package/llms.txt
CHANGED
|
@@ -79,7 +79,8 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
|
|
|
79
79
|
- `encodeEpochGrant(opts)` / `decodeEpochGrant(event, opts)` - the authority's answer (secret, epoch-0, or a refusal `'removed' | 'closed'`)
|
|
80
80
|
- `epochRequestAdmission(opts)`, `deriveEpochRequestKey(roomKey)`
|
|
81
81
|
- `hostRoomEpoch(opts)` / `requestRoomEpoch(opts)` / `EpochRefusedError` - the live request/grant desk, both sides
|
|
82
|
-
- Member epoch catch-up (see `docs/member-epoch-catch-up.md`): `encodeRekeyEvent({ ..., commit: true })` writes `epochCommitment(roomId, epoch, secret)` into the rekey body; `hostMemberEpochDesk(opts)` lets any current member answer; `requestRoomEpoch({ ..., members: memberEpochSource(opts) })` asks the authority and members, first verifiable answer wins; `requestMemberEpoch(opts)` asks members alone. Kinds `MEMBER_EPOCH_KINDS` = `{ REQUEST: 20471, GRANT: 20472 }`. A member grant carries the authority-signed rekeys from the asker's epoch to the current one; the asker verifies the chain itself (`decodeMemberEpochGrant`) and refuses a last epoch with no commitment (legacy: authority only), a chain that removes it or closes the room, and one that stops short of `expected`. Codecs: `encodeMemberEpochRequest`, `decodeMemberEpochRequest`, `encodeMemberEpochGrant`, `readRekeyEvidence`, `deriveMemberEpochRequestKey`, `MAX_MEMBER_EPOCH_CHAIN` (32)
|
|
82
|
+
- Member epoch catch-up (see `docs/member-epoch-catch-up.md`): `encodeRekeyEvent({ ..., commit: true })` writes `epochCommitment(roomId, epoch, secret)` into the rekey body; `hostMemberEpochDesk(opts)` lets any current member answer; `requestRoomEpoch({ ..., members: memberEpochSource(opts) })` asks the authority and members, first verifiable answer wins; `requestMemberEpoch(opts)` asks members alone. Kinds `MEMBER_EPOCH_KINDS` = `{ REQUEST: 20471, GRANT: 20472 }`. A member grant carries the authority-signed rekeys from the asker's epoch to the current one; the asker verifies the chain itself (`decodeMemberEpochGrant`) and refuses a last epoch with no commitment (legacy: authority only), a chain that removes it or closes the room, and one that stops short of `expected`. A verified grant is `{ epoch, removed, passed }`: `passed` holds the epochs between the asker's and `epoch`, oldest first, and `requestRoomEpoch` carries it as an optional `passed` when a member answered. Codecs: `encodeMemberEpochRequest`, `decodeMemberEpochRequest`, `encodeMemberEpochGrant`, `readRekeyEvidence`, `deriveMemberEpochRequestKey`, `MAX_MEMBER_EPOCH_CHAIN` (32)
|
|
83
|
+
- The known-members gate (#207, see `docs/member-epoch-catch-up.md`): once a room has removed anybody (`removed()` not empty), `hostRoomEpoch` and `hostMemberEpochDesk` grant only to a participant for which `known(participant)` is true; anybody else is reported through `onUnknown` (once per participant, and again every `reportUnknownEvery` seconds, default `REPORT_UNKNOWN_EVERY_SECONDS` = 60, while they keep asking) and refused: the authority answers `refused: 'unknown'` (an `EpochRefusal`), a member stays silent. `unknown` is not final: `requestRoomEpoch` keeps asking, calls its `onUnknown` once, and rejects with `EpochRefusedError('unknown')` only at its timeout; a desk looks at the requester's next ask afresh, so letting somebody in is making `known` say yes. No `known` after a removal means nobody is known. Member lists: `encodeRekeyEvent({ members })` writes the authority's list (removed dropped, sorted) into the body, read back as `RekeyNotice.members` and `RekeyEvidence.members`, and as `MemberEpochGrant.members` (newest in the chain); `hostRoomEpoch({ members })` puts it in its grants (`EpochGrant.members`). `readMemberList(raw)` reads one, undefined when malformed. Without `members` and with no removal every event is byte-identical to 0.5.1
|
|
83
84
|
- `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
|
|
84
85
|
- `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
|
|
85
86
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/fold-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Circle primitives for private Nostr groups nobody operates: epoch keys, link invites, removal by rekey, device credentials.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -136,6 +136,70 @@
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
},
|
|
139
|
+
{
|
|
140
|
+
"name": "rekey-with-members",
|
|
141
|
+
"kind": "positive",
|
|
142
|
+
"note": "The rekey to epoch 2 with the authority's member list (`encodeRekeyEvent({ members })`, #207): the participants it knows to be in the room, the removed ones dropped even when passed in, lower-case, deduplicated and sorted. Body key order: v, epoch, removed, commit, members, keys. A desk hands an epoch on, once anybody has been removed, only to a participant the room knows; this list is where a member learns who that is. A list that is not an array of 32-byte hex keys is read as absent, which makes nobody known.",
|
|
143
|
+
"input": {
|
|
144
|
+
"roomId": "76c7081ef2322b7619d5cb5e690779fb942d63964f0b819e3d852db6c6a264f9",
|
|
145
|
+
"authority": "a5098de007349f8c12d6a854dfc069074b803470f59484697c50c5211b13c810",
|
|
146
|
+
"previousEpoch": 1,
|
|
147
|
+
"previousSecretHex": "cf6c5602daa51ecab29fbcbcef0da517b303cd59ee2904c15e226b7dd00e7752",
|
|
148
|
+
"previousKeyHex": "e038a7fca65628eebee35c562410ac63cce191b776ba0e8e5a1d241d9417f733",
|
|
149
|
+
"next": {
|
|
150
|
+
"epoch": 2,
|
|
151
|
+
"secretHex": "d4f5a0426e6880797fa29d914bb6977d193dfcd4f46b2a3acc576226eeec3ac9"
|
|
152
|
+
},
|
|
153
|
+
"recipients": [
|
|
154
|
+
"ef4396c842882c47ad1106d846d5c7e712c8928b69c92bc1798fa63deb9dd76b"
|
|
155
|
+
],
|
|
156
|
+
"removed": [
|
|
157
|
+
"298cf780bc6d1b93c4900b20c89a911286ee3488ab94f543d96d567c70eb9ed2"
|
|
158
|
+
],
|
|
159
|
+
"members": [
|
|
160
|
+
"588c59130ea8b9d81e836d0c5a89daa6b888923357c1415359b48595b4937f53",
|
|
161
|
+
"298cf780bc6d1b93c4900b20c89a911286ee3488ab94f543d96d567c70eb9ed2"
|
|
162
|
+
],
|
|
163
|
+
"authoritySkHex": "825cf62829b0262bc5b46870d5cc054d6049e54a68cf1ea393c3da56226c456b",
|
|
164
|
+
"createdAt": 1799999002,
|
|
165
|
+
"event": {
|
|
166
|
+
"id": "d8d5c810c2e22d87493d8a10f908e38e68b5e4611ad5a230103ca01d5ba7a954",
|
|
167
|
+
"pubkey": "a5098de007349f8c12d6a854dfc069074b803470f59484697c50c5211b13c810",
|
|
168
|
+
"created_at": 1799999002,
|
|
169
|
+
"kind": 1462,
|
|
170
|
+
"tags": [
|
|
171
|
+
[
|
|
172
|
+
"d",
|
|
173
|
+
"76c7081ef2322b7619d5cb5e690779fb942d63964f0b819e3d852db6c6a264f9"
|
|
174
|
+
],
|
|
175
|
+
[
|
|
176
|
+
"epoch",
|
|
177
|
+
"2"
|
|
178
|
+
]
|
|
179
|
+
],
|
|
180
|
+
"content": "AjCp1V9xfa32TYx5FAmMp1toqAM2yKrdBWdygJs+bFNGNIls48ZUufb0m3Cs51qusvmVJF51AEvXiW82LyhO6o/2J9Sra65b80+cyj8Km+LrLCXx6qyLPIYjKdISORDS6UhDKXa9UInbnZnGf87KKruDujB82QKDo26ImrXPbzy246dYMCvon7dYLGa+y3Qk9u5OLVKSrYVhDhFRHZX2XMltl478SRQ7GuvzbibZbFrNz1EvWSJOqbxf8YLpvYzbq55xmr9CA1rjk9vBKTzaHGX23ZvPPdaQFHh53YXCzYGN4+p54P/gH9PgZREZt+5Jel5kDhVfk8YMinaSzaBQ7MvEUEEIGDUPc6xbmZ3twlam+y/4H0vuugQtOm85tU7sozohPj65hlpaxRmTdenWivldIAWyPAEsUjOtGcvnFur5Hbg9H+WEsyXAX6tLZZV3jNFcCPLkoWB9O3iFasN3m2P0larhRTr9lhReqzt3UxtGBp14rErj0uZEZlwrz4n3ZiFgH/qc1X/pFlskh4dQJV/PGCEe6F7v7fd4aChiYHA1ghq2cy4fKW44+FU2EKPEezWcD96sOshiCoh0xWxHJoNbWhKJ5P8EcyK7TCH9UJVk9OyAiGVYnnxh+cuDgVfCOdRR6VbxWopqclvhlV+NGBullxSv5aQJSj6fS5O+QxSCwZrR8DA2SMKRSFU+kXHQxOgEvZW1cPdHWMtUr1dwsRrMd3ayNyZqSIjjyktqzsmrD+Pl6Ikk5tbE3kyNYdWHX7Mz",
|
|
181
|
+
"sig": "90c5666a5ca4c5d4c1a0e4b3b4422aa7462d4300986f82afff435a88edb48a41e3191192c6404b17401712204de56ba2269a6b4376f0ef224ad9798728550710"
|
|
182
|
+
},
|
|
183
|
+
"randomHex": [
|
|
184
|
+
"ff9c267263a44961a1d49a09c831ef35fa2d53e4d4cfc572d72f506478e61045",
|
|
185
|
+
"30a9d55f717dadf64d8c7914098ca75b68a80336c8aadd056772809b3e6c5346",
|
|
186
|
+
"11ae6ba1ae1e25f1493588462e919538089a63c28e68b7f5734bd0df2ba395e3"
|
|
187
|
+
]
|
|
188
|
+
},
|
|
189
|
+
"output": {
|
|
190
|
+
"evidence": {
|
|
191
|
+
"epoch": 2,
|
|
192
|
+
"removed": [
|
|
193
|
+
"298cf780bc6d1b93c4900b20c89a911286ee3488ab94f543d96d567c70eb9ed2"
|
|
194
|
+
],
|
|
195
|
+
"closed": false,
|
|
196
|
+
"commit": "3472885df839fc5d48d809476e28e36c458c3e4717e35c2ec0d69bbbdf94f896",
|
|
197
|
+
"members": [
|
|
198
|
+
"588c59130ea8b9d81e836d0c5a89daa6b888923357c1415359b48595b4937f53"
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
},
|
|
139
203
|
{
|
|
140
204
|
"name": "member-request",
|
|
141
205
|
"kind": "positive",
|