@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 CHANGED
@@ -5,12 +5,140 @@ per epoch, sealed to each device that stays; link invitations as
5
5
  capabilities; member removal by rekey; device credentials so a person
6
6
  approves a device once and it signs from then on.
7
7
 
8
- Fold-kit is being extracted from [KithMoot](https://github.com/forgesworn/kithmoot)
8
+ Fold-kit is extracted from [KithMoot](https://github.com/forgesworn/kithmoot)
9
9
  so that other ForgeSworn clients can share one circle model. KithMoot's wire
10
- format does not change: every derivation label and event shape moves
11
- byte-identical and is pinned by known-answer vectors.
10
+ format does not change: every derivation label and event shape moved
11
+ byte-identical from the pinned source commit and is pinned by known-answer
12
+ vectors (see EXTRACTION.md). Framework-free: owns no storage, UI, relay
13
+ defaults or identity keys, and no relay I/O of its own - callers inject a
14
+ transport.
12
15
 
13
- **Status: extraction in progress. No published package yet.**
16
+ **Status:** T1 extraction complete (see EXTRACTION.md). Not yet published to
17
+ npm under this API; pin an immutable Git commit until it is (see Install).
18
+ KithMoot itself has not cut over to this kit yet - it still carries its own
19
+ copy of these modules.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm install @forgesworn/fold-kit
25
+ ```
26
+
27
+ Not yet published to npm. Until it is, pin an immutable Git commit:
28
+
29
+ ```json
30
+ {
31
+ "dependencies": {
32
+ "@forgesworn/fold-kit": "github:forgesworn/fold-kit#<commit>"
33
+ }
34
+ }
35
+ ```
36
+
37
+ ### Peer dependencies
38
+
39
+ ```json
40
+ {
41
+ "peerDependencies": {
42
+ "nostr-tools": ">=2.24.2 <3",
43
+ "@noble/hashes": "^1.8.0",
44
+ "@noble/curves": "^2.0.1"
45
+ }
46
+ }
47
+ ```
48
+
49
+ Peers, not dependencies, so a consumer such as KithMoot keeps a single copy
50
+ of `nostr-tools` (guarded by its own `overrides` and version-guard test) and
51
+ of the noble libraries, at the same versions KithMoot already pins.
52
+ `@noble/hashes` is pinned to its 1.x major only: its 2.x major changed its
53
+ `hkdf` signature to reject a string `info` argument (this kit's HKDF calls
54
+ pass strings) and its `exports` map no longer resolves the bare subpaths
55
+ this kit and KithMoot both import without a `.js` suffix, so 2.x cannot
56
+ satisfy this kit's imports as written.
57
+
58
+ ESM only (`"type": "module"`). Node.js `>=22.13`.
59
+
60
+ ## Exports
61
+
62
+ Two entry points: `@forgesworn/fold-kit` (everything) and
63
+ `@forgesworn/fold-kit/lane` (just the lane helpers, for a consumer that only
64
+ wants to classify a relay as `public`/`sheltered`/`direct` without pulling in
65
+ the rest).
66
+
67
+ ### Identity, verification, hex
68
+
69
+ - `hexEquals`, `normaliseHex` - constant-shape hex comparison and lower-casing
70
+ - `verifyEventUncached`, `boundedEventVerifier` - Nostr event signature checks
71
+ - `localIdentity`, `ParticipantIdentity`, `UnsignedEvent` - the signer seam
72
+ every codec below is built on
73
+
74
+ ### Wire kinds and types
75
+
76
+ - `KINDS` - the circle layer's kind numbers (`CREDENTIAL`, `CHAT`,
77
+ `INVITATION_REQUEST`, `INVITATION_GRANT`, `INVITATION_RETIREMENT`,
78
+ `GROUP_INVITATION`, `ROOM_REKEY`, `EPOCH_REQUEST`, `EPOCH_GRANT`) - a
79
+ subset of KithMoot's full registry. `CHAT` (1460) is included because a
80
+ downstream app's own board events are designed to share KithMoot's chat
81
+ kind, so a relay cannot tell a board from a chat (see
82
+ docs/extraction-plan-excerpt.md).
83
+ - `DeviceCredential`, `AccessTier`, `AgentRule`, `RoomPolicy`, `KindredProof` -
84
+ the link-layer wire types
85
+ - `RelayTransport` - the transport seam (`publish`/`subscribe`/`close`) every
86
+ codec below that talks to a relay takes as a dependency. This kit declares
87
+ its own interface rather than importing KithMoot's relay pool, which has
88
+ not moved here (see EXTRACTION.md).
89
+
90
+ ### Rooms, credentials, access
91
+
92
+ - `generateRoomSecret`, `deriveRoom`, `encodeJoinUrl`, `decodeJoinUrl`,
93
+ `parseRoomPolicy` - the legacy v1 join URL and room id/key derivation
94
+ - `createDeviceCredential`, `verifyDeviceCredential`,
95
+ `PERSON_CREDENTIAL_MAX_SECONDS` - room-scope and person-scope device
96
+ credentials
97
+ - `issueKindredProof`, `evaluateAccess` - kindred-tier admission
98
+ - `sanitiseDisplayName`, `MAX_DISPLAY_NAME_LENGTH` - defused display names
99
+ - `safeRelayUrls`, `safeIceUrls`, `assertNetworkHintBounds`, and the related
100
+ `MAX_*` bounds - link envelope network hints
101
+
102
+ ### Links and invitations
103
+
104
+ - `parseRoomLink`, `encodeRoomLink`, `RoomLink` - the v1/v2/v3 link envelope
105
+ - `createRoomInvitation`, `roomInvitation`, `deriveInvitationId`,
106
+ `encodeInvitationRequest`, `decodeInvitationRequest`,
107
+ `verifyInvitationDelegation`, `encodeInvitationGrant`,
108
+ `decodeRoomAdmissionGrant`, `encodeInvitationRetirement`,
109
+ `decodeInvitationRetirementNotice`, `hostRoomInvitation`,
110
+ `requestRoomAdmission` - the v2 live rendezvous invitation, its delegation
111
+ chain and retirement
112
+ - `encodePersistentInvitation`, `decodePersistentInvitation`,
113
+ `requestPersistentRoomAdmission` - the v3 stored group invitation (1463)
114
+
115
+ ### Epochs (removal by rekey)
116
+
117
+ - `deriveEpoch`, `generateEpochSecret` - per-epoch id/key derivation
118
+ - `encodeRekeyEvent`, `peekRekeyEvent`, `decodeRekeyEvent` - the durable
119
+ rekey notice (1462), sealed per remaining device
120
+ - `encodeEpochRequest`, `decodeEpochRequest`, `encodeEpochGrant`,
121
+ `decodeEpochGrant`, `epochRequestAdmission`, `deriveEpochRequestKey` -
122
+ catch-up for a device that missed a rekey
123
+ - `hostRoomEpoch`, `requestRoomEpoch`, `EpochRefusedError` - the live
124
+ request/grant desk
125
+ - `canonicalAdmins`, `signAdmins`, `verifyAdmins` - the authority's signed
126
+ admin list
127
+ - `canonicalChannels`, `signChannels`, `verifyChannels`, `CHANNEL_NAME`,
128
+ `RESERVED_CHANNELS` - the authority's signed channel list
129
+
130
+ ### Channel (main entry, and `deriveChannel` only)
131
+
132
+ - `deriveChannel`, `CHANNEL_ID_INFO`, `CHANNEL_KEY_INFO`,
133
+ `MAX_CHANNEL_NAME_LENGTH` - a named channel's id/key, derived from the room
134
+ key. `ChatLog` and the chat event codecs stay in KithMoot; only the
135
+ derivation moved.
136
+
137
+ ### Lane (`@forgesworn/fold-kit/lane`)
138
+
139
+ - `LANES`, `LANE_MEANING`, `LANE_LABEL`, `LANE_GLYPH`, `isLane`,
140
+ `laneOfRelayUrl`, `laneOfRelays`, `weakestLane`, `isDowngrade` - classifies
141
+ a relay as `public`, `sheltered` or `direct`
14
142
 
15
143
  ## How it relates to its siblings
16
144
 
@@ -23,6 +151,28 @@ byte-identical and is pinned by known-answer vectors.
23
151
  collaborative documents, where a wrap per recipient per message is too
24
152
  costly.
25
153
 
154
+ Nothing is shared in code between the three today: the key models are
155
+ incompatible (a static epoch secret with sealed rekeys here, versus a
156
+ deterministic reseed from a root in covey-kit), the trust models differ
157
+ (pinned authority here versus caller-enforced roles in covey-kit), and
158
+ covey-kit/roost-kit pin a different `nostr-tools` version than this kit and
159
+ KithMoot share.
160
+
161
+ ## Development
162
+
163
+ ```bash
164
+ npm install
165
+ npm run build # compile TypeScript into dist/
166
+ npm test # run the Vitest suite, including both vector files
167
+ npm run typecheck # type-check src/ and test/ (matching KithMoot, vectors/ is not tsc-checked)
168
+ npm run vectors # run only the vector-verification suites
169
+ npm run diff-source # compare moved modules against the pinned source commit (needs FOLD_KIT_SOURCE_DIR)
170
+ npm run bundle-check # esbuild browser bundle check (needs a prior build)
171
+ npm run check # typecheck + test + diff-source
172
+ ```
173
+
174
+ There is no separate lint script.
175
+
26
176
  ## Licence
27
177
 
28
178
  MIT
@@ -0,0 +1,34 @@
1
+ import type { KindredProof, RoomPolicy } from './types.js';
2
+ export interface IssueKindredProofOptions {
3
+ /** The issuer's secret key. */
4
+ hostSk: Uint8Array;
5
+ participant: string;
6
+ tier: KindredProof['tier'];
7
+ /** The room this proof admits the participant to. */
8
+ roomId: string;
9
+ /** Unix seconds. */
10
+ expiresAt: number;
11
+ /** 32 bytes, hex. Supply one only to make a proof reproducible - the
12
+ * interop vectors do; everything else wants the random default. */
13
+ nonce?: string;
14
+ }
15
+ /** Vouch for a participant at a tier, in one room, until an expiry. */
16
+ export declare function issueKindredProof(opts: IssueKindredProofOptions): KindredProof;
17
+ /**
18
+ * Decide whether `participant` may join a room under `policy`.
19
+ *
20
+ * `ken` is one-way recognition - pinning someone's key from an authoritative
21
+ * source, with no bond back - so it never satisfies a `kith` gate. `kin` is
22
+ * closer than `kith` and does. Checks run cheapest first and never throw:
23
+ * participant match, room match, expiry, issuer trust, tier, then the schnorr
24
+ * verification last, since it is the most expensive.
25
+ */
26
+ export declare function evaluateAccess(policy: RoomPolicy, participant: string, proof: KindredProof | undefined, now: number, roomId: string): {
27
+ admitted: boolean;
28
+ reason: string;
29
+ };
30
+ /** Every wire-format literal this module owns (each one a kithmoot protocol string), frozen for
31
+ * `src/labels.test.ts`, which checks each module against its own exported
32
+ * list rather than scanning file text for matching comments. Pure data -
33
+ * adding this export changes no runtime behaviour. */
34
+ export declare const ACCESS_LABELS: readonly ["kithmoot/v1/kindred:"];
package/dist/access.js ADDED
@@ -0,0 +1,102 @@
1
+ import { schnorr } from '@noble/curves/secp256k1.js';
2
+ import { sha256 } from '@noble/hashes/sha2';
3
+ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils';
4
+ import { getPublicKey } from 'nostr-tools/pure';
5
+ import { hexEquals, normaliseHex } from './hex.js';
6
+ /** Closest first, matching the canonical order in the kindred primitive. */
7
+ const TIER_RANK = { kin: 3, kith: 2, ken: 1, open: 0 };
8
+ /**
9
+ * The exact bytes a kindred proof signs over. Every field that changes the
10
+ * proof's meaning is in here, so tampering with any one of them - tier,
11
+ * subject, room, nonce or expiry - invalidates the signature.
12
+ *
13
+ * `room` and `nonce` are covered deliberately: see `KindredProof`. A proof
14
+ * signed by an implementation that omits them reconstructs a different message
15
+ * and fails the signature check, which is the right way round - an older proof
16
+ * is refused rather than silently admitted somewhere it was never meant to go.
17
+ */
18
+ function canonicalMessage(tier, participant, room, nonce, expiresAt) {
19
+ return sha256(new TextEncoder().encode(`kithmoot/v1/kindred:${tier}:${participant}:${room}:${nonce}:${expiresAt}`));
20
+ }
21
+ /** Vouch for a participant at a tier, in one room, until an expiry. */
22
+ export function issueKindredProof(opts) {
23
+ // `participant` and `roomId` are identifiers handed in by the caller -
24
+ // possibly typed or pasted - so they are canonicalised here, at the point
25
+ // they enter the proof, rather than left for `evaluateAccess`'s equality
26
+ // checks to paper over.
27
+ const participant = normaliseHex(opts.participant);
28
+ const room = normaliseHex(opts.roomId);
29
+ const nonce = normaliseHex(opts.nonce ?? bytesToHex(randomBytes(32)));
30
+ const sig = schnorr.sign(canonicalMessage(opts.tier, participant, room, nonce, opts.expiresAt), opts.hostSk);
31
+ return {
32
+ tier: opts.tier,
33
+ participant,
34
+ issuer: getPublicKey(opts.hostSk),
35
+ room,
36
+ nonce,
37
+ sig: bytesToHex(sig),
38
+ expiresAt: opts.expiresAt,
39
+ };
40
+ }
41
+ /**
42
+ * Decide whether `participant` may join a room under `policy`.
43
+ *
44
+ * `ken` is one-way recognition - pinning someone's key from an authoritative
45
+ * source, with no bond back - so it never satisfies a `kith` gate. `kin` is
46
+ * closer than `kith` and does. Checks run cheapest first and never throw:
47
+ * participant match, room match, expiry, issuer trust, tier, then the schnorr
48
+ * verification last, since it is the most expensive.
49
+ */
50
+ export function evaluateAccess(policy, participant, proof, now, roomId) {
51
+ // A members list closes the door before any tier is considered: a
52
+ // direct message is open in tier and shut to everybody but its two.
53
+ if (policy.members !== undefined && !policy.members.some((m) => hexEquals(m, participant))) {
54
+ return { admitted: false, reason: 'not a member' };
55
+ }
56
+ if (policy.tier === 'open')
57
+ return { admitted: true, reason: 'open room' };
58
+ if (!proof)
59
+ return { admitted: false, reason: 'no kindred proof' };
60
+ // Hex, compared case-insensitively throughout this function: see
61
+ // `hexEquals` and `vectors/README.md`. An allow-list entry, or a proof,
62
+ // stored in upper-case hex names exactly the same identifier and must not
63
+ // be rejected on case alone.
64
+ if (!hexEquals(proof.participant, participant))
65
+ return { admitted: false, reason: 'proof names another participant' };
66
+ // A proof is a grant in one room, not a bearer token - see `KindredProof`.
67
+ if (proof.room === undefined || !hexEquals(proof.room, roomId)) {
68
+ return { admitted: false, reason: 'proof names another room' };
69
+ }
70
+ if (proof.expiresAt <= now)
71
+ return { admitted: false, reason: 'expired' };
72
+ if (!policy.admitted?.some((a) => hexEquals(a, proof.issuer))) {
73
+ return { admitted: false, reason: 'untrusted issuer' };
74
+ }
75
+ // Fail closed on a tier we do not recognise. TIER_RANK[unknown] is
76
+ // undefined and `undefined < 2` is false, so the obvious comparison skips
77
+ // the rejection branch and admits - the opposite of what a gate is for. A
78
+ // trusted issuer's typo, or a looser independent implementation, is all it
79
+ // would take.
80
+ const rank = TIER_RANK[proof.tier];
81
+ if (rank === undefined)
82
+ return { admitted: false, reason: 'unrecognised tier' };
83
+ if (rank < (TIER_RANK[policy.tier] ?? 0))
84
+ return { admitted: false, reason: 'tier too low' };
85
+ try {
86
+ const message = canonicalMessage(proof.tier, proof.participant, proof.room, proof.nonce, proof.expiresAt);
87
+ if (!schnorr.verify(hexToBytes(proof.sig), message, hexToBytes(proof.issuer))) {
88
+ return { admitted: false, reason: 'bad signature' };
89
+ }
90
+ }
91
+ catch {
92
+ return { admitted: false, reason: 'bad signature' };
93
+ }
94
+ return { admitted: true, reason: 'kindred proof accepted' };
95
+ }
96
+ /** Every wire-format literal this module owns (each one a kithmoot protocol string), frozen for
97
+ * `src/labels.test.ts`, which checks each module against its own exported
98
+ * list rather than scanning file text for matching comments. Pure data -
99
+ * adding this export changes no runtime behaviour. */
100
+ export const ACCESS_LABELS = [
101
+ "kithmoot/v1/kindred:",
102
+ ];
@@ -0,0 +1,27 @@
1
+ /** `deriveChannel`, extracted from KithMoot's `src/chat.ts` (see
2
+ * EXTRACTION.md).
3
+ * `ChatLog` and the chat event codecs stay in KithMoot. */
4
+ export declare const CHANNEL_ID_INFO = "kithmoot/v1/channel-id/";
5
+ export declare const CHANNEL_KEY_INFO = "kithmoot/v1/channel-key/";
6
+ /** Bounds a channel name, which rides only in an HKDF info string and
7
+ * never on the wire; long enough for any sensible name. */
8
+ export declare const MAX_CHANNEL_NAME_LENGTH = 64;
9
+ /**
10
+ * The room id and key a named channel lives under.
11
+ *
12
+ * Both derived from the room KEY, never the room id, so a party that holds
13
+ * the id and not the key - a forwarder, a relay - cannot find the channel
14
+ * from the room, let alone read it. Two separate HKDF expansions for the
15
+ * same reason `deriveRoom` uses two: publishing the id reveals nothing about
16
+ * the key. The main chat is the unnamed channel and is untouched by this:
17
+ * its id is the room id and its key the room key, byte for byte as before.
18
+ */
19
+ export declare function deriveChannel(roomId: string, roomKey: Uint8Array, channel?: string): {
20
+ id: string;
21
+ key: Uint8Array;
22
+ };
23
+ /** Every wire-format literal this module owns (each one a kithmoot protocol string), frozen for
24
+ * `src/labels.test.ts`, which checks each module against its own exported
25
+ * list rather than scanning file text for matching comments. Pure data -
26
+ * adding this export changes no runtime behaviour. */
27
+ export declare const CHANNEL_LABELS: readonly ["kithmoot/v1/channel-id/", "kithmoot/v1/channel-key/"];
@@ -0,0 +1,38 @@
1
+ import { hkdf } from '@noble/hashes/hkdf';
2
+ import { sha256 } from '@noble/hashes/sha2';
3
+ /** `deriveChannel`, extracted from KithMoot's `src/chat.ts` (see
4
+ * EXTRACTION.md).
5
+ * `ChatLog` and the chat event codecs stay in KithMoot. */
6
+ export const CHANNEL_ID_INFO = 'kithmoot/v1/channel-id/';
7
+ export const CHANNEL_KEY_INFO = 'kithmoot/v1/channel-key/';
8
+ /** Bounds a channel name, which rides only in an HKDF info string and
9
+ * never on the wire; long enough for any sensible name. */
10
+ export const MAX_CHANNEL_NAME_LENGTH = 64;
11
+ /**
12
+ * The room id and key a named channel lives under.
13
+ *
14
+ * Both derived from the room KEY, never the room id, so a party that holds
15
+ * the id and not the key - a forwarder, a relay - cannot find the channel
16
+ * from the room, let alone read it. Two separate HKDF expansions for the
17
+ * same reason `deriveRoom` uses two: publishing the id reveals nothing about
18
+ * the key. The main chat is the unnamed channel and is untouched by this:
19
+ * its id is the room id and its key the room key, byte for byte as before.
20
+ */
21
+ export function deriveChannel(roomId, roomKey, channel) {
22
+ if (channel === undefined)
23
+ return { id: roomId, key: roomKey };
24
+ if (channel.length === 0 || channel.length > MAX_CHANNEL_NAME_LENGTH)
25
+ throw new Error('channel name out of range');
26
+ const idBytes = hkdf(sha256, roomKey, undefined, CHANNEL_ID_INFO + channel, 32);
27
+ const key = hkdf(sha256, roomKey, undefined, CHANNEL_KEY_INFO + channel, 32);
28
+ const id = Array.from(idBytes, (b) => b.toString(16).padStart(2, '0')).join('');
29
+ return { id, key };
30
+ }
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 const CHANNEL_LABELS = [
36
+ "kithmoot/v1/channel-id/",
37
+ "kithmoot/v1/channel-key/",
38
+ ];
@@ -0,0 +1,58 @@
1
+ import type { ParticipantIdentity } from './identity.js';
2
+ import type { DeviceCredential } from './types.js';
3
+ export interface CreateCredentialOptions {
4
+ /**
5
+ * The participant. A locally held key (`localIdentity`) or an external
6
+ * signer - see `ParticipantIdentity`. Either way the secret itself never
7
+ * reaches this function.
8
+ */
9
+ identity: ParticipantIdentity;
10
+ /** The pubkey of the device being authorised. */
11
+ devicePubkey: string;
12
+ /**
13
+ * The room this credential is for. Omit it, and pass `scope: 'person'`,
14
+ * for a credential that lets the device act for the participant in every
15
+ * room, DM and box: the same event with `d` set to the participant's own
16
+ * pubkey and a `scope` tag, so a verifier that knows one form knows the
17
+ * other. See docs/device-credential.md.
18
+ */
19
+ roomId?: string;
20
+ /** `person` widens the credential from one room to the participant. */
21
+ scope?: 'person';
22
+ /** What the person calls this device. Shown to them and to nobody else. */
23
+ label?: string;
24
+ /** Unix seconds. A person credential may not run more than 30 days. */
25
+ expiresAt: number;
26
+ /** Injectable clock, in unix seconds. Defaults to the real one. */
27
+ now?: () => number;
28
+ }
29
+ /** The longest a person-scoped credential may run. A phone that leaves the house is better at seven days. */
30
+ export declare const PERSON_CREDENTIAL_MAX_SECONDS: number;
31
+ /**
32
+ * Authorise a device to act for a participant in one room, until an expiry.
33
+ *
34
+ * The credential is signed by the participant key but is never published to a
35
+ * relay - it travels inside the room-key-encrypted roster, so relays never
36
+ * learn the participant pubkey.
37
+ *
38
+ * Asynchronous because the signer may not be here: an extension has to
39
+ * prompt, a bunker has to be reached over a relay, a phone has to be
40
+ * unlocked. See `ParticipantIdentity`.
41
+ */
42
+ export declare function createDeviceCredential(opts: CreateCredentialOptions): Promise<DeviceCredential>;
43
+ export type VerifyResult = {
44
+ ok: true;
45
+ participant: string;
46
+ device: string;
47
+ } | {
48
+ ok: false;
49
+ reason: string;
50
+ };
51
+ export declare function verifyDeviceCredential(cred: DeviceCredential, opts: {
52
+ roomId: string;
53
+ now: number;
54
+ acceptPerson?: boolean;
55
+ } | {
56
+ identity: string;
57
+ now: number;
58
+ }): VerifyResult;
@@ -0,0 +1,145 @@
1
+ import { KINDS } from './kinds.js';
2
+ import { verifyEventUncached } from './verify.js';
3
+ import { hexEquals, normaliseHex } from './hex.js';
4
+ /** The longest a person-scoped credential may run. A phone that leaves the house is better at seven days. */
5
+ export const PERSON_CREDENTIAL_MAX_SECONDS = 30 * 24 * 60 * 60;
6
+ /**
7
+ * Authorise a device to act for a participant in one room, until an expiry.
8
+ *
9
+ * The credential is signed by the participant key but is never published to a
10
+ * relay - it travels inside the room-key-encrypted roster, so relays never
11
+ * learn the participant pubkey.
12
+ *
13
+ * Asynchronous because the signer may not be here: an extension has to
14
+ * prompt, a bunker has to be reached over a relay, a phone has to be
15
+ * unlocked. See `ParticipantIdentity`.
16
+ */
17
+ export async function createDeviceCredential(opts) {
18
+ const now = (opts.now ?? (() => Math.floor(Date.now() / 1000)))();
19
+ const person = opts.scope === 'person';
20
+ if (person && opts.roomId !== undefined)
21
+ throw new Error('a person credential names no room');
22
+ if (!person && opts.roomId === undefined)
23
+ throw new Error('a room credential needs a room');
24
+ if (person && opts.expiresAt - now > PERSON_CREDENTIAL_MAX_SECONDS)
25
+ throw new Error('a person credential may not run more than 30 days');
26
+ const unsigned = {
27
+ kind: KINDS.CREDENTIAL,
28
+ created_at: now,
29
+ tags: [
30
+ ['d', person ? opts.identity.pubkey : opts.roomId],
31
+ ['device', opts.devicePubkey],
32
+ ['expiration', String(opts.expiresAt)],
33
+ ...(person ? [['scope', 'person']] : []),
34
+ ...(opts.label !== undefined ? [['label', opts.label]] : []),
35
+ ],
36
+ content: '',
37
+ };
38
+ const signed = await opts.identity.signEvent(unsigned);
39
+ // A signer is not part of this codebase: it is a browser extension, a
40
+ // bunker on somebody else's machine, or an app on a phone. It is trusted
41
+ // to hold the key, not to be correct - and a credential is the one thing
42
+ // in this protocol that says who a device speaks for. So what comes back
43
+ // is checked against what was asked for, rather than returned on faith.
44
+ //
45
+ // `created_at` is deliberately NOT compared: some signers stamp their own,
46
+ // it is inside the signature either way, and nothing here decides anything
47
+ // on it - the `expiration` tag is what bounds the credential, and that IS
48
+ // compared.
49
+ if (!hexEquals(signed.pubkey, opts.identity.pubkey)) {
50
+ throw new Error('the signer returned a credential signed by a different key');
51
+ }
52
+ if (signed.kind !== unsigned.kind || signed.content !== unsigned.content) {
53
+ throw new Error('the signer returned a credential for something else');
54
+ }
55
+ if (JSON.stringify(signed.tags) !== JSON.stringify(unsigned.tags)) {
56
+ throw new Error('the signer returned a credential on different terms than it was asked for');
57
+ }
58
+ if (!verifyEventUncached(signed)) {
59
+ throw new Error('the signer returned a credential that does not verify');
60
+ }
61
+ return signed;
62
+ }
63
+ /**
64
+ * Verify a credential for one room (`roomId`) or for the person (`identity`,
65
+ * the participant pubkey the verifier expects). A person credential is
66
+ * accepted where a room credential is expected only when the caller says
67
+ * so with `acceptPerson`, which a room does when it admits the person and
68
+ * so admits their devices. A room credential is never accepted as a person
69
+ * credential, and a room credential carrying a `scope` tag is refused.
70
+ */
71
+ /** A credential rides on every message and presence its device sends, so
72
+ * the same one reaches this check again and again: checking its signature
73
+ * afresh each time was half of what opening an old room cost. Keyed on the
74
+ * whole event, signature included: a Schnorr signature is randomised, so
75
+ * one credential can carry two valid signatures, and an id-keyed cache
76
+ * would refuse the second. */
77
+ const verifiedCredentials = new Set();
78
+ function credentialSignature(cred) {
79
+ const whole = JSON.stringify([cred.id, cred.pubkey, cred.created_at, cred.kind, cred.tags, cred.content, cred.sig]);
80
+ if (verifiedCredentials.has(whole))
81
+ return true;
82
+ if (!verifyEventUncached(cred))
83
+ return false;
84
+ verifiedCredentials.add(whole);
85
+ if (verifiedCredentials.size > 4_096)
86
+ verifiedCredentials.delete(verifiedCredentials.values().next().value);
87
+ return true;
88
+ }
89
+ export function verifyDeviceCredential(cred, opts) {
90
+ if (cred.kind !== KINDS.CREDENTIAL)
91
+ return { ok: false, reason: 'wrong kind' };
92
+ const d = cred.tags.find((t) => t[0] === 'd')?.[1];
93
+ const scope = cred.tags.find((t) => t[0] === 'scope')?.[1];
94
+ if (d === undefined)
95
+ return { ok: false, reason: 'no scope' };
96
+ const isPerson = scope === 'person';
97
+ if (scope !== undefined && !isPerson)
98
+ return { ok: false, reason: 'unknown scope' };
99
+ if ('identity' in opts) {
100
+ if (!isPerson)
101
+ return { ok: false, reason: 'not a person credential' };
102
+ if (!hexEquals(d, opts.identity) || !hexEquals(d, cred.pubkey))
103
+ return { ok: false, reason: 'wrong person' };
104
+ }
105
+ else if (isPerson) {
106
+ if (!opts.acceptPerson)
107
+ return { ok: false, reason: 'person credential where a room credential was expected' };
108
+ if (!hexEquals(d, cred.pubkey))
109
+ return { ok: false, reason: 'wrong person' };
110
+ }
111
+ else if (!hexEquals(d, opts.roomId)) {
112
+ return { ok: false, reason: 'wrong room' };
113
+ }
114
+ const expiration = cred.tags.find((t) => t[0] === 'expiration')?.[1];
115
+ // A missing tag and a present-but-non-numeric one are the same failure:
116
+ // there is no usable expiry to check against. Folding them into one
117
+ // `Number.isFinite` guard matters because `Number(expiration)` on
118
+ // anything non-numeric is NaN, and every comparison with NaN - including
119
+ // `NaN <= now` - is false, so the naive check below would treat a
120
+ // corrupted tag as never expiring: a fail-open default in a security
121
+ // check, even though the tag sits inside the signed content today and so
122
+ // is not exploitable while the signature check below still runs.
123
+ const expiresAt = expiration === undefined ? NaN : Number(expiration);
124
+ if (!Number.isFinite(expiresAt))
125
+ return { ok: false, reason: 'no expiration' };
126
+ if (expiresAt <= opts.now)
127
+ return { ok: false, reason: 'expired' };
128
+ if (isPerson && expiresAt - cred.created_at > PERSON_CREDENTIAL_MAX_SECONDS)
129
+ return { ok: false, reason: 'longer than 30 days' };
130
+ const device = cred.tags.find((t) => t[0] === 'device')?.[1];
131
+ if (!device)
132
+ return { ok: false, reason: 'no device' };
133
+ // Signature last: it is the most expensive check, and tampering with any tag
134
+ // above invalidates it anyway. Never nostr-tools' own cached verdict, which
135
+ // a credential could arrive carrying - see `verify.ts` - but our bounded
136
+ // cache, which matches only the identical event.
137
+ if (!credentialSignature(cred))
138
+ return { ok: false, reason: 'bad signature' };
139
+ // A credential is one of the places a device/participant pubkey enters
140
+ // the system - the `device` tag in particular is free text set by
141
+ // whoever minted the credential. Canonicalise both here so every caller
142
+ // (roster decode, secondary-device adoption) compares against something
143
+ // already lower-case, rather than each having to know to.
144
+ return { ok: true, participant: normaliseHex(cred.pubkey), device: normaliseHex(device) };
145
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Display names.
3
+ *
4
+ * A name in this protocol is **self-asserted**: whoever holds a participant
5
+ * key types whatever they like, and nothing anywhere checks it. That is the
6
+ * point - joining a room should cost a name and nothing else - but it means
7
+ * a name is attacker-controlled text arriving off a relay, and every reader
8
+ * has to treat it as such.
9
+ *
10
+ * Two rules follow, and they live in different places:
11
+ *
12
+ * 1. **Sanitise it.** Everything below. A name may not carry characters
13
+ * that let it take a second row, hide part of itself, or reverse the
14
+ * direction the rest of the line renders in, and it may not be long
15
+ * enough to push everything else off the row.
16
+ *
17
+ * 2. **Never render it alone, and never as HTML.** Both are the caller's
18
+ * job, because both are about what a name sits next to. A short pubkey
19
+ * always renders beside it, so two people called "Robin" stay
20
+ * distinguishable and an impersonation is visible; and it goes in via
21
+ * `textContent`, never `innerHTML`, so markup in a name stays text.
22
+ */
23
+ /**
24
+ * How long a name may be, in characters.
25
+ *
26
+ * Counted in code points rather than UTF-16 units, so one emoji costs one -
27
+ * a cap in UTF-16 units would let an astral-plane name be twice as wide as
28
+ * an ASCII one, and could cut a surrogate pair in half.
29
+ */
30
+ export declare const MAX_DISPLAY_NAME_LENGTH = 32;
31
+ /**
32
+ * Make a name safe to put next to somebody else's, or return undefined if
33
+ * there is nothing left worth showing.
34
+ *
35
+ * Takes `unknown` because this runs on JSON off a relay as well as on a
36
+ * field somebody typed, and anything at all can arrive in either.
37
+ */
38
+ export declare function sanitiseDisplayName(raw: unknown): string | undefined;