@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.
- package/README.md +154 -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 +58 -0
- package/dist/credential.js +145 -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 +25 -0
- package/dist/index.js +17 -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/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 +100 -0
- package/package.json +76 -6
- package/vectors/circle-vectors.json +2173 -0
- package/vectors/kithmoot-vectors.json +1550 -0
package/dist/lane.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const LANES = ['public', 'sheltered', 'direct'];
|
|
2
|
+
/** The fixed meaning of each state, shown wherever the state is. */
|
|
3
|
+
export const LANE_MEANING = {
|
|
4
|
+
public: 'A relay operator could see who this was for and when.',
|
|
5
|
+
sheltered: 'No operator outside your circle saw this.',
|
|
6
|
+
direct: 'Only the two devices were involved.',
|
|
7
|
+
};
|
|
8
|
+
/** Short label, readable without colour. */
|
|
9
|
+
export const LANE_LABEL = {
|
|
10
|
+
public: 'public',
|
|
11
|
+
sheltered: 'sheltered',
|
|
12
|
+
direct: 'direct',
|
|
13
|
+
};
|
|
14
|
+
/** A glyph beside the label, so the state is told apart without colour. */
|
|
15
|
+
export const LANE_GLYPH = {
|
|
16
|
+
public: '○',
|
|
17
|
+
sheltered: '◐',
|
|
18
|
+
direct: '●',
|
|
19
|
+
};
|
|
20
|
+
/** Weakest first. A message that touched a public relay is public,
|
|
21
|
+
* whatever else it also touched. */
|
|
22
|
+
const RANK = { public: 0, sheltered: 1, direct: 2 };
|
|
23
|
+
export function isLane(value) {
|
|
24
|
+
return value === 'public' || value === 'sheltered' || value === 'direct';
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The lane one relay URL puts a message on. `circle` is the set of relay
|
|
28
|
+
* URLs the client knows to be boxes of the person's circle; a relay in it
|
|
29
|
+
* is sheltered and every other relay, onion or not, is public.
|
|
30
|
+
*/
|
|
31
|
+
export function laneOfRelayUrl(url, circle) {
|
|
32
|
+
if (circle && circle.has(url))
|
|
33
|
+
return 'sheltered';
|
|
34
|
+
try {
|
|
35
|
+
if (circle && circle.has(normaliseRelay(url)))
|
|
36
|
+
return 'sheltered';
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// An unparseable URL is nobody's box.
|
|
40
|
+
}
|
|
41
|
+
return 'public';
|
|
42
|
+
}
|
|
43
|
+
function normaliseRelay(url) {
|
|
44
|
+
const u = new URL(url);
|
|
45
|
+
u.hostname = u.hostname.toLowerCase();
|
|
46
|
+
return u.toString().replace(/\/$/, '');
|
|
47
|
+
}
|
|
48
|
+
/** The weakest of several lanes. Undefined when there are none. */
|
|
49
|
+
export function weakestLane(lanes) {
|
|
50
|
+
let out;
|
|
51
|
+
for (const lane of lanes)
|
|
52
|
+
if (out === undefined || RANK[lane] < RANK[out])
|
|
53
|
+
out = lane;
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
/** The lane a message takes when it is published to all of these relays. */
|
|
57
|
+
export function laneOfRelays(urls, circle) {
|
|
58
|
+
return weakestLane(urls.map((u) => laneOfRelayUrl(u, circle)));
|
|
59
|
+
}
|
|
60
|
+
/** A message went by a weaker lane than the one asked for. */
|
|
61
|
+
export function isDowngrade(requested, actual) {
|
|
62
|
+
return RANK[actual] < RANK[requested];
|
|
63
|
+
}
|
package/dist/link.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { RoomInvitation } from './invitation.js';
|
|
2
|
+
import type { RoomPolicy } from './types.js';
|
|
3
|
+
export { safeIceUrls, safeRelayUrls } from './network-hints.js';
|
|
4
|
+
/** A normal room link is small enough for a QR code. This generous ceiling
|
|
5
|
+
* stops a fragment from becoming an unbounded base64/JSON allocation. */
|
|
6
|
+
export declare const MAX_ROOM_LINK_FRAGMENT_LENGTH = 16384;
|
|
7
|
+
/**
|
|
8
|
+
* Everything a room link carries, whichever version it is.
|
|
9
|
+
*
|
|
10
|
+
* The app has always written its own envelope around the library's join
|
|
11
|
+
* payload - relay hints, ICE hints, a pairing code - and read it back with
|
|
12
|
+
* a hand of helpers in the page. An agent joining from a Node process needs
|
|
13
|
+
* to read exactly the same link a person was sent, so the envelope is a
|
|
14
|
+
* library concern from here on: one parser, shared by the page and by
|
|
15
|
+
* anything else that is handed a link.
|
|
16
|
+
*
|
|
17
|
+
* Exactly one of `invitation` (v2 live rendezvous or v3 stored group) and
|
|
18
|
+
* `secret` (legacy version 1, the room traffic secret itself) is present.
|
|
19
|
+
*/
|
|
20
|
+
export interface RoomLink {
|
|
21
|
+
/** V2 meeting invitation or v3 persistent group invitation. */
|
|
22
|
+
invitation?: RoomInvitation;
|
|
23
|
+
/** Version 1: the room traffic secret, in the link. Legacy. */
|
|
24
|
+
secret?: Uint8Array;
|
|
25
|
+
/** Relay hints. Empty means the reader's defaults. */
|
|
26
|
+
relays: string[];
|
|
27
|
+
/** STUN/TURN hints. Empty means the reader's defaults. */
|
|
28
|
+
iceUrls: string[];
|
|
29
|
+
/** The room's admission rule, when it has one. */
|
|
30
|
+
policy?: RoomPolicy;
|
|
31
|
+
/** A one-off pairing code, on a link that adds a device. Never a key. */
|
|
32
|
+
pairingCode?: Uint8Array;
|
|
33
|
+
/**
|
|
34
|
+
* What the room is called, when whoever made the link gave it a name.
|
|
35
|
+
*
|
|
36
|
+
* A label for people, on the same terms as a display name: self-asserted,
|
|
37
|
+
* bounded by `sanitiseDisplayName`, and never load-bearing. It rides in
|
|
38
|
+
* the link because the link is the one thing every member of a room was
|
|
39
|
+
* handed, so a room a person is in on three devices is called the same
|
|
40
|
+
* thing on all of them. Absent when nobody named it.
|
|
41
|
+
*/
|
|
42
|
+
name?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read a room link. Throws on a link that does not parse, and on one that
|
|
46
|
+
* carries an admission rule it cannot read - a dropped rule is an open
|
|
47
|
+
* room, and failing open is the mistake carrying the rule was meant to
|
|
48
|
+
* prevent.
|
|
49
|
+
*/
|
|
50
|
+
export declare function parseRoomLink(url: string): RoomLink;
|
|
51
|
+
/** Write a room link, in the same envelope the app writes. */
|
|
52
|
+
export declare function encodeRoomLink(base: string, link: RoomLink): string;
|
package/dist/link.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { base64urlnopad } from '@scure/base';
|
|
2
|
+
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
|
3
|
+
import { parseRoomPolicy } from './room.js';
|
|
4
|
+
import { roomInvitation } from './invitation.js';
|
|
5
|
+
import { sanitiseDisplayName } from './display-name.js';
|
|
6
|
+
import { assertNetworkHintBounds, safeIceUrls, safeRelayUrls } from './network-hints.js';
|
|
7
|
+
export { safeIceUrls, safeRelayUrls } from './network-hints.js';
|
|
8
|
+
/** A normal room link is small enough for a QR code. This generous ceiling
|
|
9
|
+
* stops a fragment from becoming an unbounded base64/JSON allocation. */
|
|
10
|
+
export const MAX_ROOM_LINK_FRAGMENT_LENGTH = 16_384;
|
|
11
|
+
/**
|
|
12
|
+
* Read a room link. Throws on a link that does not parse, and on one that
|
|
13
|
+
* carries an admission rule it cannot read - a dropped rule is an open
|
|
14
|
+
* room, and failing open is the mistake carrying the rule was meant to
|
|
15
|
+
* prevent.
|
|
16
|
+
*/
|
|
17
|
+
export function parseRoomLink(url) {
|
|
18
|
+
const hash = new URL(url).hash.slice(1);
|
|
19
|
+
if (!hash)
|
|
20
|
+
throw new Error('join URL has no fragment');
|
|
21
|
+
if (hash.length > MAX_ROOM_LINK_FRAGMENT_LENGTH)
|
|
22
|
+
throw new Error('join URL fragment is too large');
|
|
23
|
+
let payload;
|
|
24
|
+
try {
|
|
25
|
+
payload = JSON.parse(new TextDecoder().decode(base64urlnopad.decode(hash)));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw new Error('join URL fragment is not valid');
|
|
29
|
+
}
|
|
30
|
+
if (typeof payload !== 'object' || payload === null)
|
|
31
|
+
throw new Error('join URL fragment is not valid');
|
|
32
|
+
if (payload.v !== undefined && payload.v !== 2 && payload.v !== 3)
|
|
33
|
+
throw new Error('join URL uses an unsupported version');
|
|
34
|
+
assertNetworkHintBounds(payload.r, payload.i);
|
|
35
|
+
const link = {
|
|
36
|
+
relays: safeRelayUrls(payload.r),
|
|
37
|
+
iceUrls: safeIceUrls(payload.i),
|
|
38
|
+
};
|
|
39
|
+
const policy = parseRoomPolicy(payload.a);
|
|
40
|
+
if (policy)
|
|
41
|
+
link.policy = policy;
|
|
42
|
+
// Sanitised on the way in like a display name, because a link is text a
|
|
43
|
+
// stranger wrote and a room name lands in the same places a name does.
|
|
44
|
+
const name = sanitiseDisplayName(typeof payload.n === 'string' ? payload.n : undefined);
|
|
45
|
+
if (name !== undefined)
|
|
46
|
+
link.name = name;
|
|
47
|
+
if (typeof payload.c === 'string') {
|
|
48
|
+
try {
|
|
49
|
+
link.pairingCode = hexToBytes(payload.c);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new Error('join URL carries a malformed pairing code');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (payload.v === 2 || payload.v === 3) {
|
|
56
|
+
if (typeof payload.j !== 'string' || typeof payload.h !== 'string') {
|
|
57
|
+
throw new Error('join URL carries a malformed invitation');
|
|
58
|
+
}
|
|
59
|
+
let bearer;
|
|
60
|
+
try {
|
|
61
|
+
bearer = base64urlnopad.decode(payload.j);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new Error('join URL carries a malformed invitation');
|
|
65
|
+
}
|
|
66
|
+
link.invitation = roomInvitation(bearer, payload.h, payload.v === 3);
|
|
67
|
+
return link;
|
|
68
|
+
}
|
|
69
|
+
if (typeof payload.s !== 'string')
|
|
70
|
+
throw new Error('join URL carries neither an invitation nor a secret');
|
|
71
|
+
let secret;
|
|
72
|
+
try {
|
|
73
|
+
secret = base64urlnopad.decode(payload.s);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new Error('join URL carries a malformed secret');
|
|
77
|
+
}
|
|
78
|
+
if (secret.length !== 32)
|
|
79
|
+
throw new Error('join URL carries a malformed secret');
|
|
80
|
+
link.secret = secret;
|
|
81
|
+
return link;
|
|
82
|
+
}
|
|
83
|
+
/** Write a room link, in the same envelope the app writes. */
|
|
84
|
+
export function encodeRoomLink(base, link) {
|
|
85
|
+
assertNetworkHintBounds(link.relays, link.iceUrls);
|
|
86
|
+
const relays = safeRelayUrls(link.relays);
|
|
87
|
+
const iceUrls = safeIceUrls(link.iceUrls);
|
|
88
|
+
const payload = link.invitation
|
|
89
|
+
? { v: link.invitation.persistent ? 3 : 2, j: base64urlnopad.encode(link.invitation.bearer), h: link.invitation.inviter, r: relays, i: iceUrls }
|
|
90
|
+
: { s: base64urlnopad.encode(link.secret ?? invalid()), r: relays, i: iceUrls };
|
|
91
|
+
if (link.policy)
|
|
92
|
+
payload.a = link.policy;
|
|
93
|
+
if (link.pairingCode)
|
|
94
|
+
payload.c = bytesToHex(link.pairingCode);
|
|
95
|
+
// Written only when there is one, so a link to an unnamed room is
|
|
96
|
+
// byte-identical to one written before rooms had names.
|
|
97
|
+
const name = sanitiseDisplayName(link.name);
|
|
98
|
+
if (name !== undefined)
|
|
99
|
+
payload.n = name;
|
|
100
|
+
const fragment = base64urlnopad.encode(new TextEncoder().encode(JSON.stringify(payload)));
|
|
101
|
+
if (fragment.length > MAX_ROOM_LINK_FRAGMENT_LENGTH)
|
|
102
|
+
throw new Error('room link fragment is too large');
|
|
103
|
+
return `${base}#${fragment}`;
|
|
104
|
+
}
|
|
105
|
+
function invalid() {
|
|
106
|
+
throw new Error('a room link needs an invitation or a secret');
|
|
107
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Hard bounds for network addresses carried by an untrusted room link or
|
|
2
|
+
* descriptor. A handful of alternatives is useful; hundreds only spend
|
|
3
|
+
* parser, DNS and connection resources. */
|
|
4
|
+
export declare const MAX_RELAY_HINTS = 8;
|
|
5
|
+
export declare const MAX_ICE_HINTS = 8;
|
|
6
|
+
export declare const MAX_NETWORK_HINT_LENGTH = 2048;
|
|
7
|
+
/** Public relay hints must use encrypted WebSockets. Plain ws is retained
|
|
8
|
+
* only for loopback development and tests, where TLS adds no transport
|
|
9
|
+
* boundary. */
|
|
10
|
+
export declare function isSafeRelayUrl(raw: string): boolean;
|
|
11
|
+
export declare function safeRelayUrls(raw: unknown): string[];
|
|
12
|
+
export declare function isSafeIceUrl(raw: string): boolean;
|
|
13
|
+
export declare function safeIceUrls(raw: unknown): string[];
|
|
14
|
+
/** Refuse oversized arrays instead of silently turning an attacker's first
|
|
15
|
+
* few entries into connection attempts. */
|
|
16
|
+
export declare function assertNetworkHintBounds(relays: unknown, ice: unknown): void;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Hard bounds for network addresses carried by an untrusted room link or
|
|
2
|
+
* descriptor. A handful of alternatives is useful; hundreds only spend
|
|
3
|
+
* parser, DNS and connection resources. */
|
|
4
|
+
export const MAX_RELAY_HINTS = 8;
|
|
5
|
+
export const MAX_ICE_HINTS = 8;
|
|
6
|
+
export const MAX_NETWORK_HINT_LENGTH = 2048;
|
|
7
|
+
const ICE_SCHEMES = ['stun:', 'stuns:', 'turn:', 'turns:'];
|
|
8
|
+
function loopback(hostname) {
|
|
9
|
+
const host = hostname.toLowerCase();
|
|
10
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1';
|
|
11
|
+
}
|
|
12
|
+
/** Public relay hints must use encrypted WebSockets. Plain ws is retained
|
|
13
|
+
* only for loopback development and tests, where TLS adds no transport
|
|
14
|
+
* boundary. */
|
|
15
|
+
export function isSafeRelayUrl(raw) {
|
|
16
|
+
if (raw.length === 0 || raw.length > MAX_NETWORK_HINT_LENGTH)
|
|
17
|
+
return false;
|
|
18
|
+
try {
|
|
19
|
+
const parsed = new URL(raw);
|
|
20
|
+
return parsed.protocol === 'wss:' || (parsed.protocol === 'ws:' && loopback(parsed.hostname));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function safeRelayUrls(raw) {
|
|
27
|
+
if (!Array.isArray(raw))
|
|
28
|
+
return [];
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const value of raw) {
|
|
31
|
+
if (typeof value !== 'string' || !isSafeRelayUrl(value) || out.includes(value))
|
|
32
|
+
continue;
|
|
33
|
+
if (out.length === MAX_RELAY_HINTS)
|
|
34
|
+
break;
|
|
35
|
+
out.push(value);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
export function isSafeIceUrl(raw) {
|
|
40
|
+
if (raw.length === 0 || raw.length > MAX_NETWORK_HINT_LENGTH)
|
|
41
|
+
return false;
|
|
42
|
+
const lower = raw.toLowerCase();
|
|
43
|
+
return ICE_SCHEMES.some((scheme) => lower.startsWith(scheme));
|
|
44
|
+
}
|
|
45
|
+
export function safeIceUrls(raw) {
|
|
46
|
+
if (!Array.isArray(raw))
|
|
47
|
+
return [];
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const value of raw) {
|
|
50
|
+
if (typeof value !== 'string' || !isSafeIceUrl(value) || out.includes(value))
|
|
51
|
+
continue;
|
|
52
|
+
if (out.length === MAX_ICE_HINTS)
|
|
53
|
+
break;
|
|
54
|
+
out.push(value);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** Refuse oversized arrays instead of silently turning an attacker's first
|
|
59
|
+
* few entries into connection attempts. */
|
|
60
|
+
export function assertNetworkHintBounds(relays, ice) {
|
|
61
|
+
if (Array.isArray(relays) && relays.length > MAX_RELAY_HINTS)
|
|
62
|
+
throw new Error('join URL carries too many relay hints');
|
|
63
|
+
if (Array.isArray(ice) && ice.length > MAX_ICE_HINTS)
|
|
64
|
+
throw new Error('join URL carries too many ICE hints');
|
|
65
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Event } from 'nostr-tools/pure';
|
|
2
|
+
import { type RoomInvitation } from './invitation.js';
|
|
3
|
+
import type { RelayTransport } from './transport.js';
|
|
4
|
+
/** Durable membership is distinct from temporary permission to admit others.
|
|
5
|
+
* No inviter or delegated signing key is handed to a group member. */
|
|
6
|
+
export interface PersistentRoomAdmission {
|
|
7
|
+
secret: Uint8Array;
|
|
8
|
+
persistent: true;
|
|
9
|
+
epoch: 0;
|
|
10
|
+
}
|
|
11
|
+
/** Publish once before sharing. The link plus this envelope is a durable
|
|
12
|
+
* bearer capability: anybody holding both can learn epoch 0. Retirement
|
|
13
|
+
* tells cooperative clients to stop admitting; it cannot erase copies. */
|
|
14
|
+
export declare function encodePersistentInvitation(opts: {
|
|
15
|
+
invitation: RoomInvitation;
|
|
16
|
+
inviterSk: Uint8Array;
|
|
17
|
+
roomSecret: Uint8Array;
|
|
18
|
+
now: number;
|
|
19
|
+
}): Event;
|
|
20
|
+
export declare function decodePersistentInvitation(event: Event, invitation: RoomInvitation): PersistentRoomAdmission | null;
|
|
21
|
+
/** Fetch the signed envelope and retirement in ONE stored-event query.
|
|
22
|
+
* Wait for EOSE even if the welcome arrives first: a tombstone replayed
|
|
23
|
+
* later in the same result must win. A timeout never admits on partial
|
|
24
|
+
* results. Relays remain an availability dependency, as they are for chat;
|
|
25
|
+
* no member or keeper has to answer a live request. */
|
|
26
|
+
export declare function requestPersistentRoomAdmission(opts: {
|
|
27
|
+
transport: RelayTransport;
|
|
28
|
+
invitation: RoomInvitation;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
}): Promise<PersistentRoomAdmission>;
|
|
31
|
+
/** Every wire-format literal this module owns (each one a kithmoot protocol string), frozen for
|
|
32
|
+
* `src/labels.test.ts`, which checks each module against its own exported
|
|
33
|
+
* list rather than scanning file text for matching comments. Pure data -
|
|
34
|
+
* adding this export changes no runtime behaviour. */
|
|
35
|
+
export declare const PERSISTENT_INVITATION_LABELS: readonly ["kithmoot/v3/group-invitation-key"];
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { hkdf } from '@noble/hashes/hkdf';
|
|
2
|
+
import { sha256 } from '@noble/hashes/sha2';
|
|
3
|
+
import { base64urlnopad } from '@scure/base';
|
|
4
|
+
import { nip44 } from 'nostr-tools';
|
|
5
|
+
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
|
6
|
+
import { decodeInvitationRetirementNotice, deriveInvitationId, retirementError } from './invitation.js';
|
|
7
|
+
import { KINDS } from './kinds.js';
|
|
8
|
+
import { deriveRoom } from './room.js';
|
|
9
|
+
import { verifyEventUncached } from './verify.js';
|
|
10
|
+
function welcomeKey(invitation) {
|
|
11
|
+
if (!invitation.persistent)
|
|
12
|
+
throw new Error('a persistent group invitation is required');
|
|
13
|
+
if (invitation.bearer.length !== 32)
|
|
14
|
+
throw new Error('invitation bearer must be 32 bytes');
|
|
15
|
+
return hkdf(sha256, invitation.bearer, undefined, 'kithmoot/v3/group-invitation-key', 32);
|
|
16
|
+
}
|
|
17
|
+
/** Publish once before sharing. The link plus this envelope is a durable
|
|
18
|
+
* bearer capability: anybody holding both can learn epoch 0. Retirement
|
|
19
|
+
* tells cooperative clients to stop admitting; it cannot erase copies. */
|
|
20
|
+
export function encodePersistentInvitation(opts) {
|
|
21
|
+
if (getPublicKey(opts.inviterSk) !== opts.invitation.inviter)
|
|
22
|
+
throw new Error('only the inviter can publish a group invitation');
|
|
23
|
+
const room = deriveRoom(opts.roomSecret).roomId;
|
|
24
|
+
return finalizeEvent({
|
|
25
|
+
kind: KINDS.GROUP_INVITATION,
|
|
26
|
+
created_at: opts.now,
|
|
27
|
+
tags: [['d', deriveInvitationId(opts.invitation)]],
|
|
28
|
+
content: nip44.v2.encrypt(JSON.stringify({
|
|
29
|
+
v: 3, room, secret: base64urlnopad.encode(opts.roomSecret),
|
|
30
|
+
}), welcomeKey(opts.invitation)),
|
|
31
|
+
}, opts.inviterSk);
|
|
32
|
+
}
|
|
33
|
+
export function decodePersistentInvitation(event, invitation) {
|
|
34
|
+
try {
|
|
35
|
+
if (event.kind !== KINDS.GROUP_INVITATION || event.pubkey !== invitation.inviter)
|
|
36
|
+
return null;
|
|
37
|
+
if (!verifyEventUncached(event))
|
|
38
|
+
return null;
|
|
39
|
+
if (event.tags.filter(t => t[0] === 'd').length !== 1 ||
|
|
40
|
+
event.tags.find(t => t[0] === 'd')?.[1] !== deriveInvitationId(invitation))
|
|
41
|
+
return null;
|
|
42
|
+
const body = JSON.parse(nip44.v2.decrypt(event.content, welcomeKey(invitation)));
|
|
43
|
+
if (body.v !== 3 || typeof body.secret !== 'string')
|
|
44
|
+
return null;
|
|
45
|
+
const secret = base64urlnopad.decode(body.secret);
|
|
46
|
+
if (deriveRoom(secret).roomId !== body.room)
|
|
47
|
+
return null;
|
|
48
|
+
return { secret, persistent: true, epoch: 0 };
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Fetch the signed envelope and retirement in ONE stored-event query.
|
|
55
|
+
* Wait for EOSE even if the welcome arrives first: a tombstone replayed
|
|
56
|
+
* later in the same result must win. A timeout never admits on partial
|
|
57
|
+
* results. Relays remain an availability dependency, as they are for chat;
|
|
58
|
+
* no member or keeper has to answer a live request. */
|
|
59
|
+
export function requestPersistentRoomAdmission(opts) {
|
|
60
|
+
if (!opts.invitation.persistent)
|
|
61
|
+
return Promise.reject(new Error('a persistent group invitation is required'));
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
let settled = false;
|
|
64
|
+
let admission;
|
|
65
|
+
let unsub = () => { };
|
|
66
|
+
const timer = setTimeout(() => finish(new Error('the group invitation could not be loaded from its relays')), opts.timeoutMs ?? 15_000);
|
|
67
|
+
function finish(error) {
|
|
68
|
+
if (settled)
|
|
69
|
+
return;
|
|
70
|
+
settled = true;
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
unsub();
|
|
73
|
+
if (error)
|
|
74
|
+
reject(error);
|
|
75
|
+
else
|
|
76
|
+
resolve(admission);
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
unsub = opts.transport.subscribe([{
|
|
80
|
+
kinds: [KINDS.GROUP_INVITATION, KINDS.INVITATION_RETIREMENT],
|
|
81
|
+
authors: [opts.invitation.inviter], '#d': [deriveInvitationId(opts.invitation)],
|
|
82
|
+
}], event => {
|
|
83
|
+
if (settled)
|
|
84
|
+
return;
|
|
85
|
+
const retired = decodeInvitationRetirementNotice(event, opts.invitation);
|
|
86
|
+
if (retired) {
|
|
87
|
+
finish(retirementError(retired));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const decoded = decodePersistentInvitation(event, opts.invitation);
|
|
91
|
+
if (!decoded)
|
|
92
|
+
return;
|
|
93
|
+
if (admission && deriveRoom(admission.secret).roomId !== deriveRoom(decoded.secret).roomId) {
|
|
94
|
+
finish(new Error('the group invitation names conflicting rooms'));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
admission = decoded;
|
|
98
|
+
}, () => {
|
|
99
|
+
if (admission)
|
|
100
|
+
finish();
|
|
101
|
+
else
|
|
102
|
+
finish(new Error('the group invitation is not available on its relays'));
|
|
103
|
+
});
|
|
104
|
+
if (settled)
|
|
105
|
+
unsub();
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/** Every wire-format literal this module owns (each one a kithmoot protocol string), frozen for
|
|
113
|
+
* `src/labels.test.ts`, which checks each module against its own exported
|
|
114
|
+
* list rather than scanning file text for matching comments. Pure data -
|
|
115
|
+
* adding this export changes no runtime behaviour. */
|
|
116
|
+
export const PERSISTENT_INVITATION_LABELS = [
|
|
117
|
+
"kithmoot/v3/group-invitation-key",
|
|
118
|
+
];
|
package/dist/room.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { RoomPolicy } from './types.js';
|
|
2
|
+
/** Generate a fresh 32-byte room traffic secret.
|
|
3
|
+
*
|
|
4
|
+
* Legacy v1 links carry this directly. New app links exchange it through
|
|
5
|
+
* `invitation.ts` and never expose it in the URL. */
|
|
6
|
+
export declare function generateRoomSecret(): Uint8Array;
|
|
7
|
+
/**
|
|
8
|
+
* Derive the public room identifier and the symmetric room key from the secret.
|
|
9
|
+
*
|
|
10
|
+
* The two are derived with separate HKDF info strings so that publishing the
|
|
11
|
+
* room id (which relays necessarily see) reveals nothing about the room key.
|
|
12
|
+
*/
|
|
13
|
+
export declare function deriveRoom(secret: Uint8Array): {
|
|
14
|
+
roomId: string;
|
|
15
|
+
roomKey: Uint8Array;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Parse an access policy out of a URL fragment, which is untrusted input.
|
|
19
|
+
*
|
|
20
|
+
* A policy that does not parse is refused rather than silently dropped: a
|
|
21
|
+
* dropped policy is an open room, and failing open is exactly the mistake
|
|
22
|
+
* carrying the policy in the capability is meant to prevent.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseRoomPolicy(raw: unknown): RoomPolicy | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Build a legacy v1 join URL. The secret and relay hints live in the fragment, which
|
|
27
|
+
* browsers never transmit to the server - the host of kithmoot.com learns
|
|
28
|
+
* nothing about which room you are joining.
|
|
29
|
+
*
|
|
30
|
+
* Encoding uses @scure/base and TextEncoder/TextDecoder rather than a
|
|
31
|
+
* Node-only global: this library ships to a browser PWA as well as to Node,
|
|
32
|
+
* and Node-only globals do not exist there.
|
|
33
|
+
*
|
|
34
|
+
* The access policy rides here too. It is deliberately NOT a room descriptor
|
|
35
|
+
* event on a relay: the URL is the capability, so everyone who joins holds
|
|
36
|
+
* the same policy bytes and agreement is structural, with nothing to fetch
|
|
37
|
+
* before a member can check another member's tier and no durable artefact of
|
|
38
|
+
* the room on any relay. The cost is that the policy cannot change mid-room -
|
|
39
|
+
* a new rule is a new link. See `docs/decisions.md`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function encodeJoinUrl(base: string, secret: Uint8Array, relays: string[], policy?: RoomPolicy): string;
|
|
42
|
+
/** Decode a legacy v1 room-secret link. Version 2 invitation links are
|
|
43
|
+
* resolved by `invitation.ts` plus the app's URL envelope. */
|
|
44
|
+
export declare function decodeJoinUrl(url: string): {
|
|
45
|
+
secret: Uint8Array;
|
|
46
|
+
relays: string[];
|
|
47
|
+
policy?: RoomPolicy;
|
|
48
|
+
};
|
|
49
|
+
/** Every wire-format literal this module owns (each one a kithmoot protocol string), frozen for
|
|
50
|
+
* `src/labels.test.ts`, which checks each module against its own exported
|
|
51
|
+
* list rather than scanning file text for matching comments. Pure data -
|
|
52
|
+
* adding this export changes no runtime behaviour. */
|
|
53
|
+
export declare const ROOM_LABELS: readonly ["kithmoot/v1/room-id", "kithmoot/v1/room-key"];
|
package/dist/room.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { hkdf } from '@noble/hashes/hkdf';
|
|
2
|
+
import { sha256 } from '@noble/hashes/sha2';
|
|
3
|
+
import { randomBytes } from '@noble/hashes/utils';
|
|
4
|
+
import { base64urlnopad } from '@scure/base';
|
|
5
|
+
import { normaliseHex } from './hex.js';
|
|
6
|
+
import { MAX_RELAY_HINTS, safeRelayUrls } from './network-hints.js';
|
|
7
|
+
const ROOM_ID_INFO = 'kithmoot/v1/room-id';
|
|
8
|
+
const ROOM_KEY_INFO = 'kithmoot/v1/room-key';
|
|
9
|
+
const utf8Encoder = new TextEncoder();
|
|
10
|
+
const utf8Decoder = new TextDecoder();
|
|
11
|
+
/** Generate a fresh 32-byte room traffic secret.
|
|
12
|
+
*
|
|
13
|
+
* Legacy v1 links carry this directly. New app links exchange it through
|
|
14
|
+
* `invitation.ts` and never expose it in the URL. */
|
|
15
|
+
export function generateRoomSecret() {
|
|
16
|
+
return randomBytes(32);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Derive the public room identifier and the symmetric room key from the secret.
|
|
20
|
+
*
|
|
21
|
+
* The two are derived with separate HKDF info strings so that publishing the
|
|
22
|
+
* room id (which relays necessarily see) reveals nothing about the room key.
|
|
23
|
+
*/
|
|
24
|
+
export function deriveRoom(secret) {
|
|
25
|
+
if (secret.length !== 32)
|
|
26
|
+
throw new Error('room secret must be 32 bytes');
|
|
27
|
+
const idBytes = hkdf(sha256, secret, undefined, ROOM_ID_INFO, 32);
|
|
28
|
+
const roomKey = hkdf(sha256, secret, undefined, ROOM_KEY_INFO, 32);
|
|
29
|
+
const roomId = Array.from(idBytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
30
|
+
return { roomId, roomKey };
|
|
31
|
+
}
|
|
32
|
+
const TIERS = ['open', 'ken', 'kith', 'kin'];
|
|
33
|
+
const AGENT_RULES = ['owned-by-members'];
|
|
34
|
+
const MAX_POLICY_KEYS = 256;
|
|
35
|
+
/**
|
|
36
|
+
* Parse an access policy out of a URL fragment, which is untrusted input.
|
|
37
|
+
*
|
|
38
|
+
* A policy that does not parse is refused rather than silently dropped: a
|
|
39
|
+
* dropped policy is an open room, and failing open is exactly the mistake
|
|
40
|
+
* carrying the policy in the capability is meant to prevent.
|
|
41
|
+
*/
|
|
42
|
+
export function parseRoomPolicy(raw) {
|
|
43
|
+
if (raw === undefined || raw === null)
|
|
44
|
+
return undefined;
|
|
45
|
+
if (typeof raw !== 'object')
|
|
46
|
+
throw new Error('join URL carries a malformed access policy');
|
|
47
|
+
const policy = raw;
|
|
48
|
+
if (!TIERS.includes(policy.tier))
|
|
49
|
+
throw new Error('join URL carries an access policy at an unknown tier');
|
|
50
|
+
if (policy.admitted !== undefined) {
|
|
51
|
+
if (!Array.isArray(policy.admitted) || policy.admitted.length > MAX_POLICY_KEYS ||
|
|
52
|
+
!policy.admitted.every((a) => typeof a === 'string' && /^[0-9a-fA-F]{64}$/.test(a))) {
|
|
53
|
+
throw new Error('join URL carries a malformed access policy');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// A members list is a closed door, so a malformed one is refused rather
|
|
57
|
+
// than dropped: dropping it would open the room to anybody with the link.
|
|
58
|
+
if (policy.members !== undefined) {
|
|
59
|
+
if (!Array.isArray(policy.members) || policy.members.length === 0 || policy.members.length > MAX_POLICY_KEYS ||
|
|
60
|
+
!policy.members.every((m) => typeof m === 'string' && /^[0-9a-fA-F]{64}$/.test(m))) {
|
|
61
|
+
throw new Error('join URL carries a malformed members list');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// An agent rule this reader does not know is refused for the same reason
|
|
65
|
+
// an unknown tier is: dropping it would admit what the room meant to keep
|
|
66
|
+
// out.
|
|
67
|
+
if (policy.agents !== undefined && !AGENT_RULES.includes(policy.agents)) {
|
|
68
|
+
throw new Error('join URL carries an access policy with an unknown agent rule');
|
|
69
|
+
}
|
|
70
|
+
// A quiet room without a members list has nobody to derive keys for, and
|
|
71
|
+
// a quiet flag in any shape but `true` is a reader that would talk in the
|
|
72
|
+
// open while the rest of the room talks in drops. Both refused.
|
|
73
|
+
if (policy.quiet !== undefined && (policy.quiet !== true || policy.members === undefined)) {
|
|
74
|
+
throw new Error('join URL asks for a quiet room without a members list');
|
|
75
|
+
}
|
|
76
|
+
// The allow-list is exactly the case this rule was written for: entries a
|
|
77
|
+
// person typed or pasted into a link. Canonicalise here, at the point they
|
|
78
|
+
// enter the system off the URL, rather than relying on every reader to
|
|
79
|
+
// compare them case-insensitively.
|
|
80
|
+
const parsed = policy.admitted
|
|
81
|
+
? { tier: policy.tier, admitted: policy.admitted.map(normaliseHex) }
|
|
82
|
+
: { tier: policy.tier };
|
|
83
|
+
if (policy.agents !== undefined)
|
|
84
|
+
parsed.agents = policy.agents;
|
|
85
|
+
if (policy.members !== undefined)
|
|
86
|
+
parsed.members = [...new Set(policy.members.map(normaliseHex))];
|
|
87
|
+
if (policy.quiet === true)
|
|
88
|
+
parsed.quiet = true;
|
|
89
|
+
return parsed;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Build a legacy v1 join URL. The secret and relay hints live in the fragment, which
|
|
93
|
+
* browsers never transmit to the server - the host of kithmoot.com learns
|
|
94
|
+
* nothing about which room you are joining.
|
|
95
|
+
*
|
|
96
|
+
* Encoding uses @scure/base and TextEncoder/TextDecoder rather than a
|
|
97
|
+
* Node-only global: this library ships to a browser PWA as well as to Node,
|
|
98
|
+
* and Node-only globals do not exist there.
|
|
99
|
+
*
|
|
100
|
+
* The access policy rides here too. It is deliberately NOT a room descriptor
|
|
101
|
+
* event on a relay: the URL is the capability, so everyone who joins holds
|
|
102
|
+
* the same policy bytes and agreement is structural, with nothing to fetch
|
|
103
|
+
* before a member can check another member's tier and no durable artefact of
|
|
104
|
+
* the room on any relay. The cost is that the policy cannot change mid-room -
|
|
105
|
+
* a new rule is a new link. See `docs/decisions.md`.
|
|
106
|
+
*/
|
|
107
|
+
export function encodeJoinUrl(base, secret, relays, policy) {
|
|
108
|
+
if (secret.length !== 32)
|
|
109
|
+
throw new Error('room secret must be 32 bytes');
|
|
110
|
+
const payload = { s: base64urlnopad.encode(secret), r: relays };
|
|
111
|
+
if (policy)
|
|
112
|
+
payload.a = policy;
|
|
113
|
+
const encoded = base64urlnopad.encode(utf8Encoder.encode(JSON.stringify(payload)));
|
|
114
|
+
return `${base}#${encoded}`;
|
|
115
|
+
}
|
|
116
|
+
/** Decode a legacy v1 room-secret link. Version 2 invitation links are
|
|
117
|
+
* resolved by `invitation.ts` plus the app's URL envelope. */
|
|
118
|
+
export function decodeJoinUrl(url) {
|
|
119
|
+
const hash = new URL(url).hash.slice(1);
|
|
120
|
+
if (!hash)
|
|
121
|
+
throw new Error('join URL has no fragment');
|
|
122
|
+
if (hash.length > 16_384)
|
|
123
|
+
throw new Error('join URL fragment is too large');
|
|
124
|
+
let payload;
|
|
125
|
+
try {
|
|
126
|
+
payload = JSON.parse(utf8Decoder.decode(base64urlnopad.decode(hash)));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new Error('join URL fragment is not valid');
|
|
130
|
+
}
|
|
131
|
+
let secret;
|
|
132
|
+
try {
|
|
133
|
+
secret = base64urlnopad.decode(payload.s);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
throw new Error('join URL carries a malformed secret');
|
|
137
|
+
}
|
|
138
|
+
if (secret.length !== 32)
|
|
139
|
+
throw new Error('join URL carries a malformed secret');
|
|
140
|
+
if (Array.isArray(payload.r) && payload.r.length > MAX_RELAY_HINTS)
|
|
141
|
+
throw new Error('join URL carries too many relay hints');
|
|
142
|
+
const policy = parseRoomPolicy(payload.a);
|
|
143
|
+
const relays = safeRelayUrls(payload.r);
|
|
144
|
+
return policy ? { secret, relays, policy } : { secret, relays };
|
|
145
|
+
}
|
|
146
|
+
/** Every wire-format literal this module owns (each one a kithmoot protocol string), frozen for
|
|
147
|
+
* `src/labels.test.ts`, which checks each module against its own exported
|
|
148
|
+
* list rather than scanning file text for matching comments. Pure data -
|
|
149
|
+
* adding this export changes no runtime behaviour. */
|
|
150
|
+
export const ROOM_LABELS = [
|
|
151
|
+
"kithmoot/v1/room-id",
|
|
152
|
+
"kithmoot/v1/room-key",
|
|
153
|
+
];
|