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