@forgesworn/fold-kit 0.0.0-seed.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -4
- package/dist/access.d.ts +34 -0
- package/dist/access.js +102 -0
- package/dist/channel.d.ts +27 -0
- package/dist/channel.js +38 -0
- package/dist/credential.d.ts +74 -0
- package/dist/credential.js +177 -0
- package/dist/display-name.d.ts +38 -0
- package/dist/display-name.js +78 -0
- package/dist/epoch.d.ts +387 -0
- package/dist/epoch.js +665 -0
- package/dist/hex.d.ts +28 -0
- package/dist/hex.js +32 -0
- package/dist/identity.d.ts +49 -0
- package/dist/identity.js +17 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +19 -0
- package/dist/invitation.d.ts +217 -0
- package/dist/invitation.js +484 -0
- package/dist/kinds.d.ts +64 -0
- package/dist/kinds.js +64 -0
- package/dist/lane.d.ts +41 -0
- package/dist/lane.js +63 -0
- package/dist/link.d.ts +52 -0
- package/dist/link.js +107 -0
- package/dist/network-hints.d.ts +16 -0
- package/dist/network-hints.js +65 -0
- package/dist/persistent-invitation.d.ts +35 -0
- package/dist/persistent-invitation.js +118 -0
- package/dist/room.d.ts +53 -0
- package/dist/room.js +153 -0
- package/dist/scoped.d.ts +67 -0
- package/dist/scoped.js +83 -0
- package/dist/sub-cert.d.ts +117 -0
- package/dist/sub-cert.js +192 -0
- package/dist/transport.d.ts +24 -0
- package/dist/transport.js +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +1 -0
- package/dist/verify.d.ts +26 -0
- package/dist/verify.js +46 -0
- package/llms.txt +106 -0
- package/package.json +77 -6
- package/vectors/circle-vectors.json +2174 -0
- package/vectors/kithmoot-vectors.json +1550 -0
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
|
|
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
|
|
11
|
-
byte-identical and is pinned by known-answer
|
|
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
|
|
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
|
package/dist/access.d.ts
ADDED
|
@@ -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/"];
|
package/dist/channel.js
ADDED
|
@@ -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
|
+
}
|