@forgesworn/fold-kit 0.1.0 → 0.3.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 +37 -2
- package/dist/credential.d.ts +16 -0
- package/dist/credential.js +32 -0
- package/dist/epoch.d.ts +13 -0
- package/dist/epoch.js +10 -7
- package/dist/expiration.d.ts +31 -0
- package/dist/expiration.js +62 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -1
- package/dist/invitation.d.ts +3 -0
- package/dist/invitation.js +2 -1
- package/dist/persistent-invitation.d.ts +7 -0
- package/dist/persistent-invitation.js +18 -3
- 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/llms.txt +9 -0
- package/package.json +2 -1
- package/vectors/circle-vectors.json +2 -1
package/README.md
CHANGED
|
@@ -93,7 +93,10 @@ the rest).
|
|
|
93
93
|
`parseRoomPolicy` - the legacy v1 join URL and room id/key derivation
|
|
94
94
|
- `createDeviceCredential`, `verifyDeviceCredential`,
|
|
95
95
|
`PERSON_CREDENTIAL_MAX_SECONDS` - room-scope and person-scope device
|
|
96
|
-
credentials
|
|
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
|
|
97
100
|
- `issueKindredProof`, `evaluateAccess` - kindred-tier admission
|
|
98
101
|
- `sanitiseDisplayName`, `MAX_DISPLAY_NAME_LENGTH` - defused display names
|
|
99
102
|
- `safeRelayUrls`, `safeIceUrls`, `assertNetworkHintBounds`, and the related
|
|
@@ -110,7 +113,16 @@ the rest).
|
|
|
110
113
|
`requestRoomAdmission` - the v2 live rendezvous invitation, its delegation
|
|
111
114
|
chain and retirement
|
|
112
115
|
- `encodePersistentInvitation`, `decodePersistentInvitation`,
|
|
113
|
-
`requestPersistentRoomAdmission` - the v3 stored group invitation (1463)
|
|
116
|
+
`requestPersistentRoomAdmission` - the v3 stored group invitation (1463).
|
|
117
|
+
Pass `endsAt` (unix seconds, after `now` and at most 30 days on) for a
|
|
118
|
+
conference room: the encrypted body carries `ends` and the event a NIP-40
|
|
119
|
+
`expiration` tag, so relays drop it when the room ends; the decoder returns
|
|
120
|
+
`endsAt` and refuses a tag that disagrees with the body
|
|
121
|
+
- `withExpiration`, `isRoomEnds`, `requireRoomEnds`, `MAX_ROOM_ENDS_SECONDS` -
|
|
122
|
+
the conference-room expiration rule: add the end as an `expiration` tag,
|
|
123
|
+
keep an earlier one, lower a later one. `encodeInvitationRetirement` takes
|
|
124
|
+
`endsAt`, and the epoch encoders, `hostRoomEpoch` and `requestRoomEpoch`
|
|
125
|
+
take `expiresAt`, to tag what they sign the same way
|
|
114
126
|
|
|
115
127
|
### Epochs (removal by rekey)
|
|
116
128
|
|
|
@@ -127,6 +139,28 @@ the rest).
|
|
|
127
139
|
- `canonicalChannels`, `signChannels`, `verifyChannels`, `CHANNEL_NAME`,
|
|
128
140
|
`RESERVED_CHANNELS` - the authority's signed channel list
|
|
129
141
|
|
|
142
|
+
### Scoped labels and sub-key certificates (a consuming app's own keys)
|
|
143
|
+
|
|
144
|
+
- `deriveScoped` - derive an app-defined `{ id, key }` pair from an epoch key
|
|
145
|
+
under an app's own labelled namespace (e.g. `myapp/v1/board/<id>/update`),
|
|
146
|
+
so an app can ride the same epoch as the roster and the chat - and rotate
|
|
147
|
+
on the same rekey - without ever deriving a key that could be mistaken for
|
|
148
|
+
a KithMoot channel. Refuses this kit's own protocol namespace and any
|
|
149
|
+
label outside `SCOPED_LABEL_PATTERN`.
|
|
150
|
+
- `createSubKeyCertificate`, `verifySubKeyCertificate`,
|
|
151
|
+
`SUB_KEY_CERTIFICATE_SCOPE` - a small, locally-signed statement that a
|
|
152
|
+
credentialled device minted a particular app-derived key (a `deriveScoped`
|
|
153
|
+
output, typically) for a particular scoped id. Rides inside a signed
|
|
154
|
+
message's ciphertext alongside the device credential it depends on; never
|
|
155
|
+
published on its own, and never accepted by `verifyDeviceCredential` (its
|
|
156
|
+
`scope: "sub"` is a value that function already refuses). Verification is
|
|
157
|
+
strict and canonical: exactly four two-element tags in a fixed order,
|
|
158
|
+
empty `content`, an `expiration` in canonical decimal form compared as the
|
|
159
|
+
exact tag string (not numerically) against the device credential passed
|
|
160
|
+
in - `verifySubKeyCertificate` reads that credential's own `device` and
|
|
161
|
+
`expiration` tags itself, rather than trusting a caller to have copied
|
|
162
|
+
them out correctly.
|
|
163
|
+
|
|
130
164
|
### Channel (main entry, and `deriveChannel` only)
|
|
131
165
|
|
|
132
166
|
- `deriveChannel`, `CHANNEL_ID_INFO`, `CHANNEL_KEY_INFO`,
|
|
@@ -167,6 +201,7 @@ npm test # run the Vitest suite, including both vector files
|
|
|
167
201
|
npm run typecheck # type-check src/ and test/ (matching KithMoot, vectors/ is not tsc-checked)
|
|
168
202
|
npm run vectors # run only the vector-verification suites
|
|
169
203
|
npm run diff-source # compare moved modules against the pinned source commit (needs FOLD_KIT_SOURCE_DIR)
|
|
204
|
+
npm run generate-fold # regenerate vectors/fold-vectors.json (needs a prior build)
|
|
170
205
|
npm run bundle-check # esbuild browser bundle check (needs a prior build)
|
|
171
206
|
npm run check # typecheck + test + diff-source
|
|
172
207
|
```
|
package/dist/credential.d.ts
CHANGED
|
@@ -28,6 +28,22 @@ export interface CreateCredentialOptions {
|
|
|
28
28
|
}
|
|
29
29
|
/** The longest a person-scoped credential may run. A phone that leaves the house is better at seven days. */
|
|
30
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
|
+
}
|
|
31
47
|
/**
|
|
32
48
|
* Authorise a device to act for a participant in one room, until an expiry.
|
|
33
49
|
*
|
package/dist/credential.js
CHANGED
|
@@ -3,6 +3,26 @@ import { verifyEventUncached } from './verify.js';
|
|
|
3
3
|
import { hexEquals, normaliseHex } from './hex.js';
|
|
4
4
|
/** The longest a person-scoped credential may run. A phone that leaves the house is better at seven days. */
|
|
5
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
|
+
}
|
|
6
26
|
/**
|
|
7
27
|
* Authorise a device to act for a participant in one room, until an expiry.
|
|
8
28
|
*
|
|
@@ -58,6 +78,18 @@ export async function createDeviceCredential(opts) {
|
|
|
58
78
|
if (!verifyEventUncached(signed)) {
|
|
59
79
|
throw new Error('the signer returned a credential that does not verify');
|
|
60
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
|
+
}
|
|
61
93
|
return signed;
|
|
62
94
|
}
|
|
63
95
|
/**
|
package/dist/epoch.d.ts
CHANGED
|
@@ -104,6 +104,9 @@ export interface EncodeRekeyOptions {
|
|
|
104
104
|
* says so rather than leaving everybody to wonder. */
|
|
105
105
|
closed?: boolean;
|
|
106
106
|
now: number;
|
|
107
|
+
/** A conference room's end, in unix seconds: the event carries it as a
|
|
108
|
+
* NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
|
|
109
|
+
expiresAt?: number;
|
|
107
110
|
}
|
|
108
111
|
/** Announce the next epoch. See the module comment for what this is. */
|
|
109
112
|
export declare function encodeRekeyEvent(opts: EncodeRekeyOptions): Event;
|
|
@@ -189,6 +192,9 @@ export interface EncodeEpochRequestOptions {
|
|
|
189
192
|
credential: DeviceCredential;
|
|
190
193
|
proof?: KindredProof;
|
|
191
194
|
now: number;
|
|
195
|
+
/** A conference room's end, in unix seconds: the event carries it as a
|
|
196
|
+
* NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
|
|
197
|
+
expiresAt?: number;
|
|
192
198
|
}
|
|
193
199
|
/** Ask the authority which epoch the room is at, and for its secret. */
|
|
194
200
|
export declare function encodeEpochRequest(opts: EncodeEpochRequestOptions): Event;
|
|
@@ -226,6 +232,9 @@ export interface EncodeEpochGrantOptions {
|
|
|
226
232
|
epoch?: RoomEpoch;
|
|
227
233
|
removed?: string[];
|
|
228
234
|
refused?: EpochRefusal;
|
|
235
|
+
/** A conference room's end, in unix seconds: the event carries it as a
|
|
236
|
+
* NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
|
|
237
|
+
expiresAt?: number;
|
|
229
238
|
}
|
|
230
239
|
/** Answer one request: the current epoch sealed to the asking device, or a
|
|
231
240
|
* refusal it alone can read. */
|
|
@@ -268,6 +277,8 @@ export interface HostRoomEpochOptions {
|
|
|
268
277
|
now?: () => number;
|
|
269
278
|
onGranted?: (request: EpochRequest) => void;
|
|
270
279
|
onRefused?: (request: EpochRequest, why: EpochRefusal) => void;
|
|
280
|
+
/** A conference room's end: every grant carries it as an expiration. */
|
|
281
|
+
expiresAt?: number;
|
|
271
282
|
}
|
|
272
283
|
/**
|
|
273
284
|
* Answer epoch requests for as long as the handle is open. What a keeper
|
|
@@ -294,6 +305,8 @@ export interface RequestRoomEpochOptions {
|
|
|
294
305
|
now?: () => number;
|
|
295
306
|
timeoutMs?: number;
|
|
296
307
|
retryMs?: number;
|
|
308
|
+
/** A conference room's end: the request carries it as an expiration. */
|
|
309
|
+
expiresAt?: number;
|
|
297
310
|
}
|
|
298
311
|
/** Thrown when the authority answered, and the answer was no. */
|
|
299
312
|
export declare class EpochRefusedError extends Error {
|
package/dist/epoch.js
CHANGED
|
@@ -12,6 +12,7 @@ import { deriveRoom } from './room.js';
|
|
|
12
12
|
import { verifyDeviceCredential } from './credential.js';
|
|
13
13
|
import { evaluateAccess } from './access.js';
|
|
14
14
|
import { verifyEventUncached } from './verify.js';
|
|
15
|
+
import { withExpiration } from './expiration.js';
|
|
15
16
|
/**
|
|
16
17
|
* Room epochs: how a member is removed.
|
|
17
18
|
*
|
|
@@ -144,10 +145,10 @@ export function encodeRekeyEvent(opts) {
|
|
|
144
145
|
return finalizeEvent({
|
|
145
146
|
kind: KINDS.ROOM_REKEY,
|
|
146
147
|
created_at: opts.now,
|
|
147
|
-
tags: [
|
|
148
|
+
tags: withExpiration([
|
|
148
149
|
['d', roomId],
|
|
149
150
|
['epoch', String(epoch)],
|
|
150
|
-
],
|
|
151
|
+
], opts.expiresAt),
|
|
151
152
|
content: nip44.v2.encrypt(JSON.stringify(body), opts.current.key),
|
|
152
153
|
}, opts.authoritySk);
|
|
153
154
|
}
|
|
@@ -282,10 +283,10 @@ export function encodeEpochRequest(opts) {
|
|
|
282
283
|
return finalizeEvent({
|
|
283
284
|
kind: KINDS.EPOCH_REQUEST,
|
|
284
285
|
created_at: opts.now,
|
|
285
|
-
tags: [
|
|
286
|
+
tags: withExpiration([
|
|
286
287
|
['d', roomId],
|
|
287
288
|
['p', authority],
|
|
288
|
-
],
|
|
289
|
+
], opts.expiresAt),
|
|
289
290
|
content: nip44.v2.encrypt(JSON.stringify(body), nip44.v2.utils.getConversationKey(opts.deviceSk, authority)),
|
|
290
291
|
}, opts.deviceSk);
|
|
291
292
|
}
|
|
@@ -367,10 +368,10 @@ export function encodeEpochGrant(opts) {
|
|
|
367
368
|
return finalizeEvent({
|
|
368
369
|
kind: KINDS.EPOCH_GRANT,
|
|
369
370
|
created_at: opts.now,
|
|
370
|
-
tags: [
|
|
371
|
+
tags: withExpiration([
|
|
371
372
|
['d', roomId],
|
|
372
373
|
['p', device],
|
|
373
|
-
],
|
|
374
|
+
], opts.expiresAt),
|
|
374
375
|
content: nip44.v2.encrypt(JSON.stringify(body), nip44.v2.utils.getConversationKey(opts.authoritySk, device)),
|
|
375
376
|
}, opts.authoritySk);
|
|
376
377
|
}
|
|
@@ -458,7 +459,7 @@ export function hostRoomEpoch(opts) {
|
|
|
458
459
|
let grant;
|
|
459
460
|
try {
|
|
460
461
|
grant = refused
|
|
461
|
-
? encodeEpochGrant({ roomId, authoritySk: opts.authoritySk, device: request.device, request: request.request, now: now(), refused })
|
|
462
|
+
? encodeEpochGrant({ roomId, authoritySk: opts.authoritySk, device: request.device, request: request.request, now: now(), refused, expiresAt: opts.expiresAt })
|
|
462
463
|
: encodeEpochGrant({
|
|
463
464
|
roomId,
|
|
464
465
|
authoritySk: opts.authoritySk,
|
|
@@ -467,6 +468,7 @@ export function hostRoomEpoch(opts) {
|
|
|
467
468
|
now: now(),
|
|
468
469
|
epoch: opts.current(),
|
|
469
470
|
removed: [...opts.removed()],
|
|
471
|
+
expiresAt: opts.expiresAt,
|
|
470
472
|
});
|
|
471
473
|
}
|
|
472
474
|
catch {
|
|
@@ -511,6 +513,7 @@ export function requestRoomEpoch(opts) {
|
|
|
511
513
|
credential: opts.credential,
|
|
512
514
|
proof: opts.proof,
|
|
513
515
|
now: now(),
|
|
516
|
+
expiresAt: opts.expiresAt,
|
|
514
517
|
});
|
|
515
518
|
return new Promise((resolve, reject) => {
|
|
516
519
|
let settled = false;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NIP-40 expiration for a room that ends: a conference room.
|
|
3
|
+
*
|
|
4
|
+
* A conference room is a persistent group with a fixed end time. Every event
|
|
5
|
+
* signed for it carries `['expiration', String(ends)]`, so relays that honour
|
|
6
|
+
* NIP-40 drop the room's traffic once it has ended, rather than holding it
|
|
7
|
+
* for as long as they keep anything. Not new in this kit: the shape is the
|
|
8
|
+
* plain NIP-40 tag; what is decided here is how it meets an event that
|
|
9
|
+
* already carries one of its own (a signal wrap, a call bell).
|
|
10
|
+
*/
|
|
11
|
+
/** The furthest ahead a conference room may end, from the moment it is
|
|
12
|
+
* created: thirty days. */
|
|
13
|
+
export declare const MAX_ROOM_ENDS_SECONDS: number;
|
|
14
|
+
/** True for a whole number of unix seconds a room could end at: a positive
|
|
15
|
+
* safe integer. Says nothing about whether it is in the future. */
|
|
16
|
+
export declare function isRoomEnds(value: unknown): value is number;
|
|
17
|
+
/** Throws unless `endsAt` is a valid end for a room created at `now`: after
|
|
18
|
+
* `now`, and no more than `MAX_ROOM_ENDS_SECONDS` beyond it. */
|
|
19
|
+
export declare function requireRoomEnds(endsAt: number, now: number): number;
|
|
20
|
+
/**
|
|
21
|
+
* `tags` with the room's end applied as a NIP-40 expiration.
|
|
22
|
+
*
|
|
23
|
+
* An event with no expiration gains `['expiration', String(expiresAt)]`. One
|
|
24
|
+
* that already expires earlier keeps its own: the room ending does not make
|
|
25
|
+
* a short-lived event live longer. One that expires later is lowered to
|
|
26
|
+
* `expiresAt`. Never more than one expiration tag comes back; any extra is
|
|
27
|
+
* folded into the earliest. `expiresAt` undefined returns `tags` untouched,
|
|
28
|
+
* the same array, so a room with no end signs exactly the bytes it always
|
|
29
|
+
* did.
|
|
30
|
+
*/
|
|
31
|
+
export declare function withExpiration(tags: string[][], expiresAt: number | undefined): string[][];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NIP-40 expiration for a room that ends: a conference room.
|
|
3
|
+
*
|
|
4
|
+
* A conference room is a persistent group with a fixed end time. Every event
|
|
5
|
+
* signed for it carries `['expiration', String(ends)]`, so relays that honour
|
|
6
|
+
* NIP-40 drop the room's traffic once it has ended, rather than holding it
|
|
7
|
+
* for as long as they keep anything. Not new in this kit: the shape is the
|
|
8
|
+
* plain NIP-40 tag; what is decided here is how it meets an event that
|
|
9
|
+
* already carries one of its own (a signal wrap, a call bell).
|
|
10
|
+
*/
|
|
11
|
+
/** The furthest ahead a conference room may end, from the moment it is
|
|
12
|
+
* created: thirty days. */
|
|
13
|
+
export const MAX_ROOM_ENDS_SECONDS = 30 * 24 * 60 * 60;
|
|
14
|
+
/** True for a whole number of unix seconds a room could end at: a positive
|
|
15
|
+
* safe integer. Says nothing about whether it is in the future. */
|
|
16
|
+
export function isRoomEnds(value) {
|
|
17
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
18
|
+
}
|
|
19
|
+
/** Throws unless `endsAt` is a valid end for a room created at `now`: after
|
|
20
|
+
* `now`, and no more than `MAX_ROOM_ENDS_SECONDS` beyond it. */
|
|
21
|
+
export function requireRoomEnds(endsAt, now) {
|
|
22
|
+
if (!isRoomEnds(endsAt))
|
|
23
|
+
throw new Error('a room end must be a positive whole number of seconds');
|
|
24
|
+
if (endsAt <= now)
|
|
25
|
+
throw new Error('a room cannot end in the past');
|
|
26
|
+
if (endsAt > now + MAX_ROOM_ENDS_SECONDS)
|
|
27
|
+
throw new Error('a room cannot end more than 30 days from now');
|
|
28
|
+
return endsAt;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* `tags` with the room's end applied as a NIP-40 expiration.
|
|
32
|
+
*
|
|
33
|
+
* An event with no expiration gains `['expiration', String(expiresAt)]`. One
|
|
34
|
+
* that already expires earlier keeps its own: the room ending does not make
|
|
35
|
+
* a short-lived event live longer. One that expires later is lowered to
|
|
36
|
+
* `expiresAt`. Never more than one expiration tag comes back; any extra is
|
|
37
|
+
* folded into the earliest. `expiresAt` undefined returns `tags` untouched,
|
|
38
|
+
* the same array, so a room with no end signs exactly the bytes it always
|
|
39
|
+
* did.
|
|
40
|
+
*/
|
|
41
|
+
export function withExpiration(tags, expiresAt) {
|
|
42
|
+
if (expiresAt === undefined)
|
|
43
|
+
return tags;
|
|
44
|
+
if (!isRoomEnds(expiresAt))
|
|
45
|
+
throw new Error('an expiration must be a positive whole number of seconds');
|
|
46
|
+
let earliest = expiresAt;
|
|
47
|
+
let first = -1;
|
|
48
|
+
tags.forEach((tag, i) => {
|
|
49
|
+
if (tag[0] !== 'expiration')
|
|
50
|
+
return;
|
|
51
|
+
if (first === -1)
|
|
52
|
+
first = i;
|
|
53
|
+
const own = Number(tag[1]);
|
|
54
|
+
if (isRoomEnds(own) && own < earliest)
|
|
55
|
+
earliest = own;
|
|
56
|
+
});
|
|
57
|
+
const out = tags.filter((tag, i) => tag[0] !== 'expiration' || i === first);
|
|
58
|
+
const tag = ['expiration', String(earliest)];
|
|
59
|
+
if (first === -1)
|
|
60
|
+
return [...out, tag];
|
|
61
|
+
return out.map((t) => (t[0] === 'expiration' ? tag : t));
|
|
62
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,8 +5,12 @@ export { localIdentity } from './identity.js';
|
|
|
5
5
|
export { KINDS } from './kinds.js';
|
|
6
6
|
export type { DeviceCredential, AccessTier, AgentRule, RoomPolicy, KindredProof } from './types.js';
|
|
7
7
|
export type { RelayTransport } from './transport.js';
|
|
8
|
-
export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, } from './credential.js';
|
|
8
|
+
export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, RestampedCredentialExpiryError, } from './credential.js';
|
|
9
9
|
export type { CreateCredentialOptions, VerifyResult } from './credential.js';
|
|
10
|
+
export { deriveScoped, SCOPED_LABEL_PATTERN } from './scoped.js';
|
|
11
|
+
export type { ScopedKeys } from './scoped.js';
|
|
12
|
+
export { createSubKeyCertificate, verifySubKeyCertificate, SUB_KEY_CERTIFICATE_SCOPE, } from './sub-cert.js';
|
|
13
|
+
export type { CreateSubKeyCertificateOptions, VerifySubKeyCertificateOptions, VerifySubKeyCertificateResult, } from './sub-cert.js';
|
|
10
14
|
export { generateRoomSecret, deriveRoom, encodeJoinUrl, decodeJoinUrl, parseRoomPolicy, ROOM_LABELS, } from './room.js';
|
|
11
15
|
export { MAX_RELAY_HINTS, MAX_ICE_HINTS, MAX_NETWORK_HINT_LENGTH, isSafeRelayUrl, safeRelayUrls, isSafeIceUrl, safeIceUrls, assertNetworkHintBounds, } from './network-hints.js';
|
|
12
16
|
export { sanitiseDisplayName, MAX_DISPLAY_NAME_LENGTH } from './display-name.js';
|
|
@@ -16,6 +20,7 @@ export { INVITATION_DELEGATION_TTL_SECONDS, MAX_INVITATION_DELEGATION_DEPTH, cre
|
|
|
16
20
|
export type { RoomInvitation, RoomInvitationHost, InvitationDelegation, RoomInvitationDelegate, RoomAdmission, EncodeInvitationRequestOptions, InvitationRequest, DecodeInvitationRequestOptions, EncodeInvitationGrantOptions, DecodeInvitationGrantOptions, HostRoomInvitationOptions, EncodeInvitationRetirementOptions, RequestRoomAdmissionOptions, } from './invitation.js';
|
|
17
21
|
export { encodePersistentInvitation, decodePersistentInvitation, requestPersistentRoomAdmission, PERSISTENT_INVITATION_LABELS, } from './persistent-invitation.js';
|
|
18
22
|
export type { PersistentRoomAdmission } from './persistent-invitation.js';
|
|
23
|
+
export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
|
|
19
24
|
export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
|
|
20
25
|
export type { RoomLink } from './link.js';
|
|
21
26
|
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
|
package/dist/index.js
CHANGED
|
@@ -4,13 +4,16 @@ export { hexEquals, normaliseHex } from './hex.js';
|
|
|
4
4
|
export { verifyEventUncached, boundedEventVerifier } from './verify.js';
|
|
5
5
|
export { localIdentity } from './identity.js';
|
|
6
6
|
export { KINDS } from './kinds.js';
|
|
7
|
-
export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, } from './credential.js';
|
|
7
|
+
export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, RestampedCredentialExpiryError, } from './credential.js';
|
|
8
|
+
export { deriveScoped, SCOPED_LABEL_PATTERN } from './scoped.js';
|
|
9
|
+
export { createSubKeyCertificate, verifySubKeyCertificate, SUB_KEY_CERTIFICATE_SCOPE, } from './sub-cert.js';
|
|
8
10
|
export { generateRoomSecret, deriveRoom, encodeJoinUrl, decodeJoinUrl, parseRoomPolicy, ROOM_LABELS, } from './room.js';
|
|
9
11
|
export { MAX_RELAY_HINTS, MAX_ICE_HINTS, MAX_NETWORK_HINT_LENGTH, isSafeRelayUrl, safeRelayUrls, isSafeIceUrl, safeIceUrls, assertNetworkHintBounds, } from './network-hints.js';
|
|
10
12
|
export { sanitiseDisplayName, MAX_DISPLAY_NAME_LENGTH } from './display-name.js';
|
|
11
13
|
export { issueKindredProof, evaluateAccess, ACCESS_LABELS, } from './access.js';
|
|
12
14
|
export { INVITATION_DELEGATION_TTL_SECONDS, MAX_INVITATION_DELEGATION_DEPTH, createRoomInvitation, roomInvitation, deriveInvitationId, encodeInvitationRequest, decodeInvitationRequest, verifyInvitationDelegation, encodeInvitationGrant, decodeRoomAdmissionGrant, decodeInvitationGrant, ROOM_ENDED_MESSAGE, encodeInvitationRetirement, decodeInvitationRetirement, decodeInvitationRetirementNotice, retirementError, hostRoomInvitation, requestRoomAdmissionCapability, requestRoomAdmission, INVITATION_LABELS, } from './invitation.js';
|
|
13
15
|
export { encodePersistentInvitation, decodePersistentInvitation, requestPersistentRoomAdmission, PERSISTENT_INVITATION_LABELS, } from './persistent-invitation.js';
|
|
16
|
+
export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
|
|
14
17
|
export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
|
|
15
18
|
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
|
|
16
19
|
export { deriveChannel, CHANNEL_ID_INFO, CHANNEL_KEY_INFO, MAX_CHANNEL_NAME_LENGTH, CHANNEL_LABELS } from './channel.js';
|
package/dist/invitation.d.ts
CHANGED
|
@@ -165,6 +165,9 @@ export interface EncodeInvitationRetirementOptions {
|
|
|
165
165
|
/** The room itself was ended, not just this link replaced. Additive: a
|
|
166
166
|
* reader that predates it still sees an ordinary retirement. */
|
|
167
167
|
ended?: boolean;
|
|
168
|
+
/** A conference room's end, in unix seconds: the tombstone carries the
|
|
169
|
+
* same NIP-40 expiration as the invitation it retires, and lapses with it. */
|
|
170
|
+
endsAt?: number;
|
|
168
171
|
}
|
|
169
172
|
/** What a joiner is told when a link was retired because its room ended. */
|
|
170
173
|
export declare const ROOM_ENDED_MESSAGE = "this room was ended by the person who started it";
|
package/dist/invitation.js
CHANGED
|
@@ -8,6 +8,7 @@ import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure
|
|
|
8
8
|
import { hexEquals, normaliseHex } from './hex.js';
|
|
9
9
|
import { KINDS } from './kinds.js';
|
|
10
10
|
import { verifyEventUncached } from './verify.js';
|
|
11
|
+
import { withExpiration } from './expiration.js';
|
|
11
12
|
import { deriveRoom } from './room.js';
|
|
12
13
|
const INVITATION_ID_INFO = 'kithmoot/v2/invitation-id';
|
|
13
14
|
const INVITATION_REQUEST_KEY_INFO = 'kithmoot/v2/invitation-request-key';
|
|
@@ -281,7 +282,7 @@ export function encodeInvitationRetirement(opts) {
|
|
|
281
282
|
return finalizeEvent({
|
|
282
283
|
kind: KINDS.INVITATION_RETIREMENT,
|
|
283
284
|
created_at: opts.now,
|
|
284
|
-
tags: [['d', deriveInvitationId(opts.invitation)]],
|
|
285
|
+
tags: withExpiration([['d', deriveInvitationId(opts.invitation)]], opts.endsAt),
|
|
285
286
|
content: JSON.stringify(opts.ended ? { v: 1, ended: true } : { v: 1 }),
|
|
286
287
|
}, opts.inviterSk);
|
|
287
288
|
}
|
|
@@ -7,6 +7,9 @@ export interface PersistentRoomAdmission {
|
|
|
7
7
|
secret: Uint8Array;
|
|
8
8
|
persistent: true;
|
|
9
9
|
epoch: 0;
|
|
10
|
+
/** When the room ends, in unix seconds: a conference room. Absent for a
|
|
11
|
+
* group that runs until somebody ends it. */
|
|
12
|
+
endsAt?: number;
|
|
10
13
|
}
|
|
11
14
|
/** Publish once before sharing. The link plus this envelope is a durable
|
|
12
15
|
* bearer capability: anybody holding both can learn epoch 0. Retirement
|
|
@@ -16,6 +19,10 @@ export declare function encodePersistentInvitation(opts: {
|
|
|
16
19
|
inviterSk: Uint8Array;
|
|
17
20
|
roomSecret: Uint8Array;
|
|
18
21
|
now: number;
|
|
22
|
+
/** A conference room's end, in unix seconds: after `now` and no more than
|
|
23
|
+
* 30 days beyond it. Carried in the body and as a NIP-40 expiration, so
|
|
24
|
+
* relays drop the invitation when the room ends. */
|
|
25
|
+
endsAt?: number;
|
|
19
26
|
}): Event;
|
|
20
27
|
export declare function decodePersistentInvitation(event: Event, invitation: RoomInvitation): PersistentRoomAdmission | null;
|
|
21
28
|
/** Fetch the signed envelope and retirement in ONE stored-event query.
|
|
@@ -7,6 +7,7 @@ import { decodeInvitationRetirementNotice, deriveInvitationId, retirementError }
|
|
|
7
7
|
import { KINDS } from './kinds.js';
|
|
8
8
|
import { deriveRoom } from './room.js';
|
|
9
9
|
import { verifyEventUncached } from './verify.js';
|
|
10
|
+
import { isRoomEnds, requireRoomEnds } from './expiration.js';
|
|
10
11
|
function welcomeKey(invitation) {
|
|
11
12
|
if (!invitation.persistent)
|
|
12
13
|
throw new Error('a persistent group invitation is required');
|
|
@@ -21,12 +22,13 @@ export function encodePersistentInvitation(opts) {
|
|
|
21
22
|
if (getPublicKey(opts.inviterSk) !== opts.invitation.inviter)
|
|
22
23
|
throw new Error('only the inviter can publish a group invitation');
|
|
23
24
|
const room = deriveRoom(opts.roomSecret).roomId;
|
|
25
|
+
const ends = opts.endsAt === undefined ? undefined : requireRoomEnds(opts.endsAt, opts.now);
|
|
24
26
|
return finalizeEvent({
|
|
25
27
|
kind: KINDS.GROUP_INVITATION,
|
|
26
28
|
created_at: opts.now,
|
|
27
|
-
tags: [['d', deriveInvitationId(opts.invitation)]],
|
|
29
|
+
tags: ends === undefined ? [['d', deriveInvitationId(opts.invitation)]] : [['d', deriveInvitationId(opts.invitation)], ['expiration', String(ends)]],
|
|
28
30
|
content: nip44.v2.encrypt(JSON.stringify({
|
|
29
|
-
v: 3, room, secret: base64urlnopad.encode(opts.roomSecret),
|
|
31
|
+
v: 3, room, secret: base64urlnopad.encode(opts.roomSecret), ...(ends === undefined ? {} : { ends }),
|
|
30
32
|
}), welcomeKey(opts.invitation)),
|
|
31
33
|
}, opts.inviterSk);
|
|
32
34
|
}
|
|
@@ -45,7 +47,16 @@ export function decodePersistentInvitation(event, invitation) {
|
|
|
45
47
|
const secret = base64urlnopad.decode(body.secret);
|
|
46
48
|
if (deriveRoom(secret).roomId !== body.room)
|
|
47
49
|
return null;
|
|
48
|
-
|
|
50
|
+
// A conference room's end rides in the body and, for relays, as a NIP-40
|
|
51
|
+
// expiration. The two must agree: a tag with no body end, a second tag,
|
|
52
|
+
// or an end that is not a whole number of seconds is refused outright.
|
|
53
|
+
const ends = body.ends === undefined ? undefined : isRoomEnds(body.ends) ? body.ends : null;
|
|
54
|
+
const expirations = event.tags.filter(t => t[0] === 'expiration');
|
|
55
|
+
if (ends === null || expirations.length > 1)
|
|
56
|
+
return null;
|
|
57
|
+
if (expirations.length === 1 && (ends === undefined || expirations[0][1] !== String(ends)))
|
|
58
|
+
return null;
|
|
59
|
+
return ends === undefined ? { secret, persistent: true, epoch: 0 } : { secret, persistent: true, epoch: 0, endsAt: ends };
|
|
49
60
|
}
|
|
50
61
|
catch {
|
|
51
62
|
return null;
|
|
@@ -94,6 +105,10 @@ export function requestPersistentRoomAdmission(opts) {
|
|
|
94
105
|
finish(new Error('the group invitation names conflicting rooms'));
|
|
95
106
|
return;
|
|
96
107
|
}
|
|
108
|
+
// Two signed copies that disagree on when the room ends: the earlier
|
|
109
|
+
// end stands, so a stale copy can never keep a room open longer.
|
|
110
|
+
if (admission?.endsAt !== undefined && (decoded.endsAt === undefined || decoded.endsAt > admission.endsAt))
|
|
111
|
+
decoded.endsAt = admission.endsAt;
|
|
97
112
|
admission = decoded;
|
|
98
113
|
}, () => {
|
|
99
114
|
if (admission)
|
package/dist/scoped.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { EpochKeys } from './epoch.js';
|
|
2
|
+
/**
|
|
3
|
+
* Per-app scoped labels, derived from an epoch key (kit T3.1).
|
|
4
|
+
*
|
|
5
|
+
* A consuming app (a board tool, a task list, anything that rides a
|
|
6
|
+
* KithMoot-derived circle) needs its own payload keys and stream ids that
|
|
7
|
+
* live under the same epoch as the roster and the chat, without ever
|
|
8
|
+
* colliding with a KithMoot channel key or being mistaken for one. Sharing
|
|
9
|
+
* one epoch, rather than deriving a second independent secret, is what lets
|
|
10
|
+
* a board and a room use the same link and the same removal: both derive
|
|
11
|
+
* from the same epoch key, so both move together on a rekey.
|
|
12
|
+
*
|
|
13
|
+
* `deriveScoped` is deliberately NOT `deriveChannel`: a channel name is a
|
|
14
|
+
* KithMoot registry concept, signed off by the room's authority
|
|
15
|
+
* (`epoch.ts`'s `signChannels`) and rendered to members as a place to chat.
|
|
16
|
+
* An app's own scoped keys are neither: nobody signs off on them, and they
|
|
17
|
+
* are never shown as a channel. Reusing `deriveChannel` for them would let
|
|
18
|
+
* an app-chosen string collide with a real channel name, or a channel name
|
|
19
|
+
* collide with an app's payload key, so this is its own derivation under
|
|
20
|
+
* its own info strings.
|
|
21
|
+
*
|
|
22
|
+
* `label` MUST NOT start with this kit's own protocol namespace (see
|
|
23
|
+
* `KITHMOOT_NAMESPACE_PREFIX` below) - that namespace belongs to this kit's
|
|
24
|
+
* own derivations (`EPOCH_ID_INFO`, `CHANNEL_ID_INFO`, and so on), and
|
|
25
|
+
* refusing the prefix here means no app label can ever be mistaken for one
|
|
26
|
+
* of this kit's own, whatever an app supplies. `label` MUST match
|
|
27
|
+
* `^[a-z0-9-]{1,64}/v[0-9]{1,9}/[\x21-\x7e]{1,200}$`: a lower-case app
|
|
28
|
+
* namespace of 1 to 64 bytes, a version segment of 1 to 9 digits (an app's
|
|
29
|
+
* label shape is itself something it may need to change - this pins wire
|
|
30
|
+
* compatibility label by label, not by fiat), then 1 to 200 printable-ASCII
|
|
31
|
+
* bytes naming what the label is for. The namespace and version bounds are
|
|
32
|
+
* deliberately tight - an app label is always a short, fixed, hand-written
|
|
33
|
+
* string, never derived from untrusted input - so an unbounded namespace or
|
|
34
|
+
* version segment (as an earlier revision allowed) is refused rather than
|
|
35
|
+
* accepted and merely hashed away: nothing here needs to parse a namespace
|
|
36
|
+
* or version number back out of a label, but a caller building one by
|
|
37
|
+
* concatenation from attacker-influenced text should get a small, cheap
|
|
38
|
+
* refusal rather than an expensive regex match over untrusted length.
|
|
39
|
+
*
|
|
40
|
+
* `ikm` is the epoch KEY, never the epoch id: publishing a scoped id (which
|
|
41
|
+
* a relay necessarily sees as a `d` tag) must reveal nothing about any
|
|
42
|
+
* other scoped key derived from the same epoch, matching `deriveRoom` and
|
|
43
|
+
* `deriveChannel`'s own reasoning for deriving from the room/channel KEY.
|
|
44
|
+
* No salt, matching `deriveEpoch` and `deriveChannel`.
|
|
45
|
+
*/
|
|
46
|
+
export declare const SCOPED_LABEL_PATTERN: RegExp;
|
|
47
|
+
/** A derived id and key for one app-defined purpose under one epoch. */
|
|
48
|
+
export interface ScopedKeys {
|
|
49
|
+
/** Lower-case hex, 32 bytes. Suitable as a `d` tag or any other public
|
|
50
|
+
* identifier a relay is expected to see. */
|
|
51
|
+
id: string;
|
|
52
|
+
/** 32 bytes. A NIP-44 conversation key, an envelope key, an HKDF salt for
|
|
53
|
+
* a further derivation - whatever the label's owner needs a secret for. */
|
|
54
|
+
key: Uint8Array;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* `{ id, key } = HKDF-SHA256(ikm = epoch.key, info = label + "/id" | label +
|
|
58
|
+
* "/key", 32)`, no salt.
|
|
59
|
+
*
|
|
60
|
+
* Refuses a label in this kit's own protocol namespace, and any label that
|
|
61
|
+
* does not match `SCOPED_LABEL_PATTERN`. Two different labels always derive
|
|
62
|
+
* to two different (id, key) pairs (barring an HKDF collision); the same
|
|
63
|
+
* label under two different epoch keys always derives to two different
|
|
64
|
+
* pairs too, which is what lets a rekey rotate every scoped key an app
|
|
65
|
+
* derived from the old epoch.
|
|
66
|
+
*/
|
|
67
|
+
export declare function deriveScoped(epoch: EpochKeys, label: string): ScopedKeys;
|
package/dist/scoped.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { hkdf } from '@noble/hashes/hkdf';
|
|
2
|
+
import { sha256 } from '@noble/hashes/sha2';
|
|
3
|
+
/**
|
|
4
|
+
* Per-app scoped labels, derived from an epoch key (kit T3.1).
|
|
5
|
+
*
|
|
6
|
+
* A consuming app (a board tool, a task list, anything that rides a
|
|
7
|
+
* KithMoot-derived circle) needs its own payload keys and stream ids that
|
|
8
|
+
* live under the same epoch as the roster and the chat, without ever
|
|
9
|
+
* colliding with a KithMoot channel key or being mistaken for one. Sharing
|
|
10
|
+
* one epoch, rather than deriving a second independent secret, is what lets
|
|
11
|
+
* a board and a room use the same link and the same removal: both derive
|
|
12
|
+
* from the same epoch key, so both move together on a rekey.
|
|
13
|
+
*
|
|
14
|
+
* `deriveScoped` is deliberately NOT `deriveChannel`: a channel name is a
|
|
15
|
+
* KithMoot registry concept, signed off by the room's authority
|
|
16
|
+
* (`epoch.ts`'s `signChannels`) and rendered to members as a place to chat.
|
|
17
|
+
* An app's own scoped keys are neither: nobody signs off on them, and they
|
|
18
|
+
* are never shown as a channel. Reusing `deriveChannel` for them would let
|
|
19
|
+
* an app-chosen string collide with a real channel name, or a channel name
|
|
20
|
+
* collide with an app's payload key, so this is its own derivation under
|
|
21
|
+
* its own info strings.
|
|
22
|
+
*
|
|
23
|
+
* `label` MUST NOT start with this kit's own protocol namespace (see
|
|
24
|
+
* `KITHMOOT_NAMESPACE_PREFIX` below) - that namespace belongs to this kit's
|
|
25
|
+
* own derivations (`EPOCH_ID_INFO`, `CHANNEL_ID_INFO`, and so on), and
|
|
26
|
+
* refusing the prefix here means no app label can ever be mistaken for one
|
|
27
|
+
* of this kit's own, whatever an app supplies. `label` MUST match
|
|
28
|
+
* `^[a-z0-9-]{1,64}/v[0-9]{1,9}/[\x21-\x7e]{1,200}$`: a lower-case app
|
|
29
|
+
* namespace of 1 to 64 bytes, a version segment of 1 to 9 digits (an app's
|
|
30
|
+
* label shape is itself something it may need to change - this pins wire
|
|
31
|
+
* compatibility label by label, not by fiat), then 1 to 200 printable-ASCII
|
|
32
|
+
* bytes naming what the label is for. The namespace and version bounds are
|
|
33
|
+
* deliberately tight - an app label is always a short, fixed, hand-written
|
|
34
|
+
* string, never derived from untrusted input - so an unbounded namespace or
|
|
35
|
+
* version segment (as an earlier revision allowed) is refused rather than
|
|
36
|
+
* accepted and merely hashed away: nothing here needs to parse a namespace
|
|
37
|
+
* or version number back out of a label, but a caller building one by
|
|
38
|
+
* concatenation from attacker-influenced text should get a small, cheap
|
|
39
|
+
* refusal rather than an expensive regex match over untrusted length.
|
|
40
|
+
*
|
|
41
|
+
* `ikm` is the epoch KEY, never the epoch id: publishing a scoped id (which
|
|
42
|
+
* a relay necessarily sees as a `d` tag) must reveal nothing about any
|
|
43
|
+
* other scoped key derived from the same epoch, matching `deriveRoom` and
|
|
44
|
+
* `deriveChannel`'s own reasoning for deriving from the room/channel KEY.
|
|
45
|
+
* No salt, matching `deriveEpoch` and `deriveChannel`.
|
|
46
|
+
*/
|
|
47
|
+
export const SCOPED_LABEL_PATTERN = /^[a-z0-9-]{1,64}\/v[0-9]{1,9}\/[\x21-\x7e]{1,200}$/;
|
|
48
|
+
/** See the comment at its one use site in `deriveScoped`. */
|
|
49
|
+
const KITHMOOT_NAMESPACE_PREFIX = 'kithmoot' + '/';
|
|
50
|
+
function hex(bytes) {
|
|
51
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* `{ id, key } = HKDF-SHA256(ikm = epoch.key, info = label + "/id" | label +
|
|
55
|
+
* "/key", 32)`, no salt.
|
|
56
|
+
*
|
|
57
|
+
* Refuses a label in this kit's own protocol namespace, and any label that
|
|
58
|
+
* does not match `SCOPED_LABEL_PATTERN`. Two different labels always derive
|
|
59
|
+
* to two different (id, key) pairs (barring an HKDF collision); the same
|
|
60
|
+
* label under two different epoch keys always derives to two different
|
|
61
|
+
* pairs too, which is what lets a rekey rotate every scoped key an app
|
|
62
|
+
* derived from the old epoch.
|
|
63
|
+
*/
|
|
64
|
+
export function deriveScoped(epoch, label) {
|
|
65
|
+
// Built by concatenation, deliberately not as one string literal: this
|
|
66
|
+
// kit's `src/labels.test.ts` scans src/*.ts for every literal wire-format
|
|
67
|
+
// label this kit's own protocol owns, and freezes the exact set found.
|
|
68
|
+
// This check needs the same prefix at runtime, but it names that
|
|
69
|
+
// namespace in order to refuse it, rather than adding a new label of its
|
|
70
|
+
// own - so it is assembled at call time instead of appearing in the
|
|
71
|
+
// source as a scannable literal that test would otherwise pick up.
|
|
72
|
+
if (label.startsWith(KITHMOOT_NAMESPACE_PREFIX)) {
|
|
73
|
+
throw new Error('a scoped label may not start with this kit\'s own protocol namespace');
|
|
74
|
+
}
|
|
75
|
+
if (!SCOPED_LABEL_PATTERN.test(label)) {
|
|
76
|
+
throw new Error('scoped label must match ^[a-z0-9-]{1,64}/v[0-9]{1,9}/[\\x21-\\x7e]{1,200}$');
|
|
77
|
+
}
|
|
78
|
+
if (epoch.key.length !== 32)
|
|
79
|
+
throw new Error('epoch key must be 32 bytes');
|
|
80
|
+
const id = hex(hkdf(sha256, epoch.key, undefined, `${label}/id`, 32));
|
|
81
|
+
const key = hkdf(sha256, epoch.key, undefined, `${label}/key`, 32);
|
|
82
|
+
return { id, key };
|
|
83
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { ParticipantIdentity } from './identity.js';
|
|
2
|
+
import type { DeviceCredential } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Sub-key certificates (kit T3.3).
|
|
5
|
+
*
|
|
6
|
+
* A device credential (`credential.ts`) says a device may act for a
|
|
7
|
+
* participant, in one room or everywhere. An app that signs its own traffic
|
|
8
|
+
* under a further per-scope key - a board's per-epoch signing key, say,
|
|
9
|
+
* derived from the epoch key and never held anywhere but derived on demand
|
|
10
|
+
* (see `scoped.ts`) - needs a second, narrower statement: that THIS
|
|
11
|
+
* credentialled device is the one that minted THIS particular sub-key, for
|
|
12
|
+
* THIS particular scoped id. Without it, a relay (or a receiver) has no way
|
|
13
|
+
* to tell a sub-key's traffic apart from a stranger who also derived a key
|
|
14
|
+
* and started signing with it - the sub-key is not itself proof of anything.
|
|
15
|
+
*
|
|
16
|
+
* The certificate is never published on its own: it rides inside a signed
|
|
17
|
+
* message's ciphertext, alongside the device credential it depends on, so a
|
|
18
|
+
* relay only ever sees the sub-key's signature on outer events and never
|
|
19
|
+
* learns which credentialled device stands behind it.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately its own kind-20460 event with `scope: "sub"`, not a variant
|
|
22
|
+
* `verifyDeviceCredential` accepts: `verifyDeviceCredential` already refuses
|
|
23
|
+
* any `scope` it does not recognise (`credential.ts`, "unknown scope"), so a
|
|
24
|
+
* sub-key certificate can never be mistaken for a room or person credential
|
|
25
|
+
* by the existing verifier, and does not need that function to change at
|
|
26
|
+
* all. Its `device` tag also means something different here - the sub-key
|
|
27
|
+
* being certified, not a device being authorised - so folding it into
|
|
28
|
+
* `verifyDeviceCredential`'s shape would only invite the two to be confused.
|
|
29
|
+
*
|
|
30
|
+
* Exactly four tags, in this fixed order, each exactly `[name, value]`: `d`
|
|
31
|
+
* (the scoped signer id this certificate is for), `device` (the sub-key
|
|
32
|
+
* pubkey), `expiration` (MUST equal the accompanying device credential's
|
|
33
|
+
* `expiration`, compared as the exact tag string - a sub-key certificate
|
|
34
|
+
* never outlives the credential that backs it, and never gets its own
|
|
35
|
+
* independent expiry a caller could set longer by mistake, nor a
|
|
36
|
+
* differently-formatted but numerically-equal one), `scope` (always
|
|
37
|
+
* `"sub"`). `content` MUST be the empty string, matching `credential.ts`'s
|
|
38
|
+
* own convention.
|
|
39
|
+
*/
|
|
40
|
+
export declare const SUB_KEY_CERTIFICATE_SCOPE = "sub";
|
|
41
|
+
export interface CreateSubKeyCertificateOptions {
|
|
42
|
+
/** The credentialled device: the same key a device credential names in
|
|
43
|
+
* its `device` tag. Signs the certificate. */
|
|
44
|
+
identity: ParticipantIdentity;
|
|
45
|
+
/** The scoped id this certificate is for - typically
|
|
46
|
+
* `deriveScoped(epoch, '<app>/v1/.../signer').id`. Must be 32-byte hex. */
|
|
47
|
+
signerId: string;
|
|
48
|
+
/** The sub-key pubkey being certified. Must be 32-byte hex (x-only). */
|
|
49
|
+
subKeyPubkey: string;
|
|
50
|
+
/** MUST equal the accompanying device credential's `expiration` exactly.
|
|
51
|
+
* A safe integer, strictly in the future of `now` (or the real clock). */
|
|
52
|
+
expiresAt: number;
|
|
53
|
+
/** Injectable clock, in unix seconds. Defaults to the real one. */
|
|
54
|
+
now?: () => number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Mint a sub-key certificate. Asynchronous for the same reason
|
|
58
|
+
* `createDeviceCredential` is: the identity may be a remote signer.
|
|
59
|
+
*
|
|
60
|
+
* Validates its own inputs before asking the signer to sign anything: a
|
|
61
|
+
* bad `signerId`/`subKeyPubkey` shape or a non-future `expiresAt` is a
|
|
62
|
+
* caller bug, not something worth turning into a certificate a verifier
|
|
63
|
+
* would refuse for a more confusing reason downstream.
|
|
64
|
+
*
|
|
65
|
+
* Checks what the signer handed back against what was asked for, exactly as
|
|
66
|
+
* `createDeviceCredential` does, so a signer that stamps its own fields (a
|
|
67
|
+
* different `created_at` is fine and deliberately not compared; anything
|
|
68
|
+
* else is not) cannot silently hand back a certificate for something else.
|
|
69
|
+
*/
|
|
70
|
+
export declare function createSubKeyCertificate(opts: CreateSubKeyCertificateOptions): Promise<DeviceCredential>;
|
|
71
|
+
export type VerifySubKeyCertificateResult = {
|
|
72
|
+
ok: true;
|
|
73
|
+
device: string;
|
|
74
|
+
subKeyPubkey: string;
|
|
75
|
+
} | {
|
|
76
|
+
ok: false;
|
|
77
|
+
reason: string;
|
|
78
|
+
};
|
|
79
|
+
export interface VerifySubKeyCertificateOptions {
|
|
80
|
+
/** The scoped signer id the certificate must name in its `d` tag. */
|
|
81
|
+
signerId: string;
|
|
82
|
+
/**
|
|
83
|
+
* The device credential this certificate depends on, ALREADY VERIFIED by
|
|
84
|
+
* the caller (a successful `verifyDeviceCredential` call). This function
|
|
85
|
+
* reads that credential's `device` and `expiration` tags itself, rather
|
|
86
|
+
* than trusting a caller to have copied them out correctly: the
|
|
87
|
+
* certificate's signer (`cert.pubkey`) must equal the credential's
|
|
88
|
+
* `device` tag exactly, and the certificate's `expiration` tag must equal
|
|
89
|
+
* the credential's `expiration` tag as the EXACT SAME STRING (not merely
|
|
90
|
+
* the same number) - so a certificate cannot outlive, or be reissued
|
|
91
|
+
* under a differently-formatted equivalent of, the credential backing it.
|
|
92
|
+
*/
|
|
93
|
+
credential: DeviceCredential;
|
|
94
|
+
/** The pubkey that signed the message this certificate rode in on - the
|
|
95
|
+
* sub-key. The certificate's `device` tag must equal it. */
|
|
96
|
+
subKeyPubkey: string;
|
|
97
|
+
now: number;
|
|
98
|
+
/** The containing message's sign time: the certificate's own `created_at`
|
|
99
|
+
* must not be later than `at + skewSeconds`. Omit `at` entirely to skip
|
|
100
|
+
* this bound (a caller with no message context yet, such as a vector,
|
|
101
|
+
* may not have one). Giving `at` without `skewSeconds` does NOT relax
|
|
102
|
+
* the bound to unlimited skew - `skewSeconds` then defaults to `0`, a
|
|
103
|
+
* certificate must not be minted after the message at all. */
|
|
104
|
+
at?: number;
|
|
105
|
+
skewSeconds?: number;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Verify a sub-key certificate: that the credentialled device named by
|
|
109
|
+
* `credential` certified `subKeyPubkey` as its sub-key for `signerId`, with
|
|
110
|
+
* an expiry that matches `credential` exactly and a mint time no later than
|
|
111
|
+
* the message it rode in on.
|
|
112
|
+
*
|
|
113
|
+
* Every check below is cheap-first, strict and canonical, and never throws,
|
|
114
|
+
* matching `verifyDeviceCredential`; the signature is checked last, since it
|
|
115
|
+
* is the most expensive and every tag check above invalidates it anyway.
|
|
116
|
+
*/
|
|
117
|
+
export declare function verifySubKeyCertificate(cert: DeviceCredential, opts: VerifySubKeyCertificateOptions): VerifySubKeyCertificateResult;
|
package/dist/sub-cert.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { KINDS } from './kinds.js';
|
|
2
|
+
import { verifyEventUncached } from './verify.js';
|
|
3
|
+
import { hexEquals, normaliseHex } from './hex.js';
|
|
4
|
+
/**
|
|
5
|
+
* Sub-key certificates (kit T3.3).
|
|
6
|
+
*
|
|
7
|
+
* A device credential (`credential.ts`) says a device may act for a
|
|
8
|
+
* participant, in one room or everywhere. An app that signs its own traffic
|
|
9
|
+
* under a further per-scope key - a board's per-epoch signing key, say,
|
|
10
|
+
* derived from the epoch key and never held anywhere but derived on demand
|
|
11
|
+
* (see `scoped.ts`) - needs a second, narrower statement: that THIS
|
|
12
|
+
* credentialled device is the one that minted THIS particular sub-key, for
|
|
13
|
+
* THIS particular scoped id. Without it, a relay (or a receiver) has no way
|
|
14
|
+
* to tell a sub-key's traffic apart from a stranger who also derived a key
|
|
15
|
+
* and started signing with it - the sub-key is not itself proof of anything.
|
|
16
|
+
*
|
|
17
|
+
* The certificate is never published on its own: it rides inside a signed
|
|
18
|
+
* message's ciphertext, alongside the device credential it depends on, so a
|
|
19
|
+
* relay only ever sees the sub-key's signature on outer events and never
|
|
20
|
+
* learns which credentialled device stands behind it.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately its own kind-20460 event with `scope: "sub"`, not a variant
|
|
23
|
+
* `verifyDeviceCredential` accepts: `verifyDeviceCredential` already refuses
|
|
24
|
+
* any `scope` it does not recognise (`credential.ts`, "unknown scope"), so a
|
|
25
|
+
* sub-key certificate can never be mistaken for a room or person credential
|
|
26
|
+
* by the existing verifier, and does not need that function to change at
|
|
27
|
+
* all. Its `device` tag also means something different here - the sub-key
|
|
28
|
+
* being certified, not a device being authorised - so folding it into
|
|
29
|
+
* `verifyDeviceCredential`'s shape would only invite the two to be confused.
|
|
30
|
+
*
|
|
31
|
+
* Exactly four tags, in this fixed order, each exactly `[name, value]`: `d`
|
|
32
|
+
* (the scoped signer id this certificate is for), `device` (the sub-key
|
|
33
|
+
* pubkey), `expiration` (MUST equal the accompanying device credential's
|
|
34
|
+
* `expiration`, compared as the exact tag string - a sub-key certificate
|
|
35
|
+
* never outlives the credential that backs it, and never gets its own
|
|
36
|
+
* independent expiry a caller could set longer by mistake, nor a
|
|
37
|
+
* differently-formatted but numerically-equal one), `scope` (always
|
|
38
|
+
* `"sub"`). `content` MUST be the empty string, matching `credential.ts`'s
|
|
39
|
+
* own convention.
|
|
40
|
+
*/
|
|
41
|
+
export const SUB_KEY_CERTIFICATE_SCOPE = 'sub';
|
|
42
|
+
/** Lower-case or upper-case 32-byte hex - the shape of every pubkey and
|
|
43
|
+
* scoped id this module handles. Matches `epoch.ts`'s own `HEX64`. */
|
|
44
|
+
const HEX64 = /^[0-9a-f]{64}$/i;
|
|
45
|
+
/** A canonical decimal unix timestamp: no leading/trailing whitespace, no
|
|
46
|
+
* sign, no `0x` prefix, no fraction, and no leading zeros (other than the
|
|
47
|
+
* single digit `"0"` itself, which is not a legal expiry here since it
|
|
48
|
+
* refuses at "expired" but is excluded from ambiguity regardless). Matches
|
|
49
|
+
* `expiresAt` values `credential.ts` itself would produce with `String()`
|
|
50
|
+
* on a safe non-negative integer. */
|
|
51
|
+
const CANONICAL_DECIMAL = /^(0|[1-9][0-9]*)$/;
|
|
52
|
+
function requireHex64(value, what) {
|
|
53
|
+
if (!HEX64.test(value))
|
|
54
|
+
throw new Error(`${what} must be 32-byte hex`);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Mint a sub-key certificate. Asynchronous for the same reason
|
|
58
|
+
* `createDeviceCredential` is: the identity may be a remote signer.
|
|
59
|
+
*
|
|
60
|
+
* Validates its own inputs before asking the signer to sign anything: a
|
|
61
|
+
* bad `signerId`/`subKeyPubkey` shape or a non-future `expiresAt` is a
|
|
62
|
+
* caller bug, not something worth turning into a certificate a verifier
|
|
63
|
+
* would refuse for a more confusing reason downstream.
|
|
64
|
+
*
|
|
65
|
+
* Checks what the signer handed back against what was asked for, exactly as
|
|
66
|
+
* `createDeviceCredential` does, so a signer that stamps its own fields (a
|
|
67
|
+
* different `created_at` is fine and deliberately not compared; anything
|
|
68
|
+
* else is not) cannot silently hand back a certificate for something else.
|
|
69
|
+
*/
|
|
70
|
+
export async function createSubKeyCertificate(opts) {
|
|
71
|
+
requireHex64(opts.signerId, 'signerId');
|
|
72
|
+
requireHex64(opts.subKeyPubkey, 'subKeyPubkey');
|
|
73
|
+
const now = (opts.now ?? (() => Math.floor(Date.now() / 1000)))();
|
|
74
|
+
if (!Number.isSafeInteger(opts.expiresAt) || opts.expiresAt <= now) {
|
|
75
|
+
throw new Error('expiresAt must be a safe integer unix timestamp in the future');
|
|
76
|
+
}
|
|
77
|
+
const unsigned = {
|
|
78
|
+
kind: KINDS.CREDENTIAL,
|
|
79
|
+
created_at: now,
|
|
80
|
+
tags: [
|
|
81
|
+
['d', opts.signerId],
|
|
82
|
+
['device', opts.subKeyPubkey],
|
|
83
|
+
['expiration', String(opts.expiresAt)],
|
|
84
|
+
['scope', SUB_KEY_CERTIFICATE_SCOPE],
|
|
85
|
+
],
|
|
86
|
+
content: '',
|
|
87
|
+
};
|
|
88
|
+
const signed = await opts.identity.signEvent(unsigned);
|
|
89
|
+
if (!hexEquals(signed.pubkey, opts.identity.pubkey)) {
|
|
90
|
+
throw new Error('the signer returned a certificate signed by a different key');
|
|
91
|
+
}
|
|
92
|
+
if (signed.kind !== unsigned.kind || signed.content !== unsigned.content) {
|
|
93
|
+
throw new Error('the signer returned a certificate for something else');
|
|
94
|
+
}
|
|
95
|
+
if (JSON.stringify(signed.tags) !== JSON.stringify(unsigned.tags)) {
|
|
96
|
+
throw new Error('the signer returned a certificate on different terms than it was asked for');
|
|
97
|
+
}
|
|
98
|
+
if (!verifyEventUncached(signed)) {
|
|
99
|
+
throw new Error('the signer returned a certificate that does not verify');
|
|
100
|
+
}
|
|
101
|
+
return signed;
|
|
102
|
+
}
|
|
103
|
+
/** Verified sub-key certificates, keyed on the whole signed event exactly as
|
|
104
|
+
* `credential.ts`'s own bounded cache is: a Schnorr signature is
|
|
105
|
+
* randomised, so the identical certificate can arrive carrying two
|
|
106
|
+
* different valid signatures, and an id-keyed cache would refuse the
|
|
107
|
+
* second. Kept separate from `credential.ts`'s cache because a sub-key
|
|
108
|
+
* certificate and a device credential are verified under different rules
|
|
109
|
+
* and neither should be able to poison the other's cache. */
|
|
110
|
+
const verifiedSubKeyCertificates = new Set();
|
|
111
|
+
function certificateSignature(cert) {
|
|
112
|
+
const whole = JSON.stringify([cert.id, cert.pubkey, cert.created_at, cert.kind, cert.tags, cert.content, cert.sig]);
|
|
113
|
+
if (verifiedSubKeyCertificates.has(whole))
|
|
114
|
+
return true;
|
|
115
|
+
if (!verifyEventUncached(cert))
|
|
116
|
+
return false;
|
|
117
|
+
verifiedSubKeyCertificates.add(whole);
|
|
118
|
+
if (verifiedSubKeyCertificates.size > 4_096)
|
|
119
|
+
verifiedSubKeyCertificates.delete(verifiedSubKeyCertificates.values().next().value);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Verify a sub-key certificate: that the credentialled device named by
|
|
124
|
+
* `credential` certified `subKeyPubkey` as its sub-key for `signerId`, with
|
|
125
|
+
* an expiry that matches `credential` exactly and a mint time no later than
|
|
126
|
+
* the message it rode in on.
|
|
127
|
+
*
|
|
128
|
+
* Every check below is cheap-first, strict and canonical, and never throws,
|
|
129
|
+
* matching `verifyDeviceCredential`; the signature is checked last, since it
|
|
130
|
+
* is the most expensive and every tag check above invalidates it anyway.
|
|
131
|
+
*/
|
|
132
|
+
export function verifySubKeyCertificate(cert, opts) {
|
|
133
|
+
if (cert.kind !== KINDS.CREDENTIAL)
|
|
134
|
+
return { ok: false, reason: 'wrong kind' };
|
|
135
|
+
if (cert.content !== '')
|
|
136
|
+
return { ok: false, reason: 'non-empty content' };
|
|
137
|
+
// Exactly four tags, in this fixed order, each exactly [name, value] (see
|
|
138
|
+
// the module comment): a certificate with an extra, missing or reordered
|
|
139
|
+
// tag, or an extra element tacked onto one tag, is refused before any of
|
|
140
|
+
// its values are even read, so nothing downstream can be tricked by
|
|
141
|
+
// content nobody here is looking for.
|
|
142
|
+
if (cert.tags.length !== 4)
|
|
143
|
+
return { ok: false, reason: 'wrong tag count' };
|
|
144
|
+
if (cert.tags.some((t) => t.length !== 2))
|
|
145
|
+
return { ok: false, reason: 'wrong tag shape' };
|
|
146
|
+
const [dTag, deviceTag, expirationTag, scopeTag] = cert.tags;
|
|
147
|
+
if (dTag[0] !== 'd')
|
|
148
|
+
return { ok: false, reason: 'wrong tag order' };
|
|
149
|
+
if (deviceTag[0] !== 'device')
|
|
150
|
+
return { ok: false, reason: 'wrong tag order' };
|
|
151
|
+
if (expirationTag[0] !== 'expiration')
|
|
152
|
+
return { ok: false, reason: 'wrong tag order' };
|
|
153
|
+
if (scopeTag[0] !== 'scope')
|
|
154
|
+
return { ok: false, reason: 'wrong tag order' };
|
|
155
|
+
if (scopeTag[1] !== SUB_KEY_CERTIFICATE_SCOPE)
|
|
156
|
+
return { ok: false, reason: 'wrong scope' };
|
|
157
|
+
const signerId = dTag[1];
|
|
158
|
+
if (!hexEquals(signerId, opts.signerId))
|
|
159
|
+
return { ok: false, reason: 'wrong signer' };
|
|
160
|
+
const subKeyPubkey = deviceTag[1];
|
|
161
|
+
if (!hexEquals(subKeyPubkey, opts.subKeyPubkey)) {
|
|
162
|
+
return { ok: false, reason: 'device tag does not match the message signer' };
|
|
163
|
+
}
|
|
164
|
+
const credentialDevice = opts.credential.tags.find((t) => t[0] === 'device')?.[1];
|
|
165
|
+
if (credentialDevice === undefined)
|
|
166
|
+
return { ok: false, reason: 'credential missing device tag' };
|
|
167
|
+
if (!hexEquals(cert.pubkey, credentialDevice))
|
|
168
|
+
return { ok: false, reason: 'wrong device' };
|
|
169
|
+
const expirationValue = expirationTag[1];
|
|
170
|
+
if (!CANONICAL_DECIMAL.test(expirationValue))
|
|
171
|
+
return { ok: false, reason: 'non-canonical expiration' };
|
|
172
|
+
const credentialExpiration = opts.credential.tags.find((t) => t[0] === 'expiration')?.[1];
|
|
173
|
+
if (credentialExpiration === undefined)
|
|
174
|
+
return { ok: false, reason: 'credential missing expiration tag' };
|
|
175
|
+
// Exact string comparison, not numeric: "1802582000" and "01802582000" (or
|
|
176
|
+
// any other non-canonical spelling of the same number) must not be
|
|
177
|
+
// treated as equal - CANONICAL_DECIMAL above already refuses the
|
|
178
|
+
// certificate's own tag if it is not canonical, so this compares two
|
|
179
|
+
// already-canonical-or-refused strings, catching a credential whose own
|
|
180
|
+
// tag differs even numerically-identically-formatted.
|
|
181
|
+
if (expirationValue !== credentialExpiration)
|
|
182
|
+
return { ok: false, reason: 'expiration does not match the credential' };
|
|
183
|
+
const expiresAt = Number(expirationValue);
|
|
184
|
+
if (expiresAt <= opts.now)
|
|
185
|
+
return { ok: false, reason: 'expired' };
|
|
186
|
+
if (opts.at !== undefined && cert.created_at > opts.at + (opts.skewSeconds ?? 0)) {
|
|
187
|
+
return { ok: false, reason: 'certificate minted after the message it rode in on' };
|
|
188
|
+
}
|
|
189
|
+
if (!certificateSignature(cert))
|
|
190
|
+
return { ok: false, reason: 'bad signature' };
|
|
191
|
+
return { ok: true, device: normaliseHex(cert.pubkey), subKeyPubkey: normaliseHex(subKeyPubkey) };
|
|
192
|
+
}
|
package/llms.txt
CHANGED
|
@@ -49,6 +49,7 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
|
|
|
49
49
|
- `createDeviceCredential(opts)` → `Promise<DeviceCredential>` - room-scope or person-scope (`scope: 'person'`)
|
|
50
50
|
- `verifyDeviceCredential(event, opts)` → `VerifyResult` - pass `acceptPerson: true` to accept a person-scope credential where a room credential is expected
|
|
51
51
|
- `PERSON_CREDENTIAL_MAX_SECONDS` - 30-day cap on a person-scope credential
|
|
52
|
+
- `RestampedCredentialExpiryError` - thrown by `createDeviceCredential` when a restamping signer would make a person-scope credential unverifiable everywhere; has `overBySeconds`; catch and retry with a shorter `expiresAt` margin (see CHANGELOG.md)
|
|
52
53
|
- `issueKindredProof(opts)` → `KindredProof`
|
|
53
54
|
- `evaluateAccess(policy, participant, proof, now, roomId)` → `{ admitted, reason }`
|
|
54
55
|
- `sanitiseDisplayName(raw)` → `string | undefined`, `MAX_DISPLAY_NAME_LENGTH`
|
|
@@ -65,6 +66,9 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
|
|
|
65
66
|
- `hostRoomInvitation(opts)` / `requestRoomAdmission(opts)` - the live desk, both sides
|
|
66
67
|
- `encodePersistentInvitation(opts)` / `decodePersistentInvitation(event, invitation)` - v3 stored group invitation (kind 1463)
|
|
67
68
|
- `requestPersistentRoomAdmission(opts)` - joins from stored admission alone, no live desk needed
|
|
69
|
+
- Conference rooms: `encodePersistentInvitation({ ..., endsAt })` puts `ends` in the body and `['expiration', String(endsAt)]` on the 1463; `decodePersistentInvitation` returns `endsAt` and is null when an `expiration` tag disagrees with the body; `encodeInvitationRetirement({ ..., endsAt })` tags the 1461 the same way
|
|
70
|
+
- `withExpiration(tags, expiresAt?)` - NIP-40 rule for a room that ends: add the tag, keep an earlier one, lower a later one; `isRoomEnds`, `requireRoomEnds(endsAt, now)`, `MAX_ROOM_ENDS_SECONDS` (30 days)
|
|
71
|
+
- Epoch encoders (`encodeRekeyEvent`, `encodeEpochRequest`, `encodeEpochGrant`) and `hostRoomEpoch` / `requestRoomEpoch` take `expiresAt?` for a conference room
|
|
68
72
|
|
|
69
73
|
### Epochs (removal by rekey)
|
|
70
74
|
|
|
@@ -77,6 +81,11 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
|
|
|
77
81
|
- `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
|
|
78
82
|
- `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
|
|
79
83
|
|
|
84
|
+
### Scoped labels and sub-key certificates
|
|
85
|
+
|
|
86
|
+
- `deriveScoped(epoch, label)` → `{ id, key }` - HKDF from the epoch KEY under an app's own labelled namespace (e.g. `myapp/v1/board/<id>/update`); refuses this kit's own protocol namespace and any label outside `SCOPED_LABEL_PATTERN`
|
|
87
|
+
- `createSubKeyCertificate(opts)` → `Promise<DeviceCredential>` / `verifySubKeyCertificate(event, opts)` → result - a locally-signed, never-published statement binding an app-derived key to the credentialled device that minted it; `scope: 'sub'` is a value `verifyDeviceCredential` already refuses, so the two forms can never be confused. `verifySubKeyCertificate` takes `opts.credential` (the already-verified device credential) and reads its `device`/`expiration` tags itself; verification is strict (exactly four two-element tags, empty content) and canonical (expiration compared as the exact tag string). `SUB_KEY_CERTIFICATE_SCOPE`.
|
|
88
|
+
|
|
80
89
|
### Channel derivation
|
|
81
90
|
|
|
82
91
|
- `deriveChannel(roomId, roomKey, channel?)` → `{ id, key }` - a named channel's id/key, derived from the room key; `CHANNEL_ID_INFO`, `CHANNEL_KEY_INFO`, `MAX_CHANNEL_NAME_LENGTH`. The chat log and event codecs themselves stay in the consuming app.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/fold-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Circle primitives for private Nostr groups nobody operates: epoch keys, link invites, removal by rekey, device credentials.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"test:watch": "vitest",
|
|
31
31
|
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
32
32
|
"diff-source": "node scripts/diff-source.mjs",
|
|
33
|
+
"generate-fold": "node scripts/generate-fold.mjs",
|
|
33
34
|
"bundle-check": "node scripts/bundle-check.mjs",
|
|
34
35
|
"tarball-smoke": "node scripts/tarball-smoke.mjs",
|
|
35
36
|
"vectors": "vitest run vectors/",
|
|
@@ -1758,7 +1758,7 @@
|
|
|
1758
1758
|
{
|
|
1759
1759
|
"name": "refused-over-30-days",
|
|
1760
1760
|
"kind": "negative",
|
|
1761
|
-
"note": "M15 (refused side): the real
|
|
1761
|
+
"note": "M15 (refused side); updated for forgesworn/kithmoot#205: the real createDeviceCredential now measures the same way verifyDeviceCredential does - against the SIGNED created_at, not the requested now - and throws RestampedCredentialExpiryError at mint time instead of returning a credential. output.event/output.result keep M15's original coverage: the exact over-cap event a pre-#205 mint would have returned (built deterministically, the same bytes as before), and verifyDeviceCredential's own refusal of it, which is unchanged by #205 (its measurement was already correct - only the mint side was checking the wrong clock).",
|
|
1762
1762
|
"input": {
|
|
1763
1763
|
"participantSkHex": "826f7335cca1f25633dbc6efc43131a651feb9b7d419c9b1fdc35b2e115d2180",
|
|
1764
1764
|
"devicePubkey": "b4814eb28f92350f362af5eb7a1dc6b9bdd1d27e789f5d97ad3b8c7653753076",
|
|
@@ -1772,6 +1772,7 @@
|
|
|
1772
1772
|
}
|
|
1773
1773
|
},
|
|
1774
1774
|
"output": {
|
|
1775
|
+
"mintThrew": "the signer restamped created_at, and the requested expiry now runs longer than 30 days from it",
|
|
1775
1776
|
"event": {
|
|
1776
1777
|
"kind": 20460,
|
|
1777
1778
|
"created_at": 1799989990,
|