@forgesworn/fold-kit 0.0.0-seed.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +180 -4
  2. package/dist/access.d.ts +34 -0
  3. package/dist/access.js +102 -0
  4. package/dist/channel.d.ts +27 -0
  5. package/dist/channel.js +38 -0
  6. package/dist/credential.d.ts +74 -0
  7. package/dist/credential.js +177 -0
  8. package/dist/display-name.d.ts +38 -0
  9. package/dist/display-name.js +78 -0
  10. package/dist/epoch.d.ts +387 -0
  11. package/dist/epoch.js +665 -0
  12. package/dist/hex.d.ts +28 -0
  13. package/dist/hex.js +32 -0
  14. package/dist/identity.d.ts +49 -0
  15. package/dist/identity.js +17 -0
  16. package/dist/index.d.ts +29 -0
  17. package/dist/index.js +19 -0
  18. package/dist/invitation.d.ts +217 -0
  19. package/dist/invitation.js +484 -0
  20. package/dist/kinds.d.ts +64 -0
  21. package/dist/kinds.js +64 -0
  22. package/dist/lane.d.ts +41 -0
  23. package/dist/lane.js +63 -0
  24. package/dist/link.d.ts +52 -0
  25. package/dist/link.js +107 -0
  26. package/dist/network-hints.d.ts +16 -0
  27. package/dist/network-hints.js +65 -0
  28. package/dist/persistent-invitation.d.ts +35 -0
  29. package/dist/persistent-invitation.js +118 -0
  30. package/dist/room.d.ts +53 -0
  31. package/dist/room.js +153 -0
  32. package/dist/scoped.d.ts +67 -0
  33. package/dist/scoped.js +83 -0
  34. package/dist/sub-cert.d.ts +117 -0
  35. package/dist/sub-cert.js +192 -0
  36. package/dist/transport.d.ts +24 -0
  37. package/dist/transport.js +1 -0
  38. package/dist/types.d.ts +62 -0
  39. package/dist/types.js +1 -0
  40. package/dist/verify.d.ts +26 -0
  41. package/dist/verify.js +46 -0
  42. package/llms.txt +106 -0
  43. package/package.json +77 -6
  44. package/vectors/circle-vectors.json +2174 -0
  45. package/vectors/kithmoot-vectors.json +1550 -0
package/README.md CHANGED
@@ -5,12 +5,165 @@ 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. `createDeviceCredential` throws `RestampedCredentialExpiryError`
97
+ for a person-scope credential a restamping signer would make unverifiable
98
+ everywhere (see CHANGELOG.md) - catch it and retry with a shorter
99
+ `expiresAt` margin
100
+ - `issueKindredProof`, `evaluateAccess` - kindred-tier admission
101
+ - `sanitiseDisplayName`, `MAX_DISPLAY_NAME_LENGTH` - defused display names
102
+ - `safeRelayUrls`, `safeIceUrls`, `assertNetworkHintBounds`, and the related
103
+ `MAX_*` bounds - link envelope network hints
104
+
105
+ ### Links and invitations
106
+
107
+ - `parseRoomLink`, `encodeRoomLink`, `RoomLink` - the v1/v2/v3 link envelope
108
+ - `createRoomInvitation`, `roomInvitation`, `deriveInvitationId`,
109
+ `encodeInvitationRequest`, `decodeInvitationRequest`,
110
+ `verifyInvitationDelegation`, `encodeInvitationGrant`,
111
+ `decodeRoomAdmissionGrant`, `encodeInvitationRetirement`,
112
+ `decodeInvitationRetirementNotice`, `hostRoomInvitation`,
113
+ `requestRoomAdmission` - the v2 live rendezvous invitation, its delegation
114
+ chain and retirement
115
+ - `encodePersistentInvitation`, `decodePersistentInvitation`,
116
+ `requestPersistentRoomAdmission` - the v3 stored group invitation (1463)
117
+
118
+ ### Epochs (removal by rekey)
119
+
120
+ - `deriveEpoch`, `generateEpochSecret` - per-epoch id/key derivation
121
+ - `encodeRekeyEvent`, `peekRekeyEvent`, `decodeRekeyEvent` - the durable
122
+ rekey notice (1462), sealed per remaining device
123
+ - `encodeEpochRequest`, `decodeEpochRequest`, `encodeEpochGrant`,
124
+ `decodeEpochGrant`, `epochRequestAdmission`, `deriveEpochRequestKey` -
125
+ catch-up for a device that missed a rekey
126
+ - `hostRoomEpoch`, `requestRoomEpoch`, `EpochRefusedError` - the live
127
+ request/grant desk
128
+ - `canonicalAdmins`, `signAdmins`, `verifyAdmins` - the authority's signed
129
+ admin list
130
+ - `canonicalChannels`, `signChannels`, `verifyChannels`, `CHANNEL_NAME`,
131
+ `RESERVED_CHANNELS` - the authority's signed channel list
132
+
133
+ ### Scoped labels and sub-key certificates (a consuming app's own keys)
134
+
135
+ - `deriveScoped` - derive an app-defined `{ id, key }` pair from an epoch key
136
+ under an app's own labelled namespace (e.g. `myapp/v1/board/<id>/update`),
137
+ so an app can ride the same epoch as the roster and the chat - and rotate
138
+ on the same rekey - without ever deriving a key that could be mistaken for
139
+ a KithMoot channel. Refuses this kit's own protocol namespace and any
140
+ label outside `SCOPED_LABEL_PATTERN`.
141
+ - `createSubKeyCertificate`, `verifySubKeyCertificate`,
142
+ `SUB_KEY_CERTIFICATE_SCOPE` - a small, locally-signed statement that a
143
+ credentialled device minted a particular app-derived key (a `deriveScoped`
144
+ output, typically) for a particular scoped id. Rides inside a signed
145
+ message's ciphertext alongside the device credential it depends on; never
146
+ published on its own, and never accepted by `verifyDeviceCredential` (its
147
+ `scope: "sub"` is a value that function already refuses). Verification is
148
+ strict and canonical: exactly four two-element tags in a fixed order,
149
+ empty `content`, an `expiration` in canonical decimal form compared as the
150
+ exact tag string (not numerically) against the device credential passed
151
+ in - `verifySubKeyCertificate` reads that credential's own `device` and
152
+ `expiration` tags itself, rather than trusting a caller to have copied
153
+ them out correctly.
154
+
155
+ ### Channel (main entry, and `deriveChannel` only)
156
+
157
+ - `deriveChannel`, `CHANNEL_ID_INFO`, `CHANNEL_KEY_INFO`,
158
+ `MAX_CHANNEL_NAME_LENGTH` - a named channel's id/key, derived from the room
159
+ key. `ChatLog` and the chat event codecs stay in KithMoot; only the
160
+ derivation moved.
161
+
162
+ ### Lane (`@forgesworn/fold-kit/lane`)
163
+
164
+ - `LANES`, `LANE_MEANING`, `LANE_LABEL`, `LANE_GLYPH`, `isLane`,
165
+ `laneOfRelayUrl`, `laneOfRelays`, `weakestLane`, `isDowngrade` - classifies
166
+ a relay as `public`, `sheltered` or `direct`
14
167
 
15
168
  ## How it relates to its siblings
16
169
 
@@ -23,6 +176,29 @@ byte-identical and is pinned by known-answer vectors.
23
176
  collaborative documents, where a wrap per recipient per message is too
24
177
  costly.
25
178
 
179
+ Nothing is shared in code between the three today: the key models are
180
+ incompatible (a static epoch secret with sealed rekeys here, versus a
181
+ deterministic reseed from a root in covey-kit), the trust models differ
182
+ (pinned authority here versus caller-enforced roles in covey-kit), and
183
+ covey-kit/roost-kit pin a different `nostr-tools` version than this kit and
184
+ KithMoot share.
185
+
186
+ ## Development
187
+
188
+ ```bash
189
+ npm install
190
+ npm run build # compile TypeScript into dist/
191
+ npm test # run the Vitest suite, including both vector files
192
+ npm run typecheck # type-check src/ and test/ (matching KithMoot, vectors/ is not tsc-checked)
193
+ npm run vectors # run only the vector-verification suites
194
+ npm run diff-source # compare moved modules against the pinned source commit (needs FOLD_KIT_SOURCE_DIR)
195
+ npm run generate-fold # regenerate vectors/fold-vectors.json (needs a prior build)
196
+ npm run bundle-check # esbuild browser bundle check (needs a prior build)
197
+ npm run check # typecheck + test + diff-source
198
+ ```
199
+
200
+ There is no separate lint script.
201
+
26
202
  ## Licence
27
203
 
28
204
  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,74 @@
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
+ * Thrown by `createDeviceCredential` when a remote signer's restamped
33
+ * `created_at` would make the requested expiry run longer than 30 days from
34
+ * it - see the comment at the throw site, and forgesworn/kithmoot issue 205
35
+ * (CHANGELOG.md has the full writeup and a link).
36
+ * Typed (rather than a plain `Error`) so a caller can distinguish this from
37
+ * the other, unrelated failure modes above it (wrong key, wrong terms, bad
38
+ * signature) and retry with a shorter margin specifically in this case.
39
+ */
40
+ export declare class RestampedCredentialExpiryError extends Error {
41
+ /** How far over the 30-day cap the signed `created_at` pushed the
42
+ * requested expiry, in seconds. Always positive. A caller retrying with
43
+ * a shorter margin needs to shorten by at least this much. */
44
+ readonly overBySeconds: number;
45
+ constructor(overBySeconds: number);
46
+ }
47
+ /**
48
+ * Authorise a device to act for a participant in one room, until an expiry.
49
+ *
50
+ * The credential is signed by the participant key but is never published to a
51
+ * relay - it travels inside the room-key-encrypted roster, so relays never
52
+ * learn the participant pubkey.
53
+ *
54
+ * Asynchronous because the signer may not be here: an extension has to
55
+ * prompt, a bunker has to be reached over a relay, a phone has to be
56
+ * unlocked. See `ParticipantIdentity`.
57
+ */
58
+ export declare function createDeviceCredential(opts: CreateCredentialOptions): Promise<DeviceCredential>;
59
+ export type VerifyResult = {
60
+ ok: true;
61
+ participant: string;
62
+ device: string;
63
+ } | {
64
+ ok: false;
65
+ reason: string;
66
+ };
67
+ export declare function verifyDeviceCredential(cred: DeviceCredential, opts: {
68
+ roomId: string;
69
+ now: number;
70
+ acceptPerson?: boolean;
71
+ } | {
72
+ identity: string;
73
+ now: number;
74
+ }): VerifyResult;
@@ -0,0 +1,177 @@
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
+ * Thrown by `createDeviceCredential` when a remote signer's restamped
8
+ * `created_at` would make the requested expiry run longer than 30 days from
9
+ * it - see the comment at the throw site, and forgesworn/kithmoot issue 205
10
+ * (CHANGELOG.md has the full writeup and a link).
11
+ * Typed (rather than a plain `Error`) so a caller can distinguish this from
12
+ * the other, unrelated failure modes above it (wrong key, wrong terms, bad
13
+ * signature) and retry with a shorter margin specifically in this case.
14
+ */
15
+ export class RestampedCredentialExpiryError extends Error {
16
+ /** How far over the 30-day cap the signed `created_at` pushed the
17
+ * requested expiry, in seconds. Always positive. A caller retrying with
18
+ * a shorter margin needs to shorten by at least this much. */
19
+ overBySeconds;
20
+ constructor(overBySeconds) {
21
+ super('the signer restamped created_at, and the requested expiry now runs longer than 30 days from it');
22
+ this.name = 'RestampedCredentialExpiryError';
23
+ this.overBySeconds = overBySeconds;
24
+ }
25
+ }
26
+ /**
27
+ * Authorise a device to act for a participant in one room, until an expiry.
28
+ *
29
+ * The credential is signed by the participant key but is never published to a
30
+ * relay - it travels inside the room-key-encrypted roster, so relays never
31
+ * learn the participant pubkey.
32
+ *
33
+ * Asynchronous because the signer may not be here: an extension has to
34
+ * prompt, a bunker has to be reached over a relay, a phone has to be
35
+ * unlocked. See `ParticipantIdentity`.
36
+ */
37
+ export async function createDeviceCredential(opts) {
38
+ const now = (opts.now ?? (() => Math.floor(Date.now() / 1000)))();
39
+ const person = opts.scope === 'person';
40
+ if (person && opts.roomId !== undefined)
41
+ throw new Error('a person credential names no room');
42
+ if (!person && opts.roomId === undefined)
43
+ throw new Error('a room credential needs a room');
44
+ if (person && opts.expiresAt - now > PERSON_CREDENTIAL_MAX_SECONDS)
45
+ throw new Error('a person credential may not run more than 30 days');
46
+ const unsigned = {
47
+ kind: KINDS.CREDENTIAL,
48
+ created_at: now,
49
+ tags: [
50
+ ['d', person ? opts.identity.pubkey : opts.roomId],
51
+ ['device', opts.devicePubkey],
52
+ ['expiration', String(opts.expiresAt)],
53
+ ...(person ? [['scope', 'person']] : []),
54
+ ...(opts.label !== undefined ? [['label', opts.label]] : []),
55
+ ],
56
+ content: '',
57
+ };
58
+ const signed = await opts.identity.signEvent(unsigned);
59
+ // A signer is not part of this codebase: it is a browser extension, a
60
+ // bunker on somebody else's machine, or an app on a phone. It is trusted
61
+ // to hold the key, not to be correct - and a credential is the one thing
62
+ // in this protocol that says who a device speaks for. So what comes back
63
+ // is checked against what was asked for, rather than returned on faith.
64
+ //
65
+ // `created_at` is deliberately NOT compared: some signers stamp their own,
66
+ // it is inside the signature either way, and nothing here decides anything
67
+ // on it - the `expiration` tag is what bounds the credential, and that IS
68
+ // compared.
69
+ if (!hexEquals(signed.pubkey, opts.identity.pubkey)) {
70
+ throw new Error('the signer returned a credential signed by a different key');
71
+ }
72
+ if (signed.kind !== unsigned.kind || signed.content !== unsigned.content) {
73
+ throw new Error('the signer returned a credential for something else');
74
+ }
75
+ if (JSON.stringify(signed.tags) !== JSON.stringify(unsigned.tags)) {
76
+ throw new Error('the signer returned a credential on different terms than it was asked for');
77
+ }
78
+ if (!verifyEventUncached(signed)) {
79
+ throw new Error('the signer returned a credential that does not verify');
80
+ }
81
+ // forgesworn/kithmoot#205: a remote signer (a bunker, a phone) may restamp
82
+ // `created_at` to its own, earlier clock. `verifyDeviceCredential` measures
83
+ // the 30-day cap from the SIGNED `created_at`, not from `now` above - so a
84
+ // person credential requested near the cap can come back already unable to
85
+ // verify anywhere ("longer than 30 days"), even though nothing here was
86
+ // asked for more than 30 days. Re-check with the verifier's own
87
+ // measurement and fail loudly at mint time, rather than handing back a
88
+ // credential doomed to be refused everywhere it is presented. Callers that
89
+ // hit this can retry with a shorter `expiresAt` margin.
90
+ if (person && opts.expiresAt - signed.created_at > PERSON_CREDENTIAL_MAX_SECONDS) {
91
+ throw new RestampedCredentialExpiryError(opts.expiresAt - signed.created_at - PERSON_CREDENTIAL_MAX_SECONDS);
92
+ }
93
+ return signed;
94
+ }
95
+ /**
96
+ * Verify a credential for one room (`roomId`) or for the person (`identity`,
97
+ * the participant pubkey the verifier expects). A person credential is
98
+ * accepted where a room credential is expected only when the caller says
99
+ * so with `acceptPerson`, which a room does when it admits the person and
100
+ * so admits their devices. A room credential is never accepted as a person
101
+ * credential, and a room credential carrying a `scope` tag is refused.
102
+ */
103
+ /** A credential rides on every message and presence its device sends, so
104
+ * the same one reaches this check again and again: checking its signature
105
+ * afresh each time was half of what opening an old room cost. Keyed on the
106
+ * whole event, signature included: a Schnorr signature is randomised, so
107
+ * one credential can carry two valid signatures, and an id-keyed cache
108
+ * would refuse the second. */
109
+ const verifiedCredentials = new Set();
110
+ function credentialSignature(cred) {
111
+ const whole = JSON.stringify([cred.id, cred.pubkey, cred.created_at, cred.kind, cred.tags, cred.content, cred.sig]);
112
+ if (verifiedCredentials.has(whole))
113
+ return true;
114
+ if (!verifyEventUncached(cred))
115
+ return false;
116
+ verifiedCredentials.add(whole);
117
+ if (verifiedCredentials.size > 4_096)
118
+ verifiedCredentials.delete(verifiedCredentials.values().next().value);
119
+ return true;
120
+ }
121
+ export function verifyDeviceCredential(cred, opts) {
122
+ if (cred.kind !== KINDS.CREDENTIAL)
123
+ return { ok: false, reason: 'wrong kind' };
124
+ const d = cred.tags.find((t) => t[0] === 'd')?.[1];
125
+ const scope = cred.tags.find((t) => t[0] === 'scope')?.[1];
126
+ if (d === undefined)
127
+ return { ok: false, reason: 'no scope' };
128
+ const isPerson = scope === 'person';
129
+ if (scope !== undefined && !isPerson)
130
+ return { ok: false, reason: 'unknown scope' };
131
+ if ('identity' in opts) {
132
+ if (!isPerson)
133
+ return { ok: false, reason: 'not a person credential' };
134
+ if (!hexEquals(d, opts.identity) || !hexEquals(d, cred.pubkey))
135
+ return { ok: false, reason: 'wrong person' };
136
+ }
137
+ else if (isPerson) {
138
+ if (!opts.acceptPerson)
139
+ return { ok: false, reason: 'person credential where a room credential was expected' };
140
+ if (!hexEquals(d, cred.pubkey))
141
+ return { ok: false, reason: 'wrong person' };
142
+ }
143
+ else if (!hexEquals(d, opts.roomId)) {
144
+ return { ok: false, reason: 'wrong room' };
145
+ }
146
+ const expiration = cred.tags.find((t) => t[0] === 'expiration')?.[1];
147
+ // A missing tag and a present-but-non-numeric one are the same failure:
148
+ // there is no usable expiry to check against. Folding them into one
149
+ // `Number.isFinite` guard matters because `Number(expiration)` on
150
+ // anything non-numeric is NaN, and every comparison with NaN - including
151
+ // `NaN <= now` - is false, so the naive check below would treat a
152
+ // corrupted tag as never expiring: a fail-open default in a security
153
+ // check, even though the tag sits inside the signed content today and so
154
+ // is not exploitable while the signature check below still runs.
155
+ const expiresAt = expiration === undefined ? NaN : Number(expiration);
156
+ if (!Number.isFinite(expiresAt))
157
+ return { ok: false, reason: 'no expiration' };
158
+ if (expiresAt <= opts.now)
159
+ return { ok: false, reason: 'expired' };
160
+ if (isPerson && expiresAt - cred.created_at > PERSON_CREDENTIAL_MAX_SECONDS)
161
+ return { ok: false, reason: 'longer than 30 days' };
162
+ const device = cred.tags.find((t) => t[0] === 'device')?.[1];
163
+ if (!device)
164
+ return { ok: false, reason: 'no device' };
165
+ // Signature last: it is the most expensive check, and tampering with any tag
166
+ // above invalidates it anyway. Never nostr-tools' own cached verdict, which
167
+ // a credential could arrive carrying - see `verify.ts` - but our bounded
168
+ // cache, which matches only the identical event.
169
+ if (!credentialSignature(cred))
170
+ return { ok: false, reason: 'bad signature' };
171
+ // A credential is one of the places a device/participant pubkey enters
172
+ // the system - the `device` tag in particular is free text set by
173
+ // whoever minted the credential. Canonicalise both here so every caller
174
+ // (roster decode, secondary-device adoption) compares against something
175
+ // already lower-case, rather than each having to know to.
176
+ return { ok: true, participant: normaliseHex(cred.pubkey), device: normaliseHex(device) };
177
+ }