@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,484 @@
|
|
|
1
|
+
import { hkdf } from '@noble/hashes/hkdf';
|
|
2
|
+
import { sha256 } from '@noble/hashes/sha2';
|
|
3
|
+
import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils';
|
|
4
|
+
import { schnorr } from '@noble/curves/secp256k1.js';
|
|
5
|
+
import { base64urlnopad } from '@scure/base';
|
|
6
|
+
import { nip44 } from 'nostr-tools';
|
|
7
|
+
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
|
|
8
|
+
import { hexEquals, normaliseHex } from './hex.js';
|
|
9
|
+
import { KINDS } from './kinds.js';
|
|
10
|
+
import { verifyEventUncached } from './verify.js';
|
|
11
|
+
import { deriveRoom } from './room.js';
|
|
12
|
+
const INVITATION_ID_INFO = 'kithmoot/v2/invitation-id';
|
|
13
|
+
const INVITATION_REQUEST_KEY_INFO = 'kithmoot/v2/invitation-request-key';
|
|
14
|
+
const INVITATION_MAX_AGE_SECONDS = 90;
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
16
|
+
const DEFAULT_RETRY_MS = 2_000;
|
|
17
|
+
/** A delegated responder cannot silently make its authority permanent. */
|
|
18
|
+
export const INVITATION_DELEGATION_TTL_SECONDS = 12 * 60 * 60;
|
|
19
|
+
/** Bounds both verification work and the size of a grant from a hostile peer. */
|
|
20
|
+
export const MAX_INVITATION_DELEGATION_DEPTH = 16;
|
|
21
|
+
function require32(bytes, what) {
|
|
22
|
+
if (bytes.length !== 32)
|
|
23
|
+
throw new Error(`${what} must be 32 bytes`);
|
|
24
|
+
}
|
|
25
|
+
function requireHex32(value, what) {
|
|
26
|
+
if (!/^[0-9a-f]{64}$/i.test(value))
|
|
27
|
+
throw new Error(`${what} must be 32-byte hex`);
|
|
28
|
+
return normaliseHex(value);
|
|
29
|
+
}
|
|
30
|
+
function requirePubkey(pubkey) {
|
|
31
|
+
return requireHex32(pubkey, 'inviter pubkey');
|
|
32
|
+
}
|
|
33
|
+
/** Create a bearer plus a fresh, unlinkable inviter key for one share URL. */
|
|
34
|
+
export function createRoomInvitation(persistent = false) {
|
|
35
|
+
const inviterSk = generateSecretKey();
|
|
36
|
+
return {
|
|
37
|
+
invitation: { bearer: randomBytes(32), inviter: getPublicKey(inviterSk), ...(persistent ? { persistent: true } : {}) },
|
|
38
|
+
inviterSk,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Validate and canonicalise an invitation crossing a URL/storage boundary. */
|
|
42
|
+
export function roomInvitation(bearer, inviter, persistent = false) {
|
|
43
|
+
require32(bearer, 'invitation bearer');
|
|
44
|
+
return { bearer, inviter: requirePubkey(inviter), ...(persistent ? { persistent: true } : {}) };
|
|
45
|
+
}
|
|
46
|
+
/** Public rendezvous id. Relays see this and timing, but cannot derive the bearer. */
|
|
47
|
+
export function deriveInvitationId(invitation) {
|
|
48
|
+
require32(invitation.bearer, 'invitation bearer');
|
|
49
|
+
const bytes = hkdf(sha256, invitation.bearer, undefined, INVITATION_ID_INFO, 32);
|
|
50
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
51
|
+
}
|
|
52
|
+
function requestKey(invitation) {
|
|
53
|
+
return hkdf(sha256, invitation.bearer, undefined, INVITATION_REQUEST_KEY_INFO, 32);
|
|
54
|
+
}
|
|
55
|
+
/** How much of a name an admission request carries. The same bound as a
|
|
56
|
+
* display name on the roster. */
|
|
57
|
+
const MAX_REQUEST_NAME_LENGTH = 64;
|
|
58
|
+
/** Prove possession of the bearer without putting it, or a traffic key, on a relay. */
|
|
59
|
+
export function encodeInvitationRequest(opts) {
|
|
60
|
+
require32(opts.requesterSk, 'requester secret key');
|
|
61
|
+
const device = getPublicKey(opts.requesterSk);
|
|
62
|
+
const body = { v: 1, device };
|
|
63
|
+
const name = opts.name?.trim().slice(0, MAX_REQUEST_NAME_LENGTH);
|
|
64
|
+
if (name)
|
|
65
|
+
body.name = name;
|
|
66
|
+
if (opts.participant !== undefined)
|
|
67
|
+
body.participant = requirePubkey(opts.participant);
|
|
68
|
+
return finalizeEvent({
|
|
69
|
+
kind: KINDS.INVITATION_REQUEST,
|
|
70
|
+
created_at: opts.now,
|
|
71
|
+
tags: [
|
|
72
|
+
['d', deriveInvitationId(opts.invitation)],
|
|
73
|
+
['p', requirePubkey(opts.invitation.inviter)],
|
|
74
|
+
],
|
|
75
|
+
content: nip44.v2.encrypt(JSON.stringify(body), requestKey(opts.invitation)),
|
|
76
|
+
}, opts.requesterSk);
|
|
77
|
+
}
|
|
78
|
+
/** Returns null for all malformed, stale, wrongly addressed, or unauthorised asks. */
|
|
79
|
+
export function decodeInvitationRequest(event, opts) {
|
|
80
|
+
try {
|
|
81
|
+
if (event.kind !== KINDS.INVITATION_REQUEST)
|
|
82
|
+
return null;
|
|
83
|
+
if (!verifyEventUncached(event))
|
|
84
|
+
return null;
|
|
85
|
+
if (Math.abs(opts.now - event.created_at) > (opts.maxAgeSeconds ?? INVITATION_MAX_AGE_SECONDS))
|
|
86
|
+
return null;
|
|
87
|
+
if (event.tags.find((t) => t[0] === 'd')?.[1] !== deriveInvitationId(opts.invitation))
|
|
88
|
+
return null;
|
|
89
|
+
const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
|
|
90
|
+
if (addressed === undefined || !hexEquals(addressed, opts.invitation.inviter))
|
|
91
|
+
return null;
|
|
92
|
+
const body = JSON.parse(nip44.v2.decrypt(event.content, requestKey(opts.invitation)));
|
|
93
|
+
if (body.v !== 1 || typeof body.device !== 'string')
|
|
94
|
+
return null;
|
|
95
|
+
if (!hexEquals(body.device, event.pubkey))
|
|
96
|
+
return null;
|
|
97
|
+
const decoded = { device: requirePubkey(body.device), request: event.id };
|
|
98
|
+
// Optional, and dropped rather than refused when malformed: a host
|
|
99
|
+
// that cannot read the name can still let the device in.
|
|
100
|
+
if (typeof body.name === 'string') {
|
|
101
|
+
const name = body.name.trim().slice(0, MAX_REQUEST_NAME_LENGTH);
|
|
102
|
+
if (name)
|
|
103
|
+
decoded.name = name;
|
|
104
|
+
}
|
|
105
|
+
if (typeof body.participant === 'string' && /^[0-9a-f]{64}$/i.test(body.participant))
|
|
106
|
+
decoded.participant = body.participant.toLowerCase();
|
|
107
|
+
return decoded;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function delegationMessage(invitation, room, issuer, delegate, expiresAt) {
|
|
114
|
+
return sha256(new TextEncoder().encode(`kithmoot/v2/invitation-delegation:${invitation}:${room}:${issuer}:${delegate}:${expiresAt}`));
|
|
115
|
+
}
|
|
116
|
+
function issueInvitationDelegation(invitation, room, issuerSk, delegate, expiresAt) {
|
|
117
|
+
const invitationId = deriveInvitationId(invitation);
|
|
118
|
+
const canonicalRoom = requireHex32(room, 'room id');
|
|
119
|
+
const issuer = getPublicKey(issuerSk);
|
|
120
|
+
const canonicalDelegate = requirePubkey(delegate);
|
|
121
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt <= 0)
|
|
122
|
+
throw new Error('delegation expiry must be unix seconds');
|
|
123
|
+
return {
|
|
124
|
+
invitation: invitationId,
|
|
125
|
+
room: canonicalRoom,
|
|
126
|
+
issuer,
|
|
127
|
+
delegate: canonicalDelegate,
|
|
128
|
+
expiresAt,
|
|
129
|
+
sig: bytesToHex(schnorr.sign(delegationMessage(invitationId, canonicalRoom, issuer, canonicalDelegate, expiresAt), issuerSk)),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Verify a whole chain and return the pubkey authorised by its final hop.
|
|
134
|
+
* An empty chain names the root inviter itself.
|
|
135
|
+
*/
|
|
136
|
+
export function verifyInvitationDelegation(invitation, chain, now) {
|
|
137
|
+
try {
|
|
138
|
+
if (!Array.isArray(chain) || chain.length > MAX_INVITATION_DELEGATION_DEPTH)
|
|
139
|
+
return null;
|
|
140
|
+
const invitationId = deriveInvitationId(invitation);
|
|
141
|
+
let room;
|
|
142
|
+
let authority = requirePubkey(invitation.inviter);
|
|
143
|
+
for (const raw of chain) {
|
|
144
|
+
if (typeof raw !== 'object' || raw === null)
|
|
145
|
+
return null;
|
|
146
|
+
const cert = {
|
|
147
|
+
invitation: normaliseHex(raw.invitation),
|
|
148
|
+
room: requireHex32(raw.room, 'room id'),
|
|
149
|
+
issuer: requirePubkey(raw.issuer),
|
|
150
|
+
delegate: requirePubkey(raw.delegate),
|
|
151
|
+
expiresAt: raw.expiresAt,
|
|
152
|
+
sig: normaliseHex(raw.sig),
|
|
153
|
+
};
|
|
154
|
+
if (!hexEquals(cert.invitation, invitationId))
|
|
155
|
+
return null;
|
|
156
|
+
if (room !== undefined && !hexEquals(cert.room, room))
|
|
157
|
+
return null;
|
|
158
|
+
room = cert.room;
|
|
159
|
+
if (!hexEquals(cert.issuer, authority))
|
|
160
|
+
return null;
|
|
161
|
+
if (!Number.isSafeInteger(cert.expiresAt) || cert.expiresAt <= now)
|
|
162
|
+
return null;
|
|
163
|
+
if (hexToBytes(cert.sig).length !== 64)
|
|
164
|
+
return null;
|
|
165
|
+
if (!schnorr.verify(hexToBytes(cert.sig), delegationMessage(cert.invitation, cert.room, cert.issuer, cert.delegate, cert.expiresAt), hexToBytes(cert.issuer)))
|
|
166
|
+
return null;
|
|
167
|
+
authority = cert.delegate;
|
|
168
|
+
}
|
|
169
|
+
return authority;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Encrypt a room secret only to the requester and authenticate its signer
|
|
176
|
+
* through the room-bound chain rooted at the inviter pinned in the link. */
|
|
177
|
+
export function encodeInvitationGrant(opts) {
|
|
178
|
+
require32(opts.inviterSk, 'inviter secret key');
|
|
179
|
+
require32(opts.roomSecret, 'room secret');
|
|
180
|
+
const inviter = getPublicKey(opts.inviterSk);
|
|
181
|
+
const chain = opts.delegation ?? [];
|
|
182
|
+
const roomId = deriveRoom(opts.roomSecret).roomId;
|
|
183
|
+
const authorised = verifyInvitationDelegation(opts.invitation, chain, opts.now);
|
|
184
|
+
if (authorised === null || !hexEquals(inviter, authorised))
|
|
185
|
+
throw new Error('responder is not delegated for invitation');
|
|
186
|
+
if (chain.length > 0 && !hexEquals(chain[0].room, roomId))
|
|
187
|
+
throw new Error('delegation names another room');
|
|
188
|
+
if (chain.length >= MAX_INVITATION_DELEGATION_DEPTH)
|
|
189
|
+
throw new Error('invitation delegation is at maximum depth');
|
|
190
|
+
const requester = requirePubkey(opts.requester);
|
|
191
|
+
if (!/^[0-9a-f]{64}$/i.test(opts.request))
|
|
192
|
+
throw new Error('request id must be 32-byte hex');
|
|
193
|
+
const ownExpiry = chain.length === 0
|
|
194
|
+
? Number.POSITIVE_INFINITY
|
|
195
|
+
: Math.min(...chain.map((cert) => cert.expiresAt));
|
|
196
|
+
const expiresAt = Math.min(ownExpiry, opts.now + (opts.delegationTtlSeconds ?? INVITATION_DELEGATION_TTL_SECONDS));
|
|
197
|
+
const next = issueInvitationDelegation(opts.invitation, roomId, opts.inviterSk, requester, expiresAt);
|
|
198
|
+
const body = {
|
|
199
|
+
v: 2,
|
|
200
|
+
request: normaliseHex(opts.request),
|
|
201
|
+
secret: base64urlnopad.encode(opts.roomSecret),
|
|
202
|
+
delegation: [...chain, next],
|
|
203
|
+
...(opts.epoch !== undefined && Number.isSafeInteger(opts.epoch) && opts.epoch >= 0 ? { epoch: opts.epoch } : {}),
|
|
204
|
+
};
|
|
205
|
+
const conversationKey = nip44.v2.utils.getConversationKey(opts.inviterSk, requester);
|
|
206
|
+
return finalizeEvent({
|
|
207
|
+
kind: KINDS.INVITATION_GRANT,
|
|
208
|
+
created_at: opts.now,
|
|
209
|
+
tags: [
|
|
210
|
+
['d', deriveInvitationId(opts.invitation)],
|
|
211
|
+
['p', requester],
|
|
212
|
+
],
|
|
213
|
+
content: nip44.v2.encrypt(JSON.stringify(body), conversationKey),
|
|
214
|
+
}, opts.inviterSk);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Accept a fresh response whose delegation chain terminates at its event
|
|
218
|
+
* signer, then retain the final requester hop so this member can become a
|
|
219
|
+
* responder in turn. The chain always roots at the pubkey pinned in the
|
|
220
|
+
* link; a bearer holder cannot nominate an authority of their own.
|
|
221
|
+
*/
|
|
222
|
+
export function decodeRoomAdmissionGrant(event, opts) {
|
|
223
|
+
try {
|
|
224
|
+
if (event.kind !== KINDS.INVITATION_GRANT)
|
|
225
|
+
return null;
|
|
226
|
+
if (!verifyEventUncached(event))
|
|
227
|
+
return null;
|
|
228
|
+
if (Math.abs(opts.now - event.created_at) > (opts.maxAgeSeconds ?? INVITATION_MAX_AGE_SECONDS))
|
|
229
|
+
return null;
|
|
230
|
+
if (event.tags.find((t) => t[0] === 'd')?.[1] !== deriveInvitationId(opts.invitation))
|
|
231
|
+
return null;
|
|
232
|
+
const requester = getPublicKey(opts.requesterSk);
|
|
233
|
+
const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
|
|
234
|
+
if (addressed === undefined || !hexEquals(addressed, requester))
|
|
235
|
+
return null;
|
|
236
|
+
const conversationKey = nip44.v2.utils.getConversationKey(opts.requesterSk, event.pubkey);
|
|
237
|
+
const body = JSON.parse(nip44.v2.decrypt(event.content, conversationKey));
|
|
238
|
+
if (body.v !== 2 || typeof body.request !== 'string' || !hexEquals(body.request, opts.request))
|
|
239
|
+
return null;
|
|
240
|
+
if (typeof body.secret !== 'string')
|
|
241
|
+
return null;
|
|
242
|
+
const secret = base64urlnopad.decode(body.secret);
|
|
243
|
+
if (secret.length !== 32)
|
|
244
|
+
return null;
|
|
245
|
+
if (!Array.isArray(body.delegation) || body.delegation.length === 0)
|
|
246
|
+
return null;
|
|
247
|
+
if (!hexEquals(body.delegation[0].room, deriveRoom(secret).roomId))
|
|
248
|
+
return null;
|
|
249
|
+
const authority = verifyInvitationDelegation(opts.invitation, body.delegation, opts.now);
|
|
250
|
+
if (authority === null || !hexEquals(authority, requester))
|
|
251
|
+
return null;
|
|
252
|
+
const issuer = body.delegation.at(-1)?.issuer;
|
|
253
|
+
if (issuer === undefined || !hexEquals(issuer, event.pubkey))
|
|
254
|
+
return null;
|
|
255
|
+
const admission = {
|
|
256
|
+
secret,
|
|
257
|
+
delegate: { delegateSk: opts.requesterSk, chain: body.delegation },
|
|
258
|
+
};
|
|
259
|
+
if (Number.isSafeInteger(body.epoch) && body.epoch >= 0)
|
|
260
|
+
admission.epoch = body.epoch;
|
|
261
|
+
return admission;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/** Backward-compatible convenience for callers that only need the traffic
|
|
268
|
+
* secret. New interactive clients should retain `decodeRoomAdmissionGrant`'s
|
|
269
|
+
* delegate capability so the room does not depend on its creator staying. */
|
|
270
|
+
export function decodeInvitationGrant(event, opts) {
|
|
271
|
+
return decodeRoomAdmissionGrant(event, opts)?.secret ?? null;
|
|
272
|
+
}
|
|
273
|
+
/** What a joiner is told when a link was retired because its room ended. */
|
|
274
|
+
export const ROOM_ENDED_MESSAGE = 'this room was ended by the person who started it';
|
|
275
|
+
/** Make a permanent tombstone for one invitation rendezvous. */
|
|
276
|
+
export function encodeInvitationRetirement(opts) {
|
|
277
|
+
require32(opts.inviterSk, 'inviter secret key');
|
|
278
|
+
if (!hexEquals(getPublicKey(opts.inviterSk), opts.invitation.inviter)) {
|
|
279
|
+
throw new Error('only the root inviter can retire an invitation');
|
|
280
|
+
}
|
|
281
|
+
return finalizeEvent({
|
|
282
|
+
kind: KINDS.INVITATION_RETIREMENT,
|
|
283
|
+
created_at: opts.now,
|
|
284
|
+
tags: [['d', deriveInvitationId(opts.invitation)]],
|
|
285
|
+
content: JSON.stringify(opts.ended ? { v: 1, ended: true } : { v: 1 }),
|
|
286
|
+
}, opts.inviterSk);
|
|
287
|
+
}
|
|
288
|
+
/** A valid tombstone never expires: invitation ids are random and unique,
|
|
289
|
+
* and a retired bearer must not become usable again after a timeout. */
|
|
290
|
+
export function decodeInvitationRetirement(event, invitation) {
|
|
291
|
+
return decodeInvitationRetirementNotice(event, invitation) !== undefined;
|
|
292
|
+
}
|
|
293
|
+
/** A valid retirement, and whether it says the room was ended. Undefined
|
|
294
|
+
* for anything that is not a valid retirement of this invitation. */
|
|
295
|
+
export function decodeInvitationRetirementNotice(event, invitation) {
|
|
296
|
+
try {
|
|
297
|
+
if (event.kind !== KINDS.INVITATION_RETIREMENT)
|
|
298
|
+
return undefined;
|
|
299
|
+
if (!verifyEventUncached(event))
|
|
300
|
+
return undefined;
|
|
301
|
+
if (!hexEquals(event.pubkey, invitation.inviter))
|
|
302
|
+
return undefined;
|
|
303
|
+
if (event.tags.find((tag) => tag[0] === 'd')?.[1] !== deriveInvitationId(invitation))
|
|
304
|
+
return undefined;
|
|
305
|
+
const body = JSON.parse(event.content);
|
|
306
|
+
return body.v === 1 ? { ended: body.ended === true } : undefined;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** The error a joiner rejects with on a retirement. */
|
|
313
|
+
export function retirementError(notice) {
|
|
314
|
+
return new Error(notice.ended ? ROOM_ENDED_MESSAGE : 'this room invitation has been retired');
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Auto-admit anybody holding this link while this admitted member is online.
|
|
318
|
+
*
|
|
319
|
+
* Closing the returned handle stops this responder. People
|
|
320
|
+
* already admitted necessarily retain the room secret they were given; link
|
|
321
|
+
* rotation is not member revocation and the UI must never claim that it is.
|
|
322
|
+
*/
|
|
323
|
+
export function hostRoomInvitation(opts) {
|
|
324
|
+
require32(opts.roomSecret, 'room secret');
|
|
325
|
+
const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
|
|
326
|
+
const delegation = opts.delegation ?? [];
|
|
327
|
+
const authority = verifyInvitationDelegation(opts.invitation, delegation, now());
|
|
328
|
+
if (authority === null || !hexEquals(getPublicKey(opts.inviterSk), authority)) {
|
|
329
|
+
throw new Error('responder is not delegated for invitation');
|
|
330
|
+
}
|
|
331
|
+
if (delegation.length > 0 && !hexEquals(delegation[0].room, deriveRoom(opts.roomSecret).roomId)) {
|
|
332
|
+
throw new Error('delegation names another room');
|
|
333
|
+
}
|
|
334
|
+
const responder = getPublicKey(opts.inviterSk);
|
|
335
|
+
const invitationId = deriveInvitationId(opts.invitation);
|
|
336
|
+
const answered = new Set();
|
|
337
|
+
let closed = false;
|
|
338
|
+
let unsubRequests = () => { };
|
|
339
|
+
let unsubRetirement = () => { };
|
|
340
|
+
const close = () => {
|
|
341
|
+
if (closed)
|
|
342
|
+
return;
|
|
343
|
+
closed = true;
|
|
344
|
+
unsubRequests();
|
|
345
|
+
unsubRetirement();
|
|
346
|
+
};
|
|
347
|
+
// Subscribe to the durable tombstone before accepting requests. Real
|
|
348
|
+
// relays replay stored regular events, so a responder returning from an
|
|
349
|
+
// offline spell retires itself before it can keep an old link alive.
|
|
350
|
+
unsubRetirement = opts.transport.subscribe([{ kinds: [KINDS.INVITATION_RETIREMENT], '#d': [invitationId], authors: [opts.invitation.inviter] }], (event) => {
|
|
351
|
+
if (!decodeInvitationRetirement(event, opts.invitation))
|
|
352
|
+
return;
|
|
353
|
+
close();
|
|
354
|
+
opts.onRetired?.();
|
|
355
|
+
});
|
|
356
|
+
if (closed) {
|
|
357
|
+
unsubRetirement();
|
|
358
|
+
return { close };
|
|
359
|
+
}
|
|
360
|
+
unsubRequests = opts.transport.subscribe([{ kinds: [KINDS.INVITATION_REQUEST], '#d': [invitationId], '#p': [opts.invitation.inviter] }], (event) => {
|
|
361
|
+
if (closed)
|
|
362
|
+
return;
|
|
363
|
+
const request = decodeInvitationRequest(event, { invitation: opts.invitation, now: now() });
|
|
364
|
+
// Some relays incorrectly retain and replay ephemeral requests. A newly
|
|
365
|
+
// admitted delegate must not answer the request that admitted itself.
|
|
366
|
+
if (!request || hexEquals(request.device, responder) || answered.has(request.request))
|
|
367
|
+
return;
|
|
368
|
+
answered.add(request.request);
|
|
369
|
+
// A long-running public room must not grow this replay guard without
|
|
370
|
+
// bound. Duplicate requests are harmless after eviction: they only
|
|
371
|
+
// cause the same encrypted grant to be sent again.
|
|
372
|
+
if (answered.size > 256)
|
|
373
|
+
answered.delete(answered.values().next().value);
|
|
374
|
+
const grantNow = () => {
|
|
375
|
+
if (closed)
|
|
376
|
+
return;
|
|
377
|
+
let grant;
|
|
378
|
+
try {
|
|
379
|
+
grant = encodeInvitationGrant({
|
|
380
|
+
invitation: opts.invitation,
|
|
381
|
+
inviterSk: opts.inviterSk,
|
|
382
|
+
requester: request.device,
|
|
383
|
+
request: request.request,
|
|
384
|
+
roomSecret: opts.roomSecret,
|
|
385
|
+
now: now(),
|
|
386
|
+
delegation,
|
|
387
|
+
...(opts.epoch ? { epoch: opts.epoch() } : {}),
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// Expired or maximum-depth authority is no authority. Subscription
|
|
392
|
+
// callbacks must never throw and take the caller's relay loop down.
|
|
393
|
+
close();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
opts.transport.publish(grant).catch(() => { });
|
|
397
|
+
opts.onAdmitted?.(request.device);
|
|
398
|
+
};
|
|
399
|
+
if (!opts.admit) {
|
|
400
|
+
grantNow();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
// A host that asks first answers later, if at all. Never awaited in
|
|
404
|
+
// the relay callback, and a hook that throws declines.
|
|
405
|
+
Promise.resolve()
|
|
406
|
+
.then(() => opts.admit(request))
|
|
407
|
+
.then((yes) => { if (yes)
|
|
408
|
+
grantNow(); })
|
|
409
|
+
.catch(() => { });
|
|
410
|
+
});
|
|
411
|
+
return { close };
|
|
412
|
+
}
|
|
413
|
+
/** Resolve the room and a bounded responder delegation, with no account or
|
|
414
|
+
* prompt. Retaining the delegation is what removes the creator as an
|
|
415
|
+
* availability dependency for the next arrival. */
|
|
416
|
+
export function requestRoomAdmissionCapability(opts) {
|
|
417
|
+
const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
|
|
418
|
+
const requesterSk = opts.requesterSk ?? generateSecretKey();
|
|
419
|
+
const requester = getPublicKey(requesterSk);
|
|
420
|
+
const request = encodeInvitationRequest({
|
|
421
|
+
invitation: opts.invitation, requesterSk, now: now(),
|
|
422
|
+
...(opts.name !== undefined ? { name: opts.name } : {}),
|
|
423
|
+
...(opts.participant !== undefined ? { participant: opts.participant } : {}),
|
|
424
|
+
});
|
|
425
|
+
return new Promise((resolve, reject) => {
|
|
426
|
+
let settled = false;
|
|
427
|
+
let retry;
|
|
428
|
+
let expiry;
|
|
429
|
+
const invitationId = deriveInvitationId(opts.invitation);
|
|
430
|
+
let unsub = () => { };
|
|
431
|
+
unsub = opts.transport.subscribe([
|
|
432
|
+
{ kinds: [KINDS.INVITATION_GRANT], '#d': [invitationId], '#p': [requester] },
|
|
433
|
+
{ kinds: [KINDS.INVITATION_RETIREMENT], '#d': [invitationId], authors: [opts.invitation.inviter] },
|
|
434
|
+
], (event) => {
|
|
435
|
+
const retired = decodeInvitationRetirementNotice(event, opts.invitation);
|
|
436
|
+
if (retired) {
|
|
437
|
+
finish(() => reject(retirementError(retired)));
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const admission = decodeRoomAdmissionGrant(event, {
|
|
441
|
+
invitation: opts.invitation,
|
|
442
|
+
requesterSk,
|
|
443
|
+
request: request.id,
|
|
444
|
+
now: now(),
|
|
445
|
+
});
|
|
446
|
+
if (admission)
|
|
447
|
+
finish(() => resolve(admission));
|
|
448
|
+
});
|
|
449
|
+
if (settled)
|
|
450
|
+
unsub();
|
|
451
|
+
function finish(settle) {
|
|
452
|
+
if (settled)
|
|
453
|
+
return;
|
|
454
|
+
settled = true;
|
|
455
|
+
if (retry !== undefined)
|
|
456
|
+
clearInterval(retry);
|
|
457
|
+
if (expiry !== undefined)
|
|
458
|
+
clearTimeout(expiry);
|
|
459
|
+
unsub();
|
|
460
|
+
settle();
|
|
461
|
+
}
|
|
462
|
+
function ask() {
|
|
463
|
+
if (!settled)
|
|
464
|
+
opts.transport.publish(request).catch(() => { });
|
|
465
|
+
}
|
|
466
|
+
expiry = setTimeout(() => finish(() => reject(new Error('the room is not answering this invitation'))), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
467
|
+
retry = setInterval(ask, opts.retryMs ?? DEFAULT_RETRY_MS);
|
|
468
|
+
ask();
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
/** Compatibility wrapper for non-interactive consumers. It joins correctly,
|
|
472
|
+
* but discards the ability to keep the link available for somebody else. */
|
|
473
|
+
export async function requestRoomAdmission(opts) {
|
|
474
|
+
return (await requestRoomAdmissionCapability(opts)).secret;
|
|
475
|
+
}
|
|
476
|
+
/** Every wire-format literal this module owns (each one a kithmoot protocol string), frozen for
|
|
477
|
+
* `src/labels.test.ts`, which checks each module against its own exported
|
|
478
|
+
* list rather than scanning file text for matching comments. Pure data -
|
|
479
|
+
* adding this export changes no runtime behaviour. */
|
|
480
|
+
export const INVITATION_LABELS = [
|
|
481
|
+
"kithmoot/v2/invitation-delegation:",
|
|
482
|
+
"kithmoot/v2/invitation-id",
|
|
483
|
+
"kithmoot/v2/invitation-request-key",
|
|
484
|
+
];
|
package/dist/kinds.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Circle-layer wire kinds: a subset of KithMoot's `src/kinds.ts`, copied
|
|
2
|
+
* byte-identical per entry from the pinned source commit (see
|
|
3
|
+
* EXTRACTION.md in this repository). KithMoot's own `kinds.ts` spreads
|
|
4
|
+
* these into its wider `KINDS` object and keeps the rest (roster,
|
|
5
|
+
* signalling, descriptor, pairing, call bell, and so on) itself.
|
|
6
|
+
*
|
|
7
|
+
* `CHAT` (1460) is included because a downstream app's own board events
|
|
8
|
+
* are designed to share KithMoot's chat kind, so a relay cannot tell a
|
|
9
|
+
* board from a chat (see docs/extraction-plan-excerpt.md). */
|
|
10
|
+
export declare const KINDS: {
|
|
11
|
+
/** Device credential. Signed by the participant key; never published to a
|
|
12
|
+
* relay - it travels inside the encrypted roster, so relays never see the
|
|
13
|
+
* participant pubkey. */
|
|
14
|
+
readonly CREDENTIAL: 20460;
|
|
15
|
+
/** Chat message, encrypted to the room key and published once, exactly
|
|
16
|
+
* like the roster. Unlike the roster this is a DURABLE kind (regular
|
|
17
|
+
* event range, not ephemeral) - chat history is the point, so it must
|
|
18
|
+
* survive a relay restart and be there for late joiners. */
|
|
19
|
+
readonly CHAT: 1460;
|
|
20
|
+
/** A prospective member proving possession of a room invitation.
|
|
21
|
+
*
|
|
22
|
+
* Ephemeral deliberately: it is a live rendezvous with an inviter, not a
|
|
23
|
+
* request a relay should retain. The public `d` tag is derived from the
|
|
24
|
+
* bearer capability; the request body is encrypted under a separate key
|
|
25
|
+
* derived from that capability. */
|
|
26
|
+
readonly INVITATION_REQUEST: 20466;
|
|
27
|
+
/** A delegated responder's encrypted response carrying the room traffic
|
|
28
|
+
* secret and its root-authenticated, room-bound delegation chain. Addressed
|
|
29
|
+
* to the requester's one-use pubkey; knowing the bearer does not let
|
|
30
|
+
* somebody nominate a responder or substitute a room. Ephemeral for the
|
|
31
|
+
* same reason as the request. */
|
|
32
|
+
readonly INVITATION_GRANT: 20467;
|
|
33
|
+
/** A durable, creator-authenticated tombstone for one invitation.
|
|
34
|
+
*
|
|
35
|
+
* Unlike the live request/grant exchange this MUST be a regular stored
|
|
36
|
+
* event: a delegated responder that was offline when the creator rotated
|
|
37
|
+
* the link has to learn that fact before it starts answering the old link
|
|
38
|
+
* again. The invitation id is unique, so one valid retirement is final. */
|
|
39
|
+
readonly INVITATION_RETIREMENT: 1461;
|
|
40
|
+
/** A persistent group's invitation, signed by the pinned inviter and
|
|
41
|
+
* encrypted under a separate bearer-derived key. Regular stored event:
|
|
42
|
+
* newcomers can enter with every member offline. Contains epoch 0 only;
|
|
43
|
+
* it never grants authority to rekey or bypass a later removal. */
|
|
44
|
+
readonly GROUP_INVITATION: 1463;
|
|
45
|
+
/** A room moving to a new epoch: a fresh traffic secret, sealed per
|
|
46
|
+
* remaining device, with the participants removed at this step named.
|
|
47
|
+
*
|
|
48
|
+
* Durable, and addressed by the public room id, so a client can find the
|
|
49
|
+
* room's current epoch from the id alone and know it is behind before it
|
|
50
|
+
* says anything under a key that is dead. The body is encrypted to the
|
|
51
|
+
* previous epoch's key, so a relay sees the room id, the epoch number,
|
|
52
|
+
* the authority's pubkey and a size, and nothing about who was kept or
|
|
53
|
+
* removed. Signed only by the room's authority, the root inviter pinned
|
|
54
|
+
* in the link. See `epoch.ts`. */
|
|
55
|
+
readonly ROOM_REKEY: 1462;
|
|
56
|
+
/** A member that missed a rekey - it was offline, or it is arriving now -
|
|
57
|
+
* asking the authority for the current epoch, proving which participant it
|
|
58
|
+
* speaks for with its device credential. Encrypted to the authority and
|
|
59
|
+
* ephemeral: it is a live handshake, not a record. */
|
|
60
|
+
readonly EPOCH_REQUEST: 20468;
|
|
61
|
+
/** The authority's answer, sealed to the asking device: the current epoch's
|
|
62
|
+
* secret and the removed set, or a refusal. Ephemeral for the same reason. */
|
|
63
|
+
readonly EPOCH_GRANT: 20469;
|
|
64
|
+
};
|
package/dist/kinds.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Circle-layer wire kinds: a subset of KithMoot's `src/kinds.ts`, copied
|
|
2
|
+
* byte-identical per entry from the pinned source commit (see
|
|
3
|
+
* EXTRACTION.md in this repository). KithMoot's own `kinds.ts` spreads
|
|
4
|
+
* these into its wider `KINDS` object and keeps the rest (roster,
|
|
5
|
+
* signalling, descriptor, pairing, call bell, and so on) itself.
|
|
6
|
+
*
|
|
7
|
+
* `CHAT` (1460) is included because a downstream app's own board events
|
|
8
|
+
* are designed to share KithMoot's chat kind, so a relay cannot tell a
|
|
9
|
+
* board from a chat (see docs/extraction-plan-excerpt.md). */
|
|
10
|
+
export const KINDS = {
|
|
11
|
+
/** Device credential. Signed by the participant key; never published to a
|
|
12
|
+
* relay - it travels inside the encrypted roster, so relays never see the
|
|
13
|
+
* participant pubkey. */
|
|
14
|
+
CREDENTIAL: 20460,
|
|
15
|
+
/** Chat message, encrypted to the room key and published once, exactly
|
|
16
|
+
* like the roster. Unlike the roster this is a DURABLE kind (regular
|
|
17
|
+
* event range, not ephemeral) - chat history is the point, so it must
|
|
18
|
+
* survive a relay restart and be there for late joiners. */
|
|
19
|
+
CHAT: 1460,
|
|
20
|
+
/** A prospective member proving possession of a room invitation.
|
|
21
|
+
*
|
|
22
|
+
* Ephemeral deliberately: it is a live rendezvous with an inviter, not a
|
|
23
|
+
* request a relay should retain. The public `d` tag is derived from the
|
|
24
|
+
* bearer capability; the request body is encrypted under a separate key
|
|
25
|
+
* derived from that capability. */
|
|
26
|
+
INVITATION_REQUEST: 20466,
|
|
27
|
+
/** A delegated responder's encrypted response carrying the room traffic
|
|
28
|
+
* secret and its root-authenticated, room-bound delegation chain. Addressed
|
|
29
|
+
* to the requester's one-use pubkey; knowing the bearer does not let
|
|
30
|
+
* somebody nominate a responder or substitute a room. Ephemeral for the
|
|
31
|
+
* same reason as the request. */
|
|
32
|
+
INVITATION_GRANT: 20467,
|
|
33
|
+
/** A durable, creator-authenticated tombstone for one invitation.
|
|
34
|
+
*
|
|
35
|
+
* Unlike the live request/grant exchange this MUST be a regular stored
|
|
36
|
+
* event: a delegated responder that was offline when the creator rotated
|
|
37
|
+
* the link has to learn that fact before it starts answering the old link
|
|
38
|
+
* again. The invitation id is unique, so one valid retirement is final. */
|
|
39
|
+
INVITATION_RETIREMENT: 1461,
|
|
40
|
+
/** A persistent group's invitation, signed by the pinned inviter and
|
|
41
|
+
* encrypted under a separate bearer-derived key. Regular stored event:
|
|
42
|
+
* newcomers can enter with every member offline. Contains epoch 0 only;
|
|
43
|
+
* it never grants authority to rekey or bypass a later removal. */
|
|
44
|
+
GROUP_INVITATION: 1463,
|
|
45
|
+
/** A room moving to a new epoch: a fresh traffic secret, sealed per
|
|
46
|
+
* remaining device, with the participants removed at this step named.
|
|
47
|
+
*
|
|
48
|
+
* Durable, and addressed by the public room id, so a client can find the
|
|
49
|
+
* room's current epoch from the id alone and know it is behind before it
|
|
50
|
+
* says anything under a key that is dead. The body is encrypted to the
|
|
51
|
+
* previous epoch's key, so a relay sees the room id, the epoch number,
|
|
52
|
+
* the authority's pubkey and a size, and nothing about who was kept or
|
|
53
|
+
* removed. Signed only by the room's authority, the root inviter pinned
|
|
54
|
+
* in the link. See `epoch.ts`. */
|
|
55
|
+
ROOM_REKEY: 1462,
|
|
56
|
+
/** A member that missed a rekey - it was offline, or it is arriving now -
|
|
57
|
+
* asking the authority for the current epoch, proving which participant it
|
|
58
|
+
* speaks for with its device credential. Encrypted to the authority and
|
|
59
|
+
* ephemeral: it is a live handshake, not a record. */
|
|
60
|
+
EPOCH_REQUEST: 20468,
|
|
61
|
+
/** The authority's answer, sealed to the asking device: the current epoch's
|
|
62
|
+
* secret and the removed set, or a refusal. Ephemeral for the same reason. */
|
|
63
|
+
EPOCH_GRANT: 20469,
|
|
64
|
+
};
|
package/dist/lane.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lane a message actually travelled, and what that lane delivers.
|
|
3
|
+
*
|
|
4
|
+
* Three states, fixed in meaning. A client shows the lane a message took,
|
|
5
|
+
* never the lane it asked for, and never claims more than the lane gives.
|
|
6
|
+
* The state is worked out from where the bytes went, so nothing on the
|
|
7
|
+
* wire can assert it: a message that says it was sheltered is not.
|
|
8
|
+
*
|
|
9
|
+
* - public: a relay operator could see who this was for and when.
|
|
10
|
+
* - sheltered: no operator outside your circle saw this.
|
|
11
|
+
* - direct: only the two devices were involved.
|
|
12
|
+
*
|
|
13
|
+
* Today a room's traffic rides ordinary relays, so almost everything is
|
|
14
|
+
* public. That is the honest answer and it is shown. Sheltered means the
|
|
15
|
+
* relay is a box the person's own circle runs, known from a contact card
|
|
16
|
+
* or the keeper's claim; an onion address on its own hides the client's
|
|
17
|
+
* address and says nothing about who runs the relay, so it stays public.
|
|
18
|
+
* A data channel is direct. As more carriers arrive they classify here,
|
|
19
|
+
* and nowhere else.
|
|
20
|
+
*/
|
|
21
|
+
export type Lane = 'public' | 'sheltered' | 'direct';
|
|
22
|
+
export declare const LANES: readonly Lane[];
|
|
23
|
+
/** The fixed meaning of each state, shown wherever the state is. */
|
|
24
|
+
export declare const LANE_MEANING: Readonly<Record<Lane, string>>;
|
|
25
|
+
/** Short label, readable without colour. */
|
|
26
|
+
export declare const LANE_LABEL: Readonly<Record<Lane, string>>;
|
|
27
|
+
/** A glyph beside the label, so the state is told apart without colour. */
|
|
28
|
+
export declare const LANE_GLYPH: Readonly<Record<Lane, string>>;
|
|
29
|
+
export declare function isLane(value: unknown): value is Lane;
|
|
30
|
+
/**
|
|
31
|
+
* The lane one relay URL puts a message on. `circle` is the set of relay
|
|
32
|
+
* URLs the client knows to be boxes of the person's circle; a relay in it
|
|
33
|
+
* is sheltered and every other relay, onion or not, is public.
|
|
34
|
+
*/
|
|
35
|
+
export declare function laneOfRelayUrl(url: string, circle?: ReadonlySet<string>): Lane;
|
|
36
|
+
/** The weakest of several lanes. Undefined when there are none. */
|
|
37
|
+
export declare function weakestLane(lanes: readonly Lane[]): Lane | undefined;
|
|
38
|
+
/** The lane a message takes when it is published to all of these relays. */
|
|
39
|
+
export declare function laneOfRelays(urls: readonly string[], circle?: ReadonlySet<string>): Lane | undefined;
|
|
40
|
+
/** A message went by a weaker lane than the one asked for. */
|
|
41
|
+
export declare function isDowngrade(requested: Lane, actual: Lane): boolean;
|