@forgesworn/fold-kit 0.0.0-seed.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +154 -4
- package/dist/access.d.ts +34 -0
- package/dist/access.js +102 -0
- package/dist/channel.d.ts +27 -0
- package/dist/channel.js +38 -0
- package/dist/credential.d.ts +58 -0
- package/dist/credential.js +145 -0
- package/dist/display-name.d.ts +38 -0
- package/dist/display-name.js +78 -0
- package/dist/epoch.d.ts +387 -0
- package/dist/epoch.js +665 -0
- package/dist/hex.d.ts +28 -0
- package/dist/hex.js +32 -0
- package/dist/identity.d.ts +49 -0
- package/dist/identity.js +17 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +17 -0
- package/dist/invitation.d.ts +217 -0
- package/dist/invitation.js +484 -0
- package/dist/kinds.d.ts +64 -0
- package/dist/kinds.js +64 -0
- package/dist/lane.d.ts +41 -0
- package/dist/lane.js +63 -0
- package/dist/link.d.ts +52 -0
- package/dist/link.js +107 -0
- package/dist/network-hints.d.ts +16 -0
- package/dist/network-hints.js +65 -0
- package/dist/persistent-invitation.d.ts +35 -0
- package/dist/persistent-invitation.js +118 -0
- package/dist/room.d.ts +53 -0
- package/dist/room.js +153 -0
- package/dist/transport.d.ts +24 -0
- package/dist/transport.js +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +1 -0
- package/dist/verify.d.ts +26 -0
- package/dist/verify.js +46 -0
- package/llms.txt +100 -0
- package/package.json +76 -6
- package/vectors/circle-vectors.json +2173 -0
- package/vectors/kithmoot-vectors.json +1550 -0
package/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
|
|
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
|
|
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
|
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,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;
|