@forgesworn/fold-kit 0.0.0-seed.0 → 0.1.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.
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Display names.
3
+ *
4
+ * A name in this protocol is **self-asserted**: whoever holds a participant
5
+ * key types whatever they like, and nothing anywhere checks it. That is the
6
+ * point - joining a room should cost a name and nothing else - but it means
7
+ * a name is attacker-controlled text arriving off a relay, and every reader
8
+ * has to treat it as such.
9
+ *
10
+ * Two rules follow, and they live in different places:
11
+ *
12
+ * 1. **Sanitise it.** Everything below. A name may not carry characters
13
+ * that let it take a second row, hide part of itself, or reverse the
14
+ * direction the rest of the line renders in, and it may not be long
15
+ * enough to push everything else off the row.
16
+ *
17
+ * 2. **Never render it alone, and never as HTML.** Both are the caller's
18
+ * job, because both are about what a name sits next to. A short pubkey
19
+ * always renders beside it, so two people called "Robin" stay
20
+ * distinguishable and an impersonation is visible; and it goes in via
21
+ * `textContent`, never `innerHTML`, so markup in a name stays text.
22
+ */
23
+ /**
24
+ * How long a name may be, in characters.
25
+ *
26
+ * Counted in code points rather than UTF-16 units, so one emoji costs one -
27
+ * a cap in UTF-16 units would let an astral-plane name be twice as wide as
28
+ * an ASCII one, and could cut a surrogate pair in half.
29
+ */
30
+ export const MAX_DISPLAY_NAME_LENGTH = 32;
31
+ /**
32
+ * Every Unicode "other" character: controls (Cc), format characters (Cf),
33
+ * surrogates (Cs), private use (Co) and unassigned code points (Cn).
34
+ *
35
+ * That single class covers the whole family of tricks a name is used for:
36
+ * a newline taking a second row, a bidirectional override (U+202E and
37
+ * friends) making the rest of the line render backwards, a zero-width
38
+ * space hiding the difference between two names, a byte-order mark padding
39
+ * one invisibly. Naming the class rather than listing the code points is
40
+ * deliberate: the list grows with Unicode, and a filter that has to be
41
+ * updated to stay correct is a filter that will one day be out of date.
42
+ *
43
+ * It also removes U+200D ZERO WIDTH JOINER, so a joined emoji sequence
44
+ * comes apart into its parts. That is a real cost, accepted knowingly: an
45
+ * invisible character that changes what a name looks like is exactly the
46
+ * thing being removed, and there is no way to keep one and not the other.
47
+ */
48
+ const INVISIBLE = /\p{C}/gu;
49
+ /** Any run of whitespace, including the Unicode ones. */
50
+ const WHITESPACE = /\s+/gu;
51
+ /**
52
+ * Make a name safe to put next to somebody else's, or return undefined if
53
+ * there is nothing left worth showing.
54
+ *
55
+ * Takes `unknown` because this runs on JSON off a relay as well as on a
56
+ * field somebody typed, and anything at all can arrive in either.
57
+ */
58
+ export function sanitiseDisplayName(raw) {
59
+ if (typeof raw !== 'string')
60
+ return undefined;
61
+ // Whitespace first, invisibles second, and the order matters: a newline
62
+ // is itself a control character, so stripping controls first would turn
63
+ // "Robin\nadmin" into one word rather than two - hiding the smuggled
64
+ // line break instead of defusing it.
65
+ const collapsed = raw.replace(WHITESPACE, ' ').replace(INVISIBLE, '').replace(WHITESPACE, ' ').trim();
66
+ if (!collapsed)
67
+ return undefined;
68
+ // Split into code points before slicing: `String.prototype.slice` counts
69
+ // UTF-16 units and would cut a surrogate pair in half, leaving a lone
70
+ // surrogate on the wire.
71
+ const characters = [...collapsed];
72
+ if (characters.length <= MAX_DISPLAY_NAME_LENGTH)
73
+ return collapsed;
74
+ // Re-trimmed, because the cap can land mid-word and leave a trailing
75
+ // space that would then render as a name ending in nothing.
76
+ const capped = characters.slice(0, MAX_DISPLAY_NAME_LENGTH).join('').trim();
77
+ return capped || undefined;
78
+ }
@@ -0,0 +1,387 @@
1
+ import { type Event } from 'nostr-tools/pure';
2
+ import type { RelayTransport } from './transport.js';
3
+ import type { DeviceCredential, KindredProof, RoomPolicy } from './types.js';
4
+ /**
5
+ * Room epochs: how a member is removed.
6
+ *
7
+ * A room's traffic key is derived from its secret, and everybody who was
8
+ * ever admitted holds it. Link rotation retires the rendezvous and nothing
9
+ * else: a departed collaborator, or a leaked agent key, reads the room for
10
+ * as long as the room lives. The only thing that ends that is a new key
11
+ * that the departed party is not given, and that is an epoch.
12
+ *
13
+ * Epoch 0 is the room as it has always been: the id and key `deriveRoom`
14
+ * produces from the secret in the link, byte for byte. Every later epoch is
15
+ * a fresh 32-byte secret from which its own id and key are derived, under
16
+ * info strings that name the epoch number. The roster, the chat and its
17
+ * channels, the descriptor and the media keys all move to the new epoch's
18
+ * id and key together; the *room id* - what a credential binds to, what a
19
+ * signal names, what a forwarder is given - does not move, because it is
20
+ * the room's identity rather than its key.
21
+ *
22
+ * The move is announced by a rekey event: durable, addressed by the public
23
+ * room id, numbered in a tag, signed by the room's authority (the root
24
+ * inviter pinned in the link), and carrying the new secret sealed to each
25
+ * remaining device. Its body is encrypted to the epoch being left, so a
26
+ * member at that epoch reads it and moves, and a relay reads nothing but a
27
+ * number. A member that missed one asks the authority for the current
28
+ * epoch, proving who it is with its device credential and that it was
29
+ * admitted with a proof under the room key, and the authority answers
30
+ * everybody except the removed.
31
+ *
32
+ * The admission proof is what keeps the desk from being a back door. The
33
+ * room id and the authority's pubkey are public: both ride in the clear on
34
+ * every rekey event. A device credential is self-issued by whichever
35
+ * participant key signs it. So a request carrying only a credential proves
36
+ * nothing a stranger reading the relay could not produce, and in an open
37
+ * room the desk would hand that stranger the current epoch's secret, which
38
+ * opens the roster and the chat. The epoch-0 room key is the one thing the
39
+ * link hands out and nothing else does, so a MAC under a key derived from
40
+ * it is proof of having been admitted, and the desk demands one.
41
+ *
42
+ * Copies are sealed to the *device* pubkey, not the participant's, because
43
+ * the device key is the one every session holds in memory: a participant
44
+ * key may live in a browser extension or a bunker that signs and does
45
+ * nothing else, and the identity surface is deliberately a pubkey and one
46
+ * `signEvent` (see `identity.ts`). The participant is what a rekey names -
47
+ * the removed set, the refusal - and the device credential is how a device
48
+ * proves which participant it speaks for when it asks.
49
+ */
50
+ export declare const EPOCH_ID_INFO = "kithmoot/v1/epoch-id";
51
+ export declare const EPOCH_KEY_INFO = "kithmoot/v1/epoch-key";
52
+ /** A room that has been rekeyed more times than this has a problem that is
53
+ * not this module's to solve. Bounds the tag a client will parse. */
54
+ export declare const MAX_EPOCH = 1000000;
55
+ /**
56
+ * HKDF info string for the key an epoch request's admission proof is made
57
+ * under. Its own domain, like the media key's: a proof key that was the
58
+ * room key would put the room key itself into a MAC on every request, and
59
+ * a key that is only the epoch-request key opens nothing else if it leaks.
60
+ */
61
+ export declare const EPOCH_REQUEST_KEY_INFO = "kithmoot/v1/epoch-request-key";
62
+ /** One epoch of a room. Epoch 0's secret is the room secret itself. */
63
+ export interface RoomEpoch {
64
+ epoch: number;
65
+ secret: Uint8Array;
66
+ }
67
+ /** What the roster, chat and descriptor ride under in one epoch. */
68
+ export interface EpochKeys {
69
+ epoch: number;
70
+ /** The `d` tag root. The room id in epoch 0. */
71
+ id: string;
72
+ /** The cipher key. The room key in epoch 0. */
73
+ key: Uint8Array;
74
+ }
75
+ /** A fresh secret for the next epoch. */
76
+ export declare function generateEpochSecret(): Uint8Array;
77
+ /**
78
+ * The id and key a room's traffic rides under in `epoch`.
79
+ *
80
+ * Epoch 0 is exactly `deriveRoom(secret)`, so a room that has never been
81
+ * rekeyed is byte-identical on the wire to one from before epochs existed.
82
+ * Every later epoch expands its own secret under info strings that carry
83
+ * the epoch number, so the same secret at two numbers - which a correct
84
+ * authority never produces - would still derive two different keys.
85
+ */
86
+ export declare function deriveEpoch(epoch: RoomEpoch): EpochKeys;
87
+ export interface EncodeRekeyOptions {
88
+ roomId: string;
89
+ /** The room's authority: the root inviter key. Only it may rekey. */
90
+ authoritySk: Uint8Array;
91
+ /** The epoch being left. Its key seals the body. */
92
+ current: EpochKeys;
93
+ /** The epoch being entered. Must be `current.epoch + 1`. */
94
+ next: RoomEpoch;
95
+ /** Devices to seal the new secret for. Everybody still in the room,
96
+ * except the removed participants' devices. */
97
+ recipients: string[];
98
+ /** Participants removed at this step. Empty on a rekey that only turns
99
+ * the key over. */
100
+ removed: string[];
101
+ /** The admin who asked, when one did. */
102
+ by?: string;
103
+ /** True when the room is being closed: nobody is kept, and the event
104
+ * says so rather than leaving everybody to wonder. */
105
+ closed?: boolean;
106
+ now: number;
107
+ }
108
+ /** Announce the next epoch. See the module comment for what this is. */
109
+ export declare function encodeRekeyEvent(opts: EncodeRekeyOptions): Event;
110
+ /** What a rekey says, once read. */
111
+ export interface RekeyNotice {
112
+ epoch: number;
113
+ removed: string[];
114
+ by?: string;
115
+ closed: boolean;
116
+ /** The new secret, when a copy was sealed for this device. Absent for a
117
+ * device that was removed, or that was not in the room when the
118
+ * authority rekeyed. */
119
+ secret?: Uint8Array;
120
+ /** True for a current-state grant, whose removed list is cumulative.
121
+ * It synchronises this session; it does not describe a new removal. */
122
+ catchUp?: true;
123
+ /** The authority's clock when it rekeyed, or receipt time for catch-up. */
124
+ at: number;
125
+ }
126
+ export interface PeekRekeyOptions {
127
+ roomId: string;
128
+ /** The authority pubkey: the root inviter pinned in the link. */
129
+ authority: string;
130
+ }
131
+ /**
132
+ * The epoch a rekey announces, checking everything that does not need a
133
+ * key: kind, room, signer, signature and the epoch tag. What a client uses
134
+ * to learn that the room is ahead of it before it can read why.
135
+ */
136
+ export declare function peekRekeyEvent(event: Event, opts: PeekRekeyOptions): number | null;
137
+ export interface DecodeRekeyOptions extends PeekRekeyOptions {
138
+ /** The epoch this device is at. A rekey is readable only from the epoch
139
+ * it leaves, and only accepted when it moves forward. */
140
+ current: EpochKeys;
141
+ /** This device's key, to open its own copy. */
142
+ deviceSk: Uint8Array;
143
+ }
144
+ /**
145
+ * Read a rekey. Null for anything that does not check out, and for a rekey
146
+ * this device cannot read from where it stands: one for an epoch it is
147
+ * already past (a replay), or one sealed to an epoch it is not at.
148
+ */
149
+ export declare function decodeRekeyEvent(event: Event, opts: DecodeRekeyOptions): RekeyNotice | null;
150
+ /**
151
+ * The key an epoch request's admission proof is computed under: the epoch-0
152
+ * room key, expanded under its own info string. Taken from the room key
153
+ * rather than the room secret for the same reason the media key is: a
154
+ * joined client holds the room key and need not keep the capability.
155
+ */
156
+ export declare function deriveEpochRequestKey(roomKey: Uint8Array): Uint8Array;
157
+ export interface EpochRequestAdmissionOptions {
158
+ /** The epoch-0 room key: `deriveRoom(secret).roomKey`, never a later epoch's. */
159
+ roomKey: Uint8Array;
160
+ roomId: string;
161
+ authority: string;
162
+ device: string;
163
+ /** The request event's `created_at`. */
164
+ createdAt: number;
165
+ }
166
+ /**
167
+ * Prove, inside an epoch request, that the asking device was admitted to
168
+ * the room.
169
+ *
170
+ * `HMAC-SHA256(deriveEpochRequestKey(roomKey), "kithmoot/v1/epoch-request:" +
171
+ * roomId + ":" + authority + ":" + device + ":" + createdAt)`, as hex, with
172
+ * the three identifiers in lower-case hex. Bound to the device and the
173
+ * moment so that a proof lifted from one request is no use in another,
174
+ * though the body it rides in is already sealed to the authority. Every
175
+ * admitted device holds the epoch-0 room key, including one that has fallen
176
+ * several epochs behind, which is exactly who asks; a removed member holds
177
+ * it too, and is refused by name, not by this. What this refuses is the
178
+ * stranger: somebody with the room id and the authority's pubkey off a
179
+ * public rekey event, and no link.
180
+ */
181
+ export declare function epochRequestAdmission(opts: EpochRequestAdmissionOptions): string;
182
+ export interface EncodeEpochRequestOptions {
183
+ roomId: string;
184
+ authority: string;
185
+ deviceSk: Uint8Array;
186
+ /** The epoch-0 room key, which proves this device was admitted. */
187
+ roomKey: Uint8Array;
188
+ /** Proves which participant this device speaks for. */
189
+ credential: DeviceCredential;
190
+ proof?: KindredProof;
191
+ now: number;
192
+ }
193
+ /** Ask the authority which epoch the room is at, and for its secret. */
194
+ export declare function encodeEpochRequest(opts: EncodeEpochRequestOptions): Event;
195
+ export interface DecodeEpochRequestOptions {
196
+ roomId: string;
197
+ authoritySk: Uint8Array;
198
+ /** The epoch-0 room key the desk checks admission proofs against. */
199
+ roomKey: Uint8Array;
200
+ now: number;
201
+ policy?: RoomPolicy;
202
+ /** Participants whose already-signed device credential may bridge a
203
+ * pre-admission-proof client. Keep this narrow: a missing proof is never
204
+ * accepted for an ordinary link holder. */
205
+ legacyParticipants?: ReadonlySet<string>;
206
+ maxAgeSeconds?: number;
207
+ }
208
+ export interface EpochRequest {
209
+ device: string;
210
+ participant: string;
211
+ request: string;
212
+ }
213
+ /** Null for anything malformed, stale, misaddressed, from a device that
214
+ * cannot prove which participant it speaks for in this room, or from one
215
+ * that cannot prove it was admitted to the room at all. */
216
+ export declare function decodeEpochRequest(event: Event, opts: DecodeEpochRequestOptions): EpochRequest | null;
217
+ /** Why the authority would not hand an epoch over. */
218
+ export type EpochRefusal = 'removed' | 'closed';
219
+ export interface EncodeEpochGrantOptions {
220
+ roomId: string;
221
+ authoritySk: Uint8Array;
222
+ device: string;
223
+ request: string;
224
+ now: number;
225
+ /** The current epoch. Omitted when refusing. */
226
+ epoch?: RoomEpoch;
227
+ removed?: string[];
228
+ refused?: EpochRefusal;
229
+ }
230
+ /** Answer one request: the current epoch sealed to the asking device, or a
231
+ * refusal it alone can read. */
232
+ export declare function encodeEpochGrant(opts: EncodeEpochGrantOptions): Event;
233
+ export interface DecodeEpochGrantOptions {
234
+ roomId: string;
235
+ authority: string;
236
+ deviceSk: Uint8Array;
237
+ request: string;
238
+ now: number;
239
+ maxAgeSeconds?: number;
240
+ }
241
+ export type EpochGrant = {
242
+ epoch: RoomEpoch | {
243
+ epoch: 0;
244
+ };
245
+ removed: string[];
246
+ refused?: undefined;
247
+ } | {
248
+ refused: EpochRefusal;
249
+ };
250
+ /** Read the authority's answer to this device's own request. Null for
251
+ * anything else. Note a grant for epoch 0 carries no secret: the room has
252
+ * never been rekeyed, and the requester already holds what it needs. */
253
+ export declare function decodeEpochGrant(event: Event, opts: DecodeEpochGrantOptions): EpochGrant | null;
254
+ export interface HostRoomEpochOptions {
255
+ transport: RelayTransport;
256
+ roomId: string;
257
+ authoritySk: Uint8Array;
258
+ /** The epoch-0 room key: what a request has to prove it holds. */
259
+ roomKey: Uint8Array;
260
+ /** Where the room is now. Asked on every request, because it moves. */
261
+ current: () => RoomEpoch;
262
+ /** Who has been removed. Asked on every request, for the same reason. */
263
+ removed: () => ReadonlySet<string>;
264
+ /** True once the room has been closed: every request is refused. */
265
+ closed?: () => boolean;
266
+ policy?: RoomPolicy;
267
+ legacyParticipants?: ReadonlySet<string>;
268
+ now?: () => number;
269
+ onGranted?: (request: EpochRequest) => void;
270
+ onRefused?: (request: EpochRequest, why: EpochRefusal) => void;
271
+ }
272
+ /**
273
+ * Answer epoch requests for as long as the handle is open. What a keeper
274
+ * runs beside its invitation desk: a member arriving, or returning, after
275
+ * a rekey is handed the current epoch on proof of who it is and that it was
276
+ * admitted, and a removed participant is told no. This is where removal is
277
+ * enforced against the link: the link still admits its holder to the room's
278
+ * *secret*, which opens epoch 0 and nothing after it. A request with no
279
+ * admission proof, or one made under some other key, is not answered at
280
+ * all: a stranger learns nothing, not even that a desk is here.
281
+ */
282
+ export declare function hostRoomEpoch(opts: HostRoomEpochOptions): {
283
+ close(): void;
284
+ };
285
+ export interface RequestRoomEpochOptions {
286
+ transport: RelayTransport;
287
+ roomId: string;
288
+ authority: string;
289
+ deviceSk: Uint8Array;
290
+ /** The epoch-0 room key, which proves this device was admitted. */
291
+ roomKey: Uint8Array;
292
+ credential: DeviceCredential;
293
+ proof?: KindredProof;
294
+ now?: () => number;
295
+ timeoutMs?: number;
296
+ retryMs?: number;
297
+ }
298
+ /** Thrown when the authority answered, and the answer was no. */
299
+ export declare class EpochRefusedError extends Error {
300
+ readonly refused: EpochRefusal;
301
+ constructor(refused: EpochRefusal);
302
+ }
303
+ /** Ask the authority where the room is, and wait for the answer. Rejects
304
+ * with `EpochRefusedError` on a refusal, and with a plain error when
305
+ * nobody answers inside the timeout. */
306
+ export declare function requestRoomEpoch(opts: RequestRoomEpochOptions): Promise<Exclude<EpochGrant, {
307
+ refused: EpochRefusal;
308
+ }>>;
309
+ /** Canonical form of an admin list: lower-case, deduplicated, sorted. */
310
+ export declare function canonicalAdmins(admins: readonly string[]): string[];
311
+ export interface SignAdminsOptions {
312
+ roomId: string;
313
+ epoch: number;
314
+ admins: readonly string[];
315
+ authoritySk: Uint8Array;
316
+ }
317
+ /**
318
+ * Channel names a room may be told about.
319
+ *
320
+ * Deliberately stricter than `deriveChannel`, which accepts any string of
321
+ * 1..64 characters because it is only doing HKDF and has no opinion. A name
322
+ * in the REGISTRY is different: it is announced to every client, rendered as
323
+ * a label, and handed to an agent as its thread id. So it is held to a shape
324
+ * that survives all three without escaping, normalising or truncating -
325
+ * lower case, digits and hyphens, opening on an alphanumeric.
326
+ *
327
+ * The consequence worth stating: a name is stored and compared exactly as
328
+ * written here, so a client, an agent adapter and a relay all mean the same
329
+ * string by it, and nothing anywhere has to guess at a canonical form.
330
+ */
331
+ export declare const CHANNEL_NAME: RegExp;
332
+ /**
333
+ * Names the registry will not carry, because the room already means
334
+ * something by them: `agents` is the backchannel, `transcript` is what was
335
+ * said, `minutes` is what the scribe wrote. Deriving them still works - they
336
+ * are ordinary channels to the crypto - but announcing one as a place to
337
+ * chat would put two different things under one name.
338
+ */
339
+ export declare const RESERVED_CHANNELS: readonly string[];
340
+ /** Canonical form of a channel list: validated, deduplicated, sorted. */
341
+ export declare function canonicalChannels(channels: readonly string[]): string[];
342
+ export interface SignChannelsOptions {
343
+ roomId: string;
344
+ epoch: number;
345
+ channels: readonly string[];
346
+ authoritySk: Uint8Array;
347
+ }
348
+ /**
349
+ * Sign the list of channels a room has.
350
+ *
351
+ * Signed by the authority and bound to the epoch for exactly the reasons the
352
+ * admin list is: every member holds the room key, so an unsigned list is the
353
+ * first thing a member would forge, and a list valid at one epoch must not
354
+ * be replayable into the next. This is what settles the rule that nothing a
355
+ * chat message says may change the structure of a room - a channel exists
356
+ * because the authority said so, or it does not exist.
357
+ */
358
+ export declare function signChannels(opts: SignChannelsOptions): string;
359
+ export interface VerifyChannelsOptions {
360
+ roomId: string;
361
+ epoch: number;
362
+ channels: readonly string[];
363
+ sig: string;
364
+ authority: string;
365
+ }
366
+ /** Never throws: this runs on anything a relay hands over. */
367
+ export declare function verifyChannels(opts: VerifyChannelsOptions): boolean;
368
+ /**
369
+ * Sign the list of participants who may act on the room, so a client can
370
+ * tell a keeper's announcement from anybody else's claim: the announcement
371
+ * rides the control channel, which every member can write to, and only the
372
+ * authority key pinned in the link can produce this.
373
+ */
374
+ export declare function signAdmins(opts: SignAdminsOptions): string;
375
+ export interface VerifyAdminsOptions {
376
+ roomId: string;
377
+ epoch: number;
378
+ admins: readonly string[];
379
+ sig: string;
380
+ authority: string;
381
+ }
382
+ export declare function verifyAdmins(opts: VerifyAdminsOptions): boolean;
383
+ /** Every wire-format literal this module owns (each one a kithmoot protocol string), frozen for
384
+ * `src/labels.test.ts`, which checks each module against its own exported
385
+ * list rather than scanning file text for matching comments. Pure data -
386
+ * adding this export changes no runtime behaviour. */
387
+ export declare const EPOCH_LABELS: readonly ["kithmoot/v1/admins:", "kithmoot/v1/channels:", "kithmoot/v1/epoch-id", "kithmoot/v1/epoch-key", "kithmoot/v1/epoch-request-key", "kithmoot/v1/epoch-request:"];