@forgesworn/fold-kit 0.3.0 → 0.5.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 CHANGED
@@ -118,6 +118,14 @@ the rest).
118
118
  conference room: the encrypted body carries `ends` and the event a NIP-40
119
119
  `expiration` tag, so relays drop it when the room ends; the decoder returns
120
120
  `endsAt` and refuses a tag that disagrees with the body
121
+ - Room relays: pass `relays` (one to eight distinct safe relay URLs in
122
+ canonical `normalizeURL` form, e.g. `wss://relay.example.com/`) and the
123
+ encrypted body carries them after `ends`. Every member's pool includes
124
+ them, so members never land on disjoint relays. The decoder returns
125
+ `relays` and refuses the whole envelope on a malformed list;
126
+ `requestPersistentRoomAdmission` keeps the relays of the newest signed
127
+ copy that names any. `isInvitationRelays`, `requireInvitationRelays` and
128
+ `MAX_INVITATION_RELAYS` are the rule
121
129
  - `withExpiration`, `isRoomEnds`, `requireRoomEnds`, `MAX_ROOM_ENDS_SECONDS` -
122
130
  the conference-room expiration rule: add the end as an `expiration` tag,
123
131
  keep an earlier one, lower a later one. `encodeInvitationRetirement` takes
@@ -134,6 +142,18 @@ the rest).
134
142
  catch-up for a device that missed a rekey
135
143
  - `hostRoomEpoch`, `requestRoomEpoch`, `EpochRefusedError` - the live
136
144
  request/grant desk
145
+ - Member epoch catch-up (`docs/member-epoch-catch-up.md`): any current
146
+ member can bring an admitted, non-removed device up to date when the
147
+ authority is offline, and the device checks the answer against the
148
+ authority's own signatures rather than trusting the member.
149
+ `encodeRekeyEvent({ commit: true })` writes the epoch commitment
150
+ (`epochCommitment`) into the rekey body; `hostMemberEpochDesk` answers
151
+ member requests (kinds 20471/20472, `MEMBER_EPOCH_KINDS`);
152
+ `requestRoomEpoch({ members: memberEpochSource(...) })` asks members as
153
+ well as the authority, and `requestMemberEpoch` asks members alone. Codecs:
154
+ `encodeMemberEpochRequest`, `decodeMemberEpochRequest`,
155
+ `encodeMemberEpochGrant`, `decodeMemberEpochGrant`, `readRekeyEvidence`,
156
+ `deriveMemberEpochRequestKey`. Vectors: `vectors/member-epoch-vectors.json`
137
157
  - `canonicalAdmins`, `signAdmins`, `verifyAdmins` - the authority's signed
138
158
  admin list
139
159
  - `canonicalChannels`, `signChannels`, `verifyChannels`, `CHANNEL_NAME`,
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The epoch commitment: what lets a member who is not the authority hand an
3
+ * epoch on, and the requester check it without trusting that member.
4
+ *
5
+ * A rekey event already commits to the secret of the epoch it LEAVES: its
6
+ * body is NIP-44 under that epoch's key, the authority signed the result,
7
+ * and NIP-44's HMAC verifies under no other key. What it does not commit
8
+ * to, in a form a third party can check, is the secret of the epoch it
9
+ * ENTERS - that rides only in the per-device seals, which nobody but the
10
+ * authority and the sealed device can open. This closes that one gap: an
11
+ * optional `commit` field in the (encrypted) rekey body, written by
12
+ * `encodeRekeyEvent` when asked, equal to
13
+ *
14
+ * HMAC-SHA256(key = secret, "kithmoot/v1/epoch-commit:" + roomId + ":" + epoch)
15
+ *
16
+ * as lower-case hex, `roomId` in lower-case hex and `epoch` in decimal. A
17
+ * PRF keyed by the secret, so the value reveals nothing about it, and bound
18
+ * to the room and the number so a commitment cannot be moved between them.
19
+ * It lives inside the body, not in a tag, so a relay still sees nothing
20
+ * but a number; and it is used nowhere else on the wire, so a removed
21
+ * member who reads the body learns nothing it could find traffic by.
22
+ *
23
+ * Its own module, with no imports from `epoch.ts`, so that `epoch.ts` and
24
+ * `member-epoch.ts` can both use it without an import cycle. See
25
+ * `docs/member-epoch-catch-up.md`.
26
+ */
27
+ export declare const EPOCH_COMMIT_PREFIX = "kithmoot/v1/epoch-commit:";
28
+ /** The commitment a rekey to `epoch` carries for `secret` in `roomId`. */
29
+ export declare function epochCommitment(roomId: string, epoch: number, secret: Uint8Array): string;
30
+ /** Every wire-format literal this module owns, frozen for `src/labels.test.ts`. */
31
+ export declare const EPOCH_COMMIT_LABELS: readonly ["kithmoot/v1/epoch-commit:"];
@@ -0,0 +1,46 @@
1
+ import { hmac } from '@noble/hashes/hmac';
2
+ import { sha256 } from '@noble/hashes/sha2';
3
+ import { bytesToHex } from '@noble/hashes/utils';
4
+ /**
5
+ * The epoch commitment: what lets a member who is not the authority hand an
6
+ * epoch on, and the requester check it without trusting that member.
7
+ *
8
+ * A rekey event already commits to the secret of the epoch it LEAVES: its
9
+ * body is NIP-44 under that epoch's key, the authority signed the result,
10
+ * and NIP-44's HMAC verifies under no other key. What it does not commit
11
+ * to, in a form a third party can check, is the secret of the epoch it
12
+ * ENTERS - that rides only in the per-device seals, which nobody but the
13
+ * authority and the sealed device can open. This closes that one gap: an
14
+ * optional `commit` field in the (encrypted) rekey body, written by
15
+ * `encodeRekeyEvent` when asked, equal to
16
+ *
17
+ * HMAC-SHA256(key = secret, "kithmoot/v1/epoch-commit:" + roomId + ":" + epoch)
18
+ *
19
+ * as lower-case hex, `roomId` in lower-case hex and `epoch` in decimal. A
20
+ * PRF keyed by the secret, so the value reveals nothing about it, and bound
21
+ * to the room and the number so a commitment cannot be moved between them.
22
+ * It lives inside the body, not in a tag, so a relay still sees nothing
23
+ * but a number; and it is used nowhere else on the wire, so a removed
24
+ * member who reads the body learns nothing it could find traffic by.
25
+ *
26
+ * Its own module, with no imports from `epoch.ts`, so that `epoch.ts` and
27
+ * `member-epoch.ts` can both use it without an import cycle. See
28
+ * `docs/member-epoch-catch-up.md`.
29
+ */
30
+ export const EPOCH_COMMIT_PREFIX = 'kithmoot/v1/epoch-commit:';
31
+ const HEX64 = /^[0-9a-f]{64}$/i;
32
+ /** The commitment a rekey to `epoch` carries for `secret` in `roomId`. */
33
+ export function epochCommitment(roomId, epoch, secret) {
34
+ if (!HEX64.test(roomId))
35
+ throw new Error('room id must be 32-byte hex');
36
+ if (!Number.isSafeInteger(epoch) || epoch < 1)
37
+ throw new Error('only an epoch after 0 has a commitment');
38
+ if (secret.length !== 32)
39
+ throw new Error('epoch secret must be 32 bytes');
40
+ const message = new TextEncoder().encode(`${EPOCH_COMMIT_PREFIX}${roomId.toLowerCase()}:${epoch}`);
41
+ return bytesToHex(hmac(sha256, secret, message));
42
+ }
43
+ /** Every wire-format literal this module owns, frozen for `src/labels.test.ts`. */
44
+ export const EPOCH_COMMIT_LABELS = [
45
+ "kithmoot/v1/epoch-commit:",
46
+ ];
package/dist/epoch.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type Event } from 'nostr-tools/pure';
2
2
  import type { RelayTransport } from './transport.js';
3
3
  import type { DeviceCredential, KindredProof, RoomPolicy } from './types.js';
4
+ import type { MemberEpochSource } from './member-epoch.js';
4
5
  /**
5
6
  * Room epochs: how a member is removed.
6
7
  *
@@ -103,6 +104,10 @@ export interface EncodeRekeyOptions {
103
104
  /** True when the room is being closed: nobody is kept, and the event
104
105
  * says so rather than leaving everybody to wonder. */
105
106
  closed?: boolean;
107
+ /** Write the epoch commitment into the body, so any current member can
108
+ * bring a device that missed this rekey up to date (see
109
+ * `member-epoch.ts`). Omitted, the event is byte-identical to before. */
110
+ commit?: boolean;
106
111
  now: number;
107
112
  /** A conference room's end, in unix seconds: the event carries it as a
108
113
  * NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
@@ -307,6 +312,9 @@ export interface RequestRoomEpochOptions {
307
312
  retryMs?: number;
308
313
  /** A conference room's end: the request carries it as an expiration. */
309
314
  expiresAt?: number;
315
+ /** Ask the room's current members too (`memberEpochSource`): the first
316
+ * answer that checks out, the authority's or a member's, settles it. */
317
+ members?: MemberEpochSource;
310
318
  }
311
319
  /** Thrown when the authority answered, and the answer was no. */
312
320
  export declare class EpochRefusedError extends Error {
package/dist/epoch.js CHANGED
@@ -13,6 +13,7 @@ import { verifyDeviceCredential } from './credential.js';
13
13
  import { evaluateAccess } from './access.js';
14
14
  import { verifyEventUncached } from './verify.js';
15
15
  import { withExpiration } from './expiration.js';
16
+ import { epochCommitment } from './epoch-commit.js';
16
17
  /**
17
18
  * Room epochs: how a member is removed.
18
19
  *
@@ -140,6 +141,7 @@ export function encodeRekeyEvent(opts) {
140
141
  removed,
141
142
  ...(opts.by !== undefined ? { by: requireHex32(opts.by, 'admin') } : {}),
142
143
  ...(opts.closed ? { closed: true } : {}),
144
+ ...(opts.commit ? { commit: epochCommitment(roomId, epoch, opts.next.secret) } : {}),
143
145
  keys,
144
146
  };
145
147
  return finalizeEvent({
@@ -520,6 +522,7 @@ export function requestRoomEpoch(opts) {
520
522
  let retry;
521
523
  let expiry;
522
524
  let unsub = () => { };
525
+ let stopMembers = () => { };
523
526
  const finish = (settle) => {
524
527
  if (settled)
525
528
  return;
@@ -529,6 +532,7 @@ export function requestRoomEpoch(opts) {
529
532
  if (expiry !== undefined)
530
533
  clearTimeout(expiry);
531
534
  unsub();
535
+ stopMembers();
532
536
  settle();
533
537
  };
534
538
  unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_GRANT], '#d': [roomId], '#p': [device] }], (event) => {
@@ -542,6 +546,11 @@ export function requestRoomEpoch(opts) {
542
546
  });
543
547
  if (settled)
544
548
  unsub();
549
+ if (opts.members && !settled) {
550
+ stopMembers = opts.members.start((grant) => finish(() => resolve(grant)));
551
+ if (settled)
552
+ stopMembers();
553
+ }
545
554
  const ask = () => {
546
555
  if (!settled)
547
556
  opts.transport.publish(request).catch(() => { });
package/dist/index.d.ts CHANGED
@@ -21,10 +21,14 @@ export type { RoomInvitation, RoomInvitationHost, InvitationDelegation, RoomInvi
21
21
  export { encodePersistentInvitation, decodePersistentInvitation, requestPersistentRoomAdmission, PERSISTENT_INVITATION_LABELS, } from './persistent-invitation.js';
22
22
  export type { PersistentRoomAdmission } from './persistent-invitation.js';
23
23
  export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
24
+ export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
24
25
  export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
25
26
  export type { RoomLink } from './link.js';
26
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
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_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
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';
31
+ export type { RekeyEvidence, EncodeMemberEpochRequestOptions, DecodeMemberEpochRequestOptions, MemberEpochRequest, EncodeMemberEpochGrantOptions, DecodeMemberEpochGrantOptions, MemberEpochGrant, HostMemberEpochDeskOptions, MemberEpochRequestOptions, MemberEpochSource, } from './member-epoch.js';
28
32
  export { deriveChannel, CHANNEL_ID_INFO, CHANNEL_KEY_INFO, MAX_CHANNEL_NAME_LENGTH, CHANNEL_LABELS } from './channel.js';
29
33
  export { LANES, LANE_MEANING, LANE_LABEL, LANE_GLYPH, isLane, laneOfRelayUrl, laneOfRelays, weakestLane, isDowngrade, } from './lane.js';
30
34
  export type { Lane } from './lane.js';
package/dist/index.js CHANGED
@@ -14,7 +14,10 @@ export { issueKindredProof, evaluateAccess, ACCESS_LABELS, } from './access.js';
14
14
  export { INVITATION_DELEGATION_TTL_SECONDS, MAX_INVITATION_DELEGATION_DEPTH, createRoomInvitation, roomInvitation, deriveInvitationId, encodeInvitationRequest, decodeInvitationRequest, verifyInvitationDelegation, encodeInvitationGrant, decodeRoomAdmissionGrant, decodeInvitationGrant, ROOM_ENDED_MESSAGE, encodeInvitationRetirement, decodeInvitationRetirement, decodeInvitationRetirementNotice, retirementError, hostRoomInvitation, requestRoomAdmissionCapability, requestRoomAdmission, INVITATION_LABELS, } from './invitation.js';
15
15
  export { encodePersistentInvitation, decodePersistentInvitation, requestPersistentRoomAdmission, PERSISTENT_INVITATION_LABELS, } from './persistent-invitation.js';
16
16
  export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
17
+ export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
17
18
  export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
18
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';
20
+ export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
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';
19
22
  export { deriveChannel, CHANNEL_ID_INFO, CHANNEL_KEY_INFO, MAX_CHANNEL_NAME_LENGTH, CHANNEL_LABELS } from './channel.js';
20
23
  export { LANES, LANE_MEANING, LANE_LABEL, LANE_GLYPH, isLane, laneOfRelayUrl, laneOfRelays, weakestLane, isDowngrade, } from './lane.js';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A room's own relays, carried in its signed group invitation (kind 1463).
3
+ *
4
+ * The relays a room is created on are its meeting place: every member's pool
5
+ * includes them, whatever else that member uses, so two members can never
6
+ * end up on disjoint relays. A link's relay hints are unsigned and drift as
7
+ * links are re-shared; the invitation's list is signed by the inviter, so a
8
+ * stale bookmark converges on it.
9
+ *
10
+ * The list is strict: one to `MAX_INVITATION_RELAYS` distinct URLs, each a
11
+ * safe relay URL already in canonical form (`normalizeURL` from
12
+ * `nostr-tools/utils`: lower-case host, no default port, no trailing slash
13
+ * except the root's, sorted query), with no credentials. Anything else
14
+ * refuses the whole envelope; a list is never silently trimmed.
15
+ */
16
+ /** At most this many room relays: the same bound as a link's relay hints. */
17
+ export declare const MAX_INVITATION_RELAYS = 8;
18
+ /** True for a list of room relays the invitation may carry. Never throws. */
19
+ export declare function isInvitationRelays(value: unknown): value is string[];
20
+ /** Throws unless `relays` is a list the invitation may carry; returns a copy. */
21
+ export declare function requireInvitationRelays(relays: readonly string[]): string[];
@@ -0,0 +1,52 @@
1
+ import { normalizeURL } from 'nostr-tools/utils';
2
+ import { isSafeRelayUrl, MAX_RELAY_HINTS } from './network-hints.js';
3
+ /**
4
+ * A room's own relays, carried in its signed group invitation (kind 1463).
5
+ *
6
+ * The relays a room is created on are its meeting place: every member's pool
7
+ * includes them, whatever else that member uses, so two members can never
8
+ * end up on disjoint relays. A link's relay hints are unsigned and drift as
9
+ * links are re-shared; the invitation's list is signed by the inviter, so a
10
+ * stale bookmark converges on it.
11
+ *
12
+ * The list is strict: one to `MAX_INVITATION_RELAYS` distinct URLs, each a
13
+ * safe relay URL already in canonical form (`normalizeURL` from
14
+ * `nostr-tools/utils`: lower-case host, no default port, no trailing slash
15
+ * except the root's, sorted query), with no credentials. Anything else
16
+ * refuses the whole envelope; a list is never silently trimmed.
17
+ */
18
+ /** At most this many room relays: the same bound as a link's relay hints. */
19
+ export const MAX_INVITATION_RELAYS = MAX_RELAY_HINTS;
20
+ function isCanonicalRelayUrl(value) {
21
+ if (typeof value !== 'string' || !isSafeRelayUrl(value))
22
+ return false;
23
+ try {
24
+ const parsed = new URL(value);
25
+ if (parsed.username || parsed.password)
26
+ return false;
27
+ return normalizeURL(value) === value;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ /** True for a list of room relays the invitation may carry. Never throws. */
34
+ export function isInvitationRelays(value) {
35
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_INVITATION_RELAYS)
36
+ return false;
37
+ return value.every(isCanonicalRelayUrl) && new Set(value).size === value.length;
38
+ }
39
+ /** Throws unless `relays` is a list the invitation may carry; returns a copy. */
40
+ export function requireInvitationRelays(relays) {
41
+ if (!Array.isArray(relays) || relays.length === 0)
42
+ throw new Error('a room relay list needs at least one relay');
43
+ if (relays.length > MAX_INVITATION_RELAYS)
44
+ throw new Error(`a room can list at most ${MAX_INVITATION_RELAYS} relays`);
45
+ for (const url of relays) {
46
+ if (!isCanonicalRelayUrl(url))
47
+ throw new Error(`room relays must be wss:// URLs (ws:// only on localhost) in canonical form, without credentials: ${String(url)}`);
48
+ }
49
+ if (new Set(relays).size !== relays.length)
50
+ throw new Error('room relays must not repeat');
51
+ return [...relays];
52
+ }
@@ -0,0 +1,273 @@
1
+ import { type Event } from 'nostr-tools/pure';
2
+ import { type EpochKeys, type EpochRefusal, type RoomEpoch } from './epoch.js';
3
+ import type { RelayTransport } from './transport.js';
4
+ import type { DeviceCredential, KindredProof, RoomPolicy } from './types.js';
5
+ /**
6
+ * Member-to-member epoch catch-up: any current member can bring an
7
+ * admitted, non-removed member up to date.
8
+ *
9
+ * `hostRoomEpoch` answers an epoch request only where the authority's key
10
+ * is, and the request is ephemeral, so a device that missed a rekey - a
11
+ * phone that came back with a new device key, which no stored rekey seals
12
+ * to - recovers only while the authority's device is online with the room
13
+ * open. Every other member at the current epoch holds the secret and, until
14
+ * now, could not pass it on in a form the requester could check.
15
+ *
16
+ * It can now, because the requester does not trust the member at all. The
17
+ * grant carries the authority-signed rekey events from the requester's
18
+ * epoch to the current one, and the secret of every epoch in between:
19
+ *
20
+ * - each rekey's signature is the authority's (`peekRekeyEvent`);
21
+ * - each rekey decrypts under the key of the epoch before it, starting from
22
+ * the requester's own - and NIP-44's MAC verifies under no other key, so
23
+ * a decrypting rekey j+1 proves the secret offered for epoch j is the one
24
+ * the authority encrypted under;
25
+ * - the last epoch, which has no successor to vouch for it, is checked
26
+ * against the commitment the authority wrote into its rekey body
27
+ * (`epochCommitment`, `encodeRekeyEvent({ commit: true })`). A last rekey
28
+ * with no commitment - every rekey written before this existed - is not
29
+ * accepted from a member: that epoch stays authority-only.
30
+ *
31
+ * The removed and closed facts come from the same authority-signed bodies,
32
+ * so a member can neither hide a removal from the requester nor invent
33
+ * one. Answerers refuse the removed and a closed room by not answering at
34
+ * all; only the authority's refusal is something a requester can believe.
35
+ * See `docs/member-epoch-catch-up.md` for the threat model.
36
+ */
37
+ /** The member path's two kinds. Ephemeral, like 20468/20469: a live
38
+ * handshake, never a record. */
39
+ export declare const MEMBER_EPOCH_KINDS: {
40
+ /** A device behind the room asking any current member for the epochs it
41
+ * missed. Encrypted under a key from the epoch-0 room key, so any
42
+ * admitted device can read it and a relay cannot. */
43
+ readonly REQUEST: 20471;
44
+ /** A member's answer, signed by a one-time key made for that grant and
45
+ * sealed to the asking device: secrets plus the authority-signed rekeys
46
+ * that prove them. */
47
+ readonly GRANT: 20472;
48
+ };
49
+ /** HKDF info for the key a member epoch request's body is sealed under. */
50
+ export declare const MEMBER_EPOCH_REQUEST_KEY_INFO = "kithmoot/v1/member-epoch-request-key";
51
+ /** The most epochs one member grant will carry. A device further behind
52
+ * than this asks the authority. */
53
+ export declare const MAX_MEMBER_EPOCH_CHAIN = 32;
54
+ /** The key a member epoch request is sealed under: the epoch-0 room key,
55
+ * expanded under its own info string. */
56
+ export declare function deriveMemberEpochRequestKey(roomKey: Uint8Array): Uint8Array;
57
+ /** What a rekey body says, read with the key of the epoch it leaves. */
58
+ export interface RekeyEvidence {
59
+ epoch: number;
60
+ removed: string[];
61
+ closed: boolean;
62
+ /** The epoch commitment, when the authority wrote one. */
63
+ commit?: string;
64
+ }
65
+ /**
66
+ * Read an authority-signed rekey with the key of the epoch it leaves,
67
+ * without needing a seal of one's own. Null for anything that does not
68
+ * check out: not the authority, not this room, not epoch `previous.epoch
69
+ * + 1`, or not encrypted under `previous.key`. What both sides of a member
70
+ * grant use to check the chain.
71
+ */
72
+ export declare function readRekeyEvidence(event: Event, opts: {
73
+ roomId: string;
74
+ authority: string;
75
+ previous: EpochKeys;
76
+ }): RekeyEvidence | null;
77
+ export interface EncodeMemberEpochRequestOptions {
78
+ roomId: string;
79
+ /** The room's authority: bound into the admission proof. */
80
+ authority: string;
81
+ deviceSk: Uint8Array;
82
+ /** The epoch-0 room key: seals the body and makes the admission proof. */
83
+ roomKey: Uint8Array;
84
+ credential: DeviceCredential;
85
+ proof?: KindredProof;
86
+ /** The epoch this device is at. */
87
+ have: number;
88
+ now: number;
89
+ expiresAt?: number;
90
+ }
91
+ /** Ask any current member for the epochs after `have`. */
92
+ export declare function encodeMemberEpochRequest(opts: EncodeMemberEpochRequestOptions): Event;
93
+ export interface DecodeMemberEpochRequestOptions {
94
+ roomId: string;
95
+ authority: string;
96
+ roomKey: Uint8Array;
97
+ now: number;
98
+ policy?: RoomPolicy;
99
+ maxAgeSeconds?: number;
100
+ }
101
+ export interface MemberEpochRequest {
102
+ device: string;
103
+ participant: string;
104
+ request: string;
105
+ have: number;
106
+ }
107
+ /** Null for anything malformed, stale, from a device that cannot prove
108
+ * which participant it speaks for, or that cannot prove admission. */
109
+ export declare function decodeMemberEpochRequest(event: Event, opts: DecodeMemberEpochRequestOptions): MemberEpochRequest | null;
110
+ export interface EncodeMemberEpochGrantOptions {
111
+ roomId: string;
112
+ /** The asking device. */
113
+ device: string;
114
+ request: string;
115
+ /** Epochs have+1 .. current, in order. */
116
+ epochs: RoomEpoch[];
117
+ /** The authority's rekey event for each of `epochs`, in the same order. */
118
+ rekeys: Event[];
119
+ now: number;
120
+ expiresAt?: number;
121
+ }
122
+ /**
123
+ * Answer one member epoch request with the epochs it missed.
124
+ *
125
+ * The grant is signed, and sealed to the asking device, by a fresh key made
126
+ * for it alone and then dropped, not by the answering member's device key.
127
+ * Nothing about a grant is trusted on its signer (the authority's
128
+ * signatures are what the requester checks), so the member's key would add
129
+ * nothing but a public line from that device to the room id in the `d` tag.
130
+ */
131
+ export declare function encodeMemberEpochGrant(opts: EncodeMemberEpochGrantOptions): Event;
132
+ export interface DecodeMemberEpochGrantOptions {
133
+ roomId: string;
134
+ authority: string;
135
+ /** The asking device's key. */
136
+ deviceSk: Uint8Array;
137
+ /** The ids of this device's own outstanding member requests. */
138
+ requests: ReadonlySet<string>;
139
+ /** Where this device is: the chain starts from this epoch's key. */
140
+ current: EpochKeys;
141
+ /** The participant this device speaks for: a chain that removes it is
142
+ * refused. */
143
+ participant: string;
144
+ /** The cumulative removed set this device already knows. Read once per
145
+ * call. */
146
+ removed?: Iterable<string>;
147
+ /** The highest epoch this device has seen a valid rekey for. A grant
148
+ * that stops short of it is refused, so a member removed at that epoch
149
+ * cannot hold the requester one epoch back on a key it still has. */
150
+ expected?: number;
151
+ now: number;
152
+ maxAgeSeconds?: number;
153
+ }
154
+ /** A member grant, verified. */
155
+ export interface MemberEpochGrant {
156
+ epoch: RoomEpoch;
157
+ /** Cumulative: `opts.removed` plus every removal in the chain. */
158
+ removed: string[];
159
+ }
160
+ /**
161
+ * Verify a member's answer to one of this device's member requests. Null
162
+ * for anything that does not check out - see the module comment and
163
+ * `docs/member-epoch-catch-up.md` "Verification rules" for each check.
164
+ */
165
+ export declare function decodeMemberEpochGrant(event: Event, opts: DecodeMemberEpochGrantOptions): MemberEpochGrant | null;
166
+ export interface HostMemberEpochDeskOptions {
167
+ transport: RelayTransport;
168
+ roomId: string;
169
+ authority: string;
170
+ /** This member's device key: the desk ignores requests from it. Grants
171
+ * are signed by a one-time key each (see `encodeMemberEpochGrant`). */
172
+ deviceSk: Uint8Array;
173
+ /** The epoch-0 room key: opens requests and checks admission proofs. */
174
+ roomKey: Uint8Array;
175
+ /** Where this device is now, or undefined while it is not in step (behind,
176
+ * removed, leaving). Asked on every request. */
177
+ current: () => RoomEpoch | undefined;
178
+ /** The secret of an earlier epoch this device still holds, for a
179
+ * requester more than one epoch behind. Optional: without it this desk
180
+ * serves only requesters exactly one epoch behind. */
181
+ secretAt?: (epoch: number) => Uint8Array | undefined;
182
+ /** The authority's rekey event into `epoch`, as this device stored it. */
183
+ rekeyAt: (epoch: number) => Event | undefined;
184
+ /** The cumulative removed set, as the authority's rekeys told this device. */
185
+ removed: () => ReadonlySet<string>;
186
+ /** True once the room has been closed. */
187
+ closed?: () => boolean;
188
+ policy?: RoomPolicy;
189
+ now?: () => number;
190
+ /** Upper bound of the random wait before answering, so several members do
191
+ * not all answer at once. 0 answers at once. Default 1500. */
192
+ jitterMs?: number;
193
+ /** For tests: a source of numbers in [0, 1). Default `Math.random`. */
194
+ random?: () => number;
195
+ /** The largest serialised grant this desk will publish. Default 60000:
196
+ * under the 64 KiB many relays enforce. A longer chain is left to the
197
+ * authority. */
198
+ maxGrantBytes?: number;
199
+ onGranted?: (request: MemberEpochRequest) => void;
200
+ /** Called when this desk declines a removed participant or a closed room.
201
+ * Nothing is published either way: a member's refusal is not something a
202
+ * requester could believe, so it is not sent. */
203
+ onRefused?: (request: MemberEpochRequest, why: EpochRefusal) => void;
204
+ expiresAt?: number;
205
+ }
206
+ /**
207
+ * Answer member epoch requests for as long as the handle is open: what any
208
+ * member at the current epoch runs beside its session. It answers only an
209
+ * admitted, credentialled device that is not removed, in a room that is not
210
+ * closed, and only when it can hand over the whole chain from the
211
+ * requester's epoch to its own with the authority's rekeys to prove it.
212
+ *
213
+ * Anti-amplification: each answer waits a random `[0, jitterMs)`, and is
214
+ * dropped if another member's grant to the same device appears meanwhile.
215
+ * It is dropped at most once per requesting device: the requester asks again
216
+ * with a fresh request if what it got did not verify, and the second time
217
+ * every desk answers, so a stranger posting junk grants costs one round.
218
+ */
219
+ export declare function hostMemberEpochDesk(opts: HostMemberEpochDeskOptions): {
220
+ close(): void;
221
+ };
222
+ export interface MemberEpochRequestOptions {
223
+ transport: RelayTransport;
224
+ roomId: string;
225
+ authority: string;
226
+ deviceSk: Uint8Array;
227
+ /** The epoch-0 room key. */
228
+ roomKey: Uint8Array;
229
+ credential: DeviceCredential;
230
+ proof?: KindredProof;
231
+ /** Where this device is now. Asked each time a request goes out. */
232
+ current: EpochKeys | (() => EpochKeys);
233
+ /** The cumulative removed set this device already knows. A function is
234
+ * asked on every grant; anything else is read once, when the source is
235
+ * made, so a generator or other one-shot iterable is not used up by the
236
+ * first grant and seen as empty by the next. */
237
+ removed?: Iterable<string> | (() => Iterable<string>);
238
+ /** The highest epoch a valid rekey has been seen for: see
239
+ * `DecodeMemberEpochGrantOptions.expected`. The source also watches the
240
+ * room's rekeys on `transport` itself and floors every grant at the
241
+ * newest authority-signed one it sees, so this is only for what the
242
+ * caller learnt elsewhere. */
243
+ expected?: number | (() => number | undefined);
244
+ now?: () => number;
245
+ /** How often a fresh request goes out. Default 4000 ms. */
246
+ retryMs?: number;
247
+ expiresAt?: number;
248
+ }
249
+ /** What `requestRoomEpoch({ members })` takes: a second source of answers
250
+ * that starts when the request does and stops when it settles. */
251
+ export interface MemberEpochSource {
252
+ start(onGrant: (grant: MemberEpochGrant) => void): () => void;
253
+ }
254
+ /**
255
+ * The member side of a catch-up, for `requestRoomEpoch({ members })`. Each
256
+ * round publishes a fresh request (a new id, so a desk that stood down once
257
+ * answers it), and the first grant that verifies is handed to `onGrant`.
258
+ *
259
+ * It subscribes to the room's rekeys (kind 1462) before it asks, and refuses
260
+ * any grant that stops short of the newest one the authority signed: rule 3
261
+ * of `docs/member-epoch-catch-up.md`, enforced here rather than left to the
262
+ * caller, because a member removed at epoch E still holds E-1 and every
263
+ * rekey up to it, and would otherwise hold the requester on a key it shares.
264
+ */
265
+ export declare function memberEpochSource(opts: MemberEpochRequestOptions): MemberEpochSource;
266
+ /** Ask the room's current members alone (no authority) for the epochs this
267
+ * device missed. Rejects with a plain error when no verifiable answer comes
268
+ * inside the timeout. Most callers want `requestRoomEpoch({ members })`. */
269
+ export declare function requestMemberEpoch(opts: MemberEpochRequestOptions & {
270
+ timeoutMs?: number;
271
+ }): Promise<MemberEpochGrant>;
272
+ /** Every wire-format literal this module owns, frozen for `src/labels.test.ts`. */
273
+ export declare const MEMBER_EPOCH_LABELS: readonly ["kithmoot/v1/member-epoch-request-key"];