@forgesworn/fold-kit 0.0.0-seed.0 → 0.2.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 +180 -4
- package/dist/access.d.ts +34 -0
- package/dist/access.js +102 -0
- package/dist/channel.d.ts +27 -0
- package/dist/channel.js +38 -0
- package/dist/credential.d.ts +74 -0
- package/dist/credential.js +177 -0
- package/dist/display-name.d.ts +38 -0
- package/dist/display-name.js +78 -0
- package/dist/epoch.d.ts +387 -0
- package/dist/epoch.js +665 -0
- package/dist/hex.d.ts +28 -0
- package/dist/hex.js +32 -0
- package/dist/identity.d.ts +49 -0
- package/dist/identity.js +17 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +19 -0
- package/dist/invitation.d.ts +217 -0
- package/dist/invitation.js +484 -0
- package/dist/kinds.d.ts +64 -0
- package/dist/kinds.js +64 -0
- package/dist/lane.d.ts +41 -0
- package/dist/lane.js +63 -0
- package/dist/link.d.ts +52 -0
- package/dist/link.js +107 -0
- package/dist/network-hints.d.ts +16 -0
- package/dist/network-hints.js +65 -0
- package/dist/persistent-invitation.d.ts +35 -0
- package/dist/persistent-invitation.js +118 -0
- package/dist/room.d.ts +53 -0
- package/dist/room.js +153 -0
- package/dist/scoped.d.ts +67 -0
- package/dist/scoped.js +83 -0
- package/dist/sub-cert.d.ts +117 -0
- package/dist/sub-cert.js +192 -0
- package/dist/transport.d.ts +24 -0
- package/dist/transport.js +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +1 -0
- package/dist/verify.d.ts +26 -0
- package/dist/verify.js +46 -0
- package/llms.txt +106 -0
- package/package.json +77 -6
- package/vectors/circle-vectors.json +2174 -0
- package/vectors/kithmoot-vectors.json +1550 -0
|
@@ -0,0 +1,38 @@
|
|
|
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 declare const MAX_DISPLAY_NAME_LENGTH = 32;
|
|
31
|
+
/**
|
|
32
|
+
* Make a name safe to put next to somebody else's, or return undefined if
|
|
33
|
+
* there is nothing left worth showing.
|
|
34
|
+
*
|
|
35
|
+
* Takes `unknown` because this runs on JSON off a relay as well as on a
|
|
36
|
+
* field somebody typed, and anything at all can arrive in either.
|
|
37
|
+
*/
|
|
38
|
+
export declare function sanitiseDisplayName(raw: unknown): string | undefined;
|
|
@@ -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
|
+
}
|
package/dist/epoch.d.ts
ADDED
|
@@ -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:"];
|