@forgesworn/fold-kit 0.6.0 → 0.7.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 +8 -0
- package/dist/credential.d.ts +5 -0
- package/dist/credential.js +4 -0
- package/dist/epoch.d.ts +41 -2
- package/dist/epoch.js +69 -14
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/member-epoch.d.ts +13 -0
- package/dist/member-epoch.js +22 -9
- package/dist/seal.d.ts +77 -0
- package/dist/seal.js +128 -0
- package/llms.txt +1 -0
- package/package.json +3 -1
- package/vectors/seal-vectors.json +629 -0
package/README.md
CHANGED
|
@@ -160,6 +160,14 @@ the rest).
|
|
|
160
160
|
authority answers `refused: 'unknown'`, which `requestRoomEpoch` waits
|
|
161
161
|
through). `encodeRekeyEvent({ members })` and `hostRoomEpoch({ members })`
|
|
162
162
|
carry the authority's member list (`readMemberList` reads one)
|
|
163
|
+
- Seal keys (`docs/seal-key.md`): a credential may name a seal key
|
|
164
|
+
(`createDeviceCredential({ seal })`, `generateSealKey`), and rekeys and
|
|
165
|
+
both grants are sealed to the newest one the sender holds for a device
|
|
166
|
+
instead of its device key, so a copied device heals once its credential
|
|
167
|
+
lapses and the room rekeys. Recipients as `{ device, credential }`;
|
|
168
|
+
`sealSks` on the readers; `credentialFor` on both desks;
|
|
169
|
+
`credentialSeal`, `sealTarget`, `sealTo`, `openSealed`, `newerCredential`,
|
|
170
|
+
`sealCredential`. Vectors: `vectors/seal-vectors.json`
|
|
163
171
|
- `canonicalAdmins`, `signAdmins`, `verifyAdmins` - the authority's signed
|
|
164
172
|
admin list
|
|
165
173
|
- `canonicalChannels`, `signChannels`, `verifyChannels`, `CHANNEL_NAME`,
|
package/dist/credential.d.ts
CHANGED
|
@@ -25,6 +25,11 @@ export interface CreateCredentialOptions {
|
|
|
25
25
|
expiresAt: number;
|
|
26
26
|
/** Injectable clock, in unix seconds. Defaults to the real one. */
|
|
27
27
|
now?: () => number;
|
|
28
|
+
/** The device's seal key for this credential: an x-only public key,
|
|
29
|
+
* minted fresh for each credential, to which rekeys and epoch grants are
|
|
30
|
+
* then sealed instead of the device key. See `seal.ts`. Omitted, the
|
|
31
|
+
* credential is byte-identical to one minted before seal keys. */
|
|
32
|
+
seal?: string;
|
|
28
33
|
}
|
|
29
34
|
/** The longest a person-scoped credential may run. A phone that leaves the house is better at seven days. */
|
|
30
35
|
export declare const PERSON_CREDENTIAL_MAX_SECONDS: number;
|
package/dist/credential.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { KINDS } from './kinds.js';
|
|
2
2
|
import { verifyEventUncached } from './verify.js';
|
|
3
3
|
import { hexEquals, normaliseHex } from './hex.js';
|
|
4
|
+
import { isSealPubkey, SEAL_TAG } from './seal.js';
|
|
4
5
|
/** The longest a person-scoped credential may run. A phone that leaves the house is better at seven days. */
|
|
5
6
|
export const PERSON_CREDENTIAL_MAX_SECONDS = 30 * 24 * 60 * 60;
|
|
6
7
|
/**
|
|
@@ -43,6 +44,8 @@ export async function createDeviceCredential(opts) {
|
|
|
43
44
|
throw new Error('a room credential needs a room');
|
|
44
45
|
if (person && opts.expiresAt - now > PERSON_CREDENTIAL_MAX_SECONDS)
|
|
45
46
|
throw new Error('a person credential may not run more than 30 days');
|
|
47
|
+
if (opts.seal !== undefined && !isSealPubkey(opts.seal))
|
|
48
|
+
throw new Error('a seal key must be an x-only public key on the curve');
|
|
46
49
|
const unsigned = {
|
|
47
50
|
kind: KINDS.CREDENTIAL,
|
|
48
51
|
created_at: now,
|
|
@@ -52,6 +55,7 @@ export async function createDeviceCredential(opts) {
|
|
|
52
55
|
['expiration', String(opts.expiresAt)],
|
|
53
56
|
...(person ? [['scope', 'person']] : []),
|
|
54
57
|
...(opts.label !== undefined ? [['label', opts.label]] : []),
|
|
58
|
+
...(opts.seal !== undefined ? [[SEAL_TAG, opts.seal.toLowerCase()]] : []),
|
|
55
59
|
],
|
|
56
60
|
content: '',
|
|
57
61
|
};
|
package/dist/epoch.d.ts
CHANGED
|
@@ -98,8 +98,11 @@ export interface EncodeRekeyOptions {
|
|
|
98
98
|
/** The epoch being entered. Must be `current.epoch + 1`. */
|
|
99
99
|
next: RoomEpoch;
|
|
100
100
|
/** Devices to seal the new secret for. Everybody still in the room,
|
|
101
|
-
* except the removed participants' devices.
|
|
102
|
-
|
|
101
|
+
* except the removed participants' devices. A recipient given with its
|
|
102
|
+
* newest credential is sealed to that credential's seal key, when it
|
|
103
|
+
* names one; a bare device, to the device key (see `seal.ts`). The
|
|
104
|
+
* `keys` map is keyed by device either way. */
|
|
105
|
+
recipients: ReadonlyArray<string | RekeyRecipient>;
|
|
103
106
|
/** Participants removed at this step. Empty on a rekey that only turns
|
|
104
107
|
* the key over. */
|
|
105
108
|
removed: string[];
|
|
@@ -122,6 +125,12 @@ export interface EncodeRekeyOptions {
|
|
|
122
125
|
* NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
|
|
123
126
|
expiresAt?: number;
|
|
124
127
|
}
|
|
128
|
+
/** A rekey recipient: a device, and the newest credential the sender holds
|
|
129
|
+
* for it. */
|
|
130
|
+
export interface RekeyRecipient {
|
|
131
|
+
device: string;
|
|
132
|
+
credential?: DeviceCredential;
|
|
133
|
+
}
|
|
125
134
|
/** Announce the next epoch. See the module comment for what this is. */
|
|
126
135
|
export declare function encodeRekeyEvent(opts: EncodeRekeyOptions): Event;
|
|
127
136
|
/** What a rekey says, once read. */
|
|
@@ -160,6 +169,9 @@ export interface DecodeRekeyOptions extends PeekRekeyOptions {
|
|
|
160
169
|
current: EpochKeys;
|
|
161
170
|
/** This device's key, to open its own copy. */
|
|
162
171
|
deviceSk: Uint8Array;
|
|
172
|
+
/** The seal key secrets this device holds, newest first, tried before
|
|
173
|
+
* the device key (see `seal.ts`). */
|
|
174
|
+
sealSks?: readonly Uint8Array[];
|
|
163
175
|
}
|
|
164
176
|
/**
|
|
165
177
|
* Read a rekey. Null for anything that does not check out, and for a rekey
|
|
@@ -258,6 +270,9 @@ export interface EncodeEpochGrantOptions {
|
|
|
258
270
|
* desk knows them too. Omitted, the body is as before. */
|
|
259
271
|
members?: string[];
|
|
260
272
|
refused?: EpochRefusal;
|
|
273
|
+
/** The newest credential the authority holds for the device: the grant
|
|
274
|
+
* is sealed to its seal key when it names one (see `seal.ts`). */
|
|
275
|
+
credential?: DeviceCredential;
|
|
261
276
|
/** A conference room's end, in unix seconds: the event carries it as a
|
|
262
277
|
* NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
|
|
263
278
|
expiresAt?: number;
|
|
@@ -269,6 +284,9 @@ export interface DecodeEpochGrantOptions {
|
|
|
269
284
|
roomId: string;
|
|
270
285
|
authority: string;
|
|
271
286
|
deviceSk: Uint8Array;
|
|
287
|
+
/** The seal key secrets this device holds: see
|
|
288
|
+
* `DecodeRekeyOptions.sealSks`. */
|
|
289
|
+
sealSks?: readonly Uint8Array[];
|
|
272
290
|
request: string;
|
|
273
291
|
now: number;
|
|
274
292
|
maxAgeSeconds?: number;
|
|
@@ -314,6 +332,15 @@ export interface HostRoomEpochOptions {
|
|
|
314
332
|
/** The participants to tell a granted device about, so its own member
|
|
315
333
|
* desk knows them too. Asked on every grant. */
|
|
316
334
|
members?: () => readonly string[];
|
|
335
|
+
/**
|
|
336
|
+
* The newest credential the authority has seen for a device, from the
|
|
337
|
+
* roster. An answer is sealed to the newer of this and the one the
|
|
338
|
+
* request carries, so a thief replaying a device's older, still-live
|
|
339
|
+
* credential is answered under the seal key the device minted since.
|
|
340
|
+
* Keep it monotone: a roster entry carrying an older credential than one
|
|
341
|
+
* already seen must not replace it, or the thief can publish one.
|
|
342
|
+
*/
|
|
343
|
+
credentialFor?: (device: string) => DeviceCredential | undefined;
|
|
317
344
|
/** Somebody the room does not know asked, after a removal: what an app
|
|
318
345
|
* turns into "let them in?". Called once per participant, and again
|
|
319
346
|
* every `reportUnknownEvery` seconds while they keep asking. Letting them in
|
|
@@ -330,6 +357,15 @@ export interface HostRoomEpochOptions {
|
|
|
330
357
|
/** A conference room's end: every grant carries it as an expiration. */
|
|
331
358
|
expiresAt?: number;
|
|
332
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* The credential to seal an answer to: the one the request carries, unless
|
|
362
|
+
* the room has seen a newer one for the same device and participant that
|
|
363
|
+
* still verifies. Shared by both desks.
|
|
364
|
+
*/
|
|
365
|
+
export declare function sealCredential(request: {
|
|
366
|
+
device: string;
|
|
367
|
+
participant: string;
|
|
368
|
+
}, presented: DeviceCredential, credentialFor: ((device: string) => DeviceCredential | undefined) | undefined, roomId: string, now: number): DeviceCredential;
|
|
333
369
|
/**
|
|
334
370
|
* Answer epoch requests for as long as the handle is open. What a keeper
|
|
335
371
|
* runs beside its invitation desk: a member arriving, or returning, after
|
|
@@ -370,6 +406,9 @@ export interface RequestRoomEpochOptions {
|
|
|
370
406
|
/** The authority said the room does not know this participant yet. Not
|
|
371
407
|
* final: the ask goes on, and a member letting them in settles it. */
|
|
372
408
|
onUnknown?: () => void;
|
|
409
|
+
/** The seal key secrets this device holds. Asked on every answer,
|
|
410
|
+
* because a credential may renew while the ask is out. */
|
|
411
|
+
sealSks?: () => readonly Uint8Array[];
|
|
373
412
|
}
|
|
374
413
|
/** Thrown when the authority answered, and the answer was no. */
|
|
375
414
|
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 { newerCredential, openSealed, sealTo } from './seal.js';
|
|
15
16
|
import { withExpiration } from './expiration.js';
|
|
16
17
|
import { epochCommitment } from './epoch-commit.js';
|
|
17
18
|
/**
|
|
@@ -146,8 +147,9 @@ export function encodeRekeyEvent(opts) {
|
|
|
146
147
|
const sealed = { v: 1, secret: base64urlnopad.encode(opts.next.secret) };
|
|
147
148
|
const plaintext = JSON.stringify(sealed);
|
|
148
149
|
for (const raw of opts.closed ? [] : opts.recipients) {
|
|
149
|
-
const
|
|
150
|
-
|
|
150
|
+
const recipient = typeof raw === 'string' ? { device: raw } : raw;
|
|
151
|
+
const device = requireHex32(recipient.device, 'recipient device');
|
|
152
|
+
keys[device] = sealTo(plaintext, opts.authoritySk, device, recipient.credential);
|
|
151
153
|
}
|
|
152
154
|
const body = {
|
|
153
155
|
v: 1,
|
|
@@ -227,11 +229,20 @@ export function decodeRekeyEvent(event, opts) {
|
|
|
227
229
|
const device = getPublicKey(opts.deviceSk);
|
|
228
230
|
const mine = Object.entries(body.keys).find(([to]) => hexEquals(to, device))?.[1];
|
|
229
231
|
if (typeof mine === 'string') {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
232
|
+
// A copy none of this device's keys opens is no copy: sealed to a
|
|
233
|
+
// seal key it has since erased, or one it never had. The rest of the
|
|
234
|
+
// rekey still says who was removed, so it is read without the secret,
|
|
235
|
+
// and the device asks for the epoch.
|
|
236
|
+
try {
|
|
237
|
+
const sealed = JSON.parse(openSealed(mine, event.pubkey, opts.deviceSk, opts.sealSks));
|
|
238
|
+
if (sealed.v === 1 && typeof sealed.secret === 'string') {
|
|
239
|
+
const secret = base64urlnopad.decode(sealed.secret);
|
|
240
|
+
if (secret.length === 32)
|
|
241
|
+
notice.secret = secret;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// No secret, as above.
|
|
235
246
|
}
|
|
236
247
|
}
|
|
237
248
|
return notice;
|
|
@@ -314,6 +325,12 @@ export function encodeEpochRequest(opts) {
|
|
|
314
325
|
* cannot prove which participant it speaks for in this room, or from one
|
|
315
326
|
* that cannot prove it was admitted to the room at all. */
|
|
316
327
|
export function decodeEpochRequest(event, opts) {
|
|
328
|
+
const read = readEpochRequest(event, opts);
|
|
329
|
+
return read && read.request;
|
|
330
|
+
}
|
|
331
|
+
/** `decodeEpochRequest`, keeping the credential the request was made
|
|
332
|
+
* under: what the desk seals its answer by. */
|
|
333
|
+
function readEpochRequest(event, opts) {
|
|
317
334
|
try {
|
|
318
335
|
if (event.kind !== KINDS.EPOCH_REQUEST)
|
|
319
336
|
return null;
|
|
@@ -358,7 +375,7 @@ export function decodeEpochRequest(event, opts) {
|
|
|
358
375
|
if (!evaluateAccess(opts.policy, verdict.participant, proof, opts.now, opts.roomId).admitted)
|
|
359
376
|
return null;
|
|
360
377
|
}
|
|
361
|
-
return { device: verdict.device, participant: verdict.participant, request: event.id };
|
|
378
|
+
return { request: { device: verdict.device, participant: verdict.participant, request: event.id }, credential: body.credential };
|
|
362
379
|
}
|
|
363
380
|
catch {
|
|
364
381
|
return null;
|
|
@@ -398,7 +415,7 @@ export function encodeEpochGrant(opts) {
|
|
|
398
415
|
['d', roomId],
|
|
399
416
|
['p', device],
|
|
400
417
|
], opts.expiresAt),
|
|
401
|
-
content:
|
|
418
|
+
content: sealTo(JSON.stringify(body), opts.authoritySk, device, opts.credential),
|
|
402
419
|
}, opts.authoritySk);
|
|
403
420
|
}
|
|
404
421
|
/** Read the authority's answer to this device's own request. Null for
|
|
@@ -420,7 +437,7 @@ export function decodeEpochGrant(event, opts) {
|
|
|
420
437
|
const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
|
|
421
438
|
if (addressed === undefined || !hexEquals(addressed, device))
|
|
422
439
|
return null;
|
|
423
|
-
const body = JSON.parse(
|
|
440
|
+
const body = JSON.parse(openSealed(event.content, event.pubkey, opts.deviceSk, opts.sealSks));
|
|
424
441
|
if (body.v !== 1 || typeof body.request !== 'string' || !hexEquals(body.request, opts.request))
|
|
425
442
|
return null;
|
|
426
443
|
if (body.refused === 'removed' || body.refused === 'closed' || body.refused === 'unknown')
|
|
@@ -445,6 +462,26 @@ export function decodeEpochGrant(event, opts) {
|
|
|
445
462
|
return null;
|
|
446
463
|
}
|
|
447
464
|
}
|
|
465
|
+
/**
|
|
466
|
+
* The credential to seal an answer to: the one the request carries, unless
|
|
467
|
+
* the room has seen a newer one for the same device and participant that
|
|
468
|
+
* still verifies. Shared by both desks.
|
|
469
|
+
*/
|
|
470
|
+
export function sealCredential(request, presented, credentialFor, roomId, now) {
|
|
471
|
+
let known;
|
|
472
|
+
try {
|
|
473
|
+
known = credentialFor?.(request.device);
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
known = undefined;
|
|
477
|
+
}
|
|
478
|
+
if (!known)
|
|
479
|
+
return presented;
|
|
480
|
+
const verdict = verifyDeviceCredential(known, { roomId, now });
|
|
481
|
+
if (!verdict.ok || verdict.device !== normaliseHex(request.device) || verdict.participant !== normaliseHex(request.participant))
|
|
482
|
+
return presented;
|
|
483
|
+
return newerCredential(presented, known);
|
|
484
|
+
}
|
|
448
485
|
/**
|
|
449
486
|
* Answer epoch requests for as long as the handle is open. What a keeper
|
|
450
487
|
* runs beside its invitation desk: a member arriving, or returning, after
|
|
@@ -483,7 +520,7 @@ export function hostRoomEpoch(opts) {
|
|
|
483
520
|
const unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_REQUEST], '#d': [roomId], '#p': [authority] }], (event) => {
|
|
484
521
|
if (closed)
|
|
485
522
|
return;
|
|
486
|
-
const
|
|
523
|
+
const read = readEpochRequest(event, {
|
|
487
524
|
roomId,
|
|
488
525
|
authoritySk: opts.authoritySk,
|
|
489
526
|
roomKey: opts.roomKey,
|
|
@@ -491,8 +528,9 @@ export function hostRoomEpoch(opts) {
|
|
|
491
528
|
policy: opts.policy,
|
|
492
529
|
legacyParticipants: opts.legacyParticipants,
|
|
493
530
|
});
|
|
494
|
-
if (!
|
|
531
|
+
if (!read || answered.has(read.request.request))
|
|
495
532
|
return;
|
|
533
|
+
const request = read.request;
|
|
496
534
|
let refused;
|
|
497
535
|
if (opts.closed?.())
|
|
498
536
|
refused = 'closed';
|
|
@@ -514,7 +552,16 @@ export function hostRoomEpoch(opts) {
|
|
|
514
552
|
let grant;
|
|
515
553
|
try {
|
|
516
554
|
grant = refused
|
|
517
|
-
? encodeEpochGrant({
|
|
555
|
+
? encodeEpochGrant({
|
|
556
|
+
roomId,
|
|
557
|
+
authoritySk: opts.authoritySk,
|
|
558
|
+
device: request.device,
|
|
559
|
+
request: request.request,
|
|
560
|
+
now: now(),
|
|
561
|
+
refused,
|
|
562
|
+
credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
|
|
563
|
+
expiresAt: opts.expiresAt,
|
|
564
|
+
})
|
|
518
565
|
: encodeEpochGrant({
|
|
519
566
|
roomId,
|
|
520
567
|
authoritySk: opts.authoritySk,
|
|
@@ -524,6 +571,7 @@ export function hostRoomEpoch(opts) {
|
|
|
524
571
|
epoch: opts.current(),
|
|
525
572
|
removed: [...opts.removed()],
|
|
526
573
|
...(opts.members ? { members: [...opts.members()] } : {}),
|
|
574
|
+
credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
|
|
527
575
|
expiresAt: opts.expiresAt,
|
|
528
576
|
});
|
|
529
577
|
}
|
|
@@ -604,7 +652,14 @@ export function requestRoomEpoch(opts) {
|
|
|
604
652
|
settle();
|
|
605
653
|
};
|
|
606
654
|
unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_GRANT], '#d': [roomId], '#p': [device] }], (event) => {
|
|
607
|
-
const grant = decodeEpochGrant(event, {
|
|
655
|
+
const grant = decodeEpochGrant(event, {
|
|
656
|
+
roomId,
|
|
657
|
+
authority: opts.authority,
|
|
658
|
+
deviceSk: opts.deviceSk,
|
|
659
|
+
sealSks: opts.sealSks?.(),
|
|
660
|
+
request: request.id,
|
|
661
|
+
now: now(),
|
|
662
|
+
});
|
|
608
663
|
if (!grant)
|
|
609
664
|
return;
|
|
610
665
|
if (grant.refused === 'unknown') {
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export type { DeviceCredential, AccessTier, AgentRule, RoomPolicy, KindredProof
|
|
|
7
7
|
export type { RelayTransport } from './transport.js';
|
|
8
8
|
export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, RestampedCredentialExpiryError, } from './credential.js';
|
|
9
9
|
export type { CreateCredentialOptions, VerifyResult } from './credential.js';
|
|
10
|
+
export { SEAL_TAG, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
|
|
11
|
+
export type { SealKey } from './seal.js';
|
|
10
12
|
export { deriveScoped, SCOPED_LABEL_PATTERN } from './scoped.js';
|
|
11
13
|
export type { ScopedKeys } from './scoped.js';
|
|
12
14
|
export { createSubKeyCertificate, verifySubKeyCertificate, SUB_KEY_CERTIFICATE_SCOPE, } from './sub-cert.js';
|
|
@@ -24,8 +26,8 @@ export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } fr
|
|
|
24
26
|
export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
|
|
25
27
|
export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
|
|
26
28
|
export type { RoomLink } from './link.js';
|
|
27
|
-
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, 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';
|
|
28
|
-
export type { RoomEpoch, EpochKeys, EncodeRekeyOptions, RekeyNotice, PeekRekeyOptions, DecodeRekeyOptions, EpochRequestAdmissionOptions, EncodeEpochRequestOptions, DecodeEpochRequestOptions, EpochRequest, EpochRefusal, EncodeEpochGrantOptions, DecodeEpochGrantOptions, EpochGrant, HostRoomEpochOptions, RequestRoomEpochOptions, SignAdminsOptions, SignChannelsOptions, VerifyChannelsOptions, VerifyAdminsOptions, } from './epoch.js';
|
|
29
|
+
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, sealCredential, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
|
|
30
|
+
export type { RoomEpoch, EpochKeys, EncodeRekeyOptions, RekeyRecipient, RekeyNotice, PeekRekeyOptions, DecodeRekeyOptions, EpochRequestAdmissionOptions, EncodeEpochRequestOptions, DecodeEpochRequestOptions, EpochRequest, EpochRefusal, EncodeEpochGrantOptions, DecodeEpochGrantOptions, EpochGrant, HostRoomEpochOptions, RequestRoomEpochOptions, SignAdminsOptions, SignChannelsOptions, VerifyChannelsOptions, VerifyAdminsOptions, } from './epoch.js';
|
|
29
31
|
export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
|
|
30
32
|
export { MEMBER_EPOCH_KINDS, MEMBER_EPOCH_REQUEST_KEY_INFO, MAX_MEMBER_EPOCH_CHAIN, deriveMemberEpochRequestKey, readRekeyEvidence, encodeMemberEpochRequest, decodeMemberEpochRequest, encodeMemberEpochGrant, decodeMemberEpochGrant, hostMemberEpochDesk, memberEpochSource, requestMemberEpoch, MEMBER_EPOCH_LABELS, } from './member-epoch.js';
|
|
31
33
|
export type { RekeyEvidence, EncodeMemberEpochRequestOptions, DecodeMemberEpochRequestOptions, MemberEpochRequest, EncodeMemberEpochGrantOptions, DecodeMemberEpochGrantOptions, MemberEpochGrant, HostMemberEpochDeskOptions, MemberEpochRequestOptions, MemberEpochSource, } from './member-epoch.js';
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { verifyEventUncached, boundedEventVerifier } from './verify.js';
|
|
|
5
5
|
export { localIdentity } from './identity.js';
|
|
6
6
|
export { KINDS } from './kinds.js';
|
|
7
7
|
export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, RestampedCredentialExpiryError, } from './credential.js';
|
|
8
|
+
export { SEAL_TAG, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
|
|
8
9
|
export { deriveScoped, SCOPED_LABEL_PATTERN } from './scoped.js';
|
|
9
10
|
export { createSubKeyCertificate, verifySubKeyCertificate, SUB_KEY_CERTIFICATE_SCOPE, } from './sub-cert.js';
|
|
10
11
|
export { generateRoomSecret, deriveRoom, encodeJoinUrl, decodeJoinUrl, parseRoomPolicy, ROOM_LABELS, } from './room.js';
|
|
@@ -16,7 +17,7 @@ export { encodePersistentInvitation, decodePersistentInvitation, requestPersiste
|
|
|
16
17
|
export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
|
|
17
18
|
export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
|
|
18
19
|
export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
|
|
19
|
-
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, 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';
|
|
20
|
+
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, sealCredential, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
|
|
20
21
|
export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
|
|
21
22
|
export { MEMBER_EPOCH_KINDS, MEMBER_EPOCH_REQUEST_KEY_INFO, MAX_MEMBER_EPOCH_CHAIN, deriveMemberEpochRequestKey, readRekeyEvidence, encodeMemberEpochRequest, decodeMemberEpochRequest, encodeMemberEpochGrant, decodeMemberEpochGrant, hostMemberEpochDesk, memberEpochSource, requestMemberEpoch, MEMBER_EPOCH_LABELS, } from './member-epoch.js';
|
|
22
23
|
export { deriveChannel, CHANNEL_ID_INFO, CHANNEL_KEY_INFO, MAX_CHANNEL_NAME_LENGTH, CHANNEL_LABELS } from './channel.js';
|
package/dist/member-epoch.d.ts
CHANGED
|
@@ -118,6 +118,9 @@ export interface EncodeMemberEpochGrantOptions {
|
|
|
118
118
|
epochs: RoomEpoch[];
|
|
119
119
|
/** The authority's rekey event for each of `epochs`, in the same order. */
|
|
120
120
|
rekeys: Event[];
|
|
121
|
+
/** The newest credential the member holds for the device: the grant is
|
|
122
|
+
* sealed to its seal key when it names one (see `seal.ts`). */
|
|
123
|
+
credential?: DeviceCredential;
|
|
121
124
|
now: number;
|
|
122
125
|
expiresAt?: number;
|
|
123
126
|
}
|
|
@@ -136,6 +139,9 @@ export interface DecodeMemberEpochGrantOptions {
|
|
|
136
139
|
authority: string;
|
|
137
140
|
/** The asking device's key. */
|
|
138
141
|
deviceSk: Uint8Array;
|
|
142
|
+
/** The seal key secrets this device holds, newest first, tried before
|
|
143
|
+
* the device key (see `seal.ts`). */
|
|
144
|
+
sealSks?: readonly Uint8Array[];
|
|
139
145
|
/** The ids of this device's own outstanding member requests. */
|
|
140
146
|
requests: ReadonlySet<string>;
|
|
141
147
|
/** Where this device is: the chain starts from this epoch's key. */
|
|
@@ -201,6 +207,10 @@ export interface HostMemberEpochDeskOptions {
|
|
|
201
207
|
* answered (#207). Without it, after a removal, nobody is known.
|
|
202
208
|
*/
|
|
203
209
|
known?: (participant: string) => boolean;
|
|
210
|
+
/** The newest credential this device has seen for another device, from
|
|
211
|
+
* the roster: see `HostRoomEpochOptions.credentialFor`. Keep it
|
|
212
|
+
* monotone. */
|
|
213
|
+
credentialFor?: (device: string) => DeviceCredential | undefined;
|
|
204
214
|
/** Somebody the room does not know asked, after a removal. Called once
|
|
205
215
|
* per participant, and again every `reportUnknownEvery` seconds while
|
|
206
216
|
* they keep asking; nothing is published. Letting them in is making `known`
|
|
@@ -270,6 +280,9 @@ export interface MemberEpochRequestOptions {
|
|
|
270
280
|
now?: () => number;
|
|
271
281
|
/** How often a fresh request goes out. Default 4000 ms. */
|
|
272
282
|
retryMs?: number;
|
|
283
|
+
/** The seal key secrets this device holds. Asked on every answer,
|
|
284
|
+
* because a credential may renew while the ask is out. */
|
|
285
|
+
sealSks?: () => readonly Uint8Array[];
|
|
273
286
|
expiresAt?: number;
|
|
274
287
|
}
|
|
275
288
|
/** What `requestRoomEpoch({ members })` takes: a second source of answers
|
package/dist/member-epoch.js
CHANGED
|
@@ -9,8 +9,9 @@ import { evaluateAccess } from './access.js';
|
|
|
9
9
|
import { verifyEventUncached } from './verify.js';
|
|
10
10
|
import { withExpiration } from './expiration.js';
|
|
11
11
|
import { epochCommitment } from './epoch-commit.js';
|
|
12
|
+
import { openSealed, sealTo } from './seal.js';
|
|
12
13
|
import { KINDS } from './kinds.js';
|
|
13
|
-
import { MAX_EPOCH, REPORT_UNKNOWN_EVERY_SECONDS, deriveEpoch, epochRequestAdmission, peekRekeyEvent, readMemberList, } from './epoch.js';
|
|
14
|
+
import { MAX_EPOCH, REPORT_UNKNOWN_EVERY_SECONDS, deriveEpoch, epochRequestAdmission, peekRekeyEvent, readMemberList, sealCredential, } from './epoch.js';
|
|
14
15
|
/**
|
|
15
16
|
* Member-to-member epoch catch-up: any current member can bring an
|
|
16
17
|
* admitted, non-removed member up to date.
|
|
@@ -153,6 +154,12 @@ export function encodeMemberEpochRequest(opts) {
|
|
|
153
154
|
/** Null for anything malformed, stale, from a device that cannot prove
|
|
154
155
|
* which participant it speaks for, or that cannot prove admission. */
|
|
155
156
|
export function decodeMemberEpochRequest(event, opts) {
|
|
157
|
+
const read = readMemberEpochRequest(event, opts);
|
|
158
|
+
return read && read.request;
|
|
159
|
+
}
|
|
160
|
+
/** `decodeMemberEpochRequest`, keeping the credential the request was made
|
|
161
|
+
* under: what the desk seals its answer by. */
|
|
162
|
+
function readMemberEpochRequest(event, opts) {
|
|
156
163
|
try {
|
|
157
164
|
if (event.kind !== MEMBER_EPOCH_KINDS.REQUEST)
|
|
158
165
|
return null;
|
|
@@ -187,7 +194,10 @@ export function decodeMemberEpochRequest(event, opts) {
|
|
|
187
194
|
if (!evaluateAccess(opts.policy, verdict.participant, proof, opts.now, roomId).admitted)
|
|
188
195
|
return null;
|
|
189
196
|
}
|
|
190
|
-
return {
|
|
197
|
+
return {
|
|
198
|
+
request: { device: verdict.device, participant: verdict.participant, request: event.id, have: body.have },
|
|
199
|
+
credential: body.credential,
|
|
200
|
+
};
|
|
191
201
|
}
|
|
192
202
|
catch {
|
|
193
203
|
return null;
|
|
@@ -239,7 +249,7 @@ export function encodeMemberEpochGrant(opts) {
|
|
|
239
249
|
['d', roomId],
|
|
240
250
|
['p', device],
|
|
241
251
|
], opts.expiresAt),
|
|
242
|
-
content:
|
|
252
|
+
content: sealTo(JSON.stringify(body), signerSk, device, opts.credential),
|
|
243
253
|
}, signerSk);
|
|
244
254
|
}
|
|
245
255
|
/**
|
|
@@ -262,7 +272,7 @@ export function decodeMemberEpochGrant(event, opts) {
|
|
|
262
272
|
const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
|
|
263
273
|
if (addressed === undefined || !hexEquals(addressed, device))
|
|
264
274
|
return null;
|
|
265
|
-
const body = JSON.parse(
|
|
275
|
+
const body = JSON.parse(openSealed(event.content, event.pubkey, opts.deviceSk, opts.sealSks));
|
|
266
276
|
if (body.v !== 1 || typeof body.request !== 'string' || !HEX64.test(body.request))
|
|
267
277
|
return null;
|
|
268
278
|
if (![...opts.requests].some((r) => hexEquals(r, body.request)))
|
|
@@ -404,7 +414,7 @@ export function hostMemberEpochDesk(opts) {
|
|
|
404
414
|
return false;
|
|
405
415
|
return removed.size === 0 || opts.known?.(request.participant) === true;
|
|
406
416
|
};
|
|
407
|
-
const answer = (request) => {
|
|
417
|
+
const answer = (request, presented) => {
|
|
408
418
|
if (closed)
|
|
409
419
|
return;
|
|
410
420
|
if (grantSeen.has(request.device) && !stoodDown.has(request.device)) {
|
|
@@ -432,6 +442,7 @@ export function hostMemberEpochDesk(opts) {
|
|
|
432
442
|
request: request.request,
|
|
433
443
|
epochs: chain.epochs,
|
|
434
444
|
rekeys: chain.rekeys,
|
|
445
|
+
credential: sealCredential(request, presented, opts.credentialFor, roomId, now()),
|
|
435
446
|
now: now(),
|
|
436
447
|
expiresAt: opts.expiresAt,
|
|
437
448
|
});
|
|
@@ -458,9 +469,10 @@ export function hostMemberEpochDesk(opts) {
|
|
|
458
469
|
const unsubRequests = opts.transport.subscribe([{ kinds: [MEMBER_EPOCH_KINDS.REQUEST], '#d': [roomId] }], (event) => {
|
|
459
470
|
if (closed || hexEquals(event.pubkey, self))
|
|
460
471
|
return;
|
|
461
|
-
const
|
|
462
|
-
if (!
|
|
472
|
+
const read = readMemberEpochRequest(event, { roomId, authority, roomKey: opts.roomKey, now: now(), policy: opts.policy });
|
|
473
|
+
if (!read || answered.has(read.request.request))
|
|
463
474
|
return;
|
|
475
|
+
const { request, credential } = read;
|
|
464
476
|
if (opts.closed?.()) {
|
|
465
477
|
answered.add(request.request);
|
|
466
478
|
bound(answered);
|
|
@@ -493,12 +505,12 @@ export function hostMemberEpochDesk(opts) {
|
|
|
493
505
|
grantSeen.delete(request.device);
|
|
494
506
|
const delay = jitterMs === 0 ? 0 : Math.floor(random() * jitterMs);
|
|
495
507
|
if (delay === 0) {
|
|
496
|
-
answer(request);
|
|
508
|
+
answer(request, credential);
|
|
497
509
|
return;
|
|
498
510
|
}
|
|
499
511
|
const timer = setTimeout(() => {
|
|
500
512
|
timers.delete(timer);
|
|
501
|
-
answer(request);
|
|
513
|
+
answer(request, credential);
|
|
502
514
|
}, delay);
|
|
503
515
|
timer.unref?.();
|
|
504
516
|
timers.add(timer);
|
|
@@ -589,6 +601,7 @@ export function memberEpochSource(opts) {
|
|
|
589
601
|
roomId,
|
|
590
602
|
authority,
|
|
591
603
|
deviceSk: opts.deviceSk,
|
|
604
|
+
sealSks: opts.sealSks?.(),
|
|
592
605
|
requests,
|
|
593
606
|
current: current(),
|
|
594
607
|
participant,
|
package/dist/seal.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { DeviceCredential } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Seal keys: how a device heals after its key is copied.
|
|
4
|
+
*
|
|
5
|
+
* A device key signs, and until now every rekey copy and every epoch grant
|
|
6
|
+
* was also sealed to it. Whoever copied it opened every one after, until
|
|
7
|
+
* somebody noticed and removed the device. Rotating a receiving key signed
|
|
8
|
+
* by the device key would heal nothing: the thief holds that key and signs
|
|
9
|
+
* their own rotation.
|
|
10
|
+
*
|
|
11
|
+
* So the receiving key rides in the device credential (kind 20460), which
|
|
12
|
+
* the participant signs. A device mints a fresh seal key at every renewal,
|
|
13
|
+
* and its participant's signer, not the device, binds it. Rekeys and grants
|
|
14
|
+
* are sealed to the seal key of the newest credential the sender holds for
|
|
15
|
+
* the device. A thief who copied the device once holds the seal keys minted
|
|
16
|
+
* up to then, and none minted after: once the copied credential has lapsed
|
|
17
|
+
* and the room has rekeyed, they are out.
|
|
18
|
+
*
|
|
19
|
+
* A device keeps its seal secrets as it keeps its device key, to open what
|
|
20
|
+
* was sealed to them when it reads the room again. Healing needs only that
|
|
21
|
+
* each new one is fresh; retiring old ones is a question of forward
|
|
22
|
+
* secrecy, not of healing.
|
|
23
|
+
*
|
|
24
|
+
* A credential with no `seal` tag is sealed to as before, to the device
|
|
25
|
+
* key, so a client that knows nothing of this keeps working, unhealed. See
|
|
26
|
+
* docs/seal-key.md.
|
|
27
|
+
*/
|
|
28
|
+
/** The credential tag that names a device's seal key. */
|
|
29
|
+
export declare const SEAL_TAG = "seal";
|
|
30
|
+
/** True for an x-only secp256k1 public key: 32-byte hex that lifts to a
|
|
31
|
+
* point on the curve, so NIP-44 can agree a key with it. */
|
|
32
|
+
export declare function isSealPubkey(value: unknown): value is string;
|
|
33
|
+
export interface SealKey {
|
|
34
|
+
secretKey: Uint8Array;
|
|
35
|
+
/** x-only, lower-case hex: what `createDeviceCredential({ seal })` takes. */
|
|
36
|
+
pubkey: string;
|
|
37
|
+
}
|
|
38
|
+
/** A fresh seal key, for the next credential this device asks for. */
|
|
39
|
+
export declare function generateSealKey(): SealKey;
|
|
40
|
+
/**
|
|
41
|
+
* The seal key a credential names. Undefined when it names none, and null
|
|
42
|
+
* when its `seal` tags are unusable: more than one, or one that is not a
|
|
43
|
+
* point on the curve. Only the participant could have signed either, so
|
|
44
|
+
* nothing here is an attack, but a sender must not throw on it mid-rekey:
|
|
45
|
+
* it treats null as none.
|
|
46
|
+
*/
|
|
47
|
+
export declare function credentialSeal(credential: DeviceCredential): string | undefined | null;
|
|
48
|
+
/**
|
|
49
|
+
* Where to seal something for `device`: the seal key `credential` names, or
|
|
50
|
+
* the device key when it names none, or is not this device's.
|
|
51
|
+
*
|
|
52
|
+
* Expiry is deliberately not checked. A device that has gone quiet for
|
|
53
|
+
* longer than its credential kept that seal key, and opens the copy when it
|
|
54
|
+
* is back. Falling back to the device key instead would hand that copy to
|
|
55
|
+
* anybody holding a stolen device key, which is the one person healing
|
|
56
|
+
* exists to shut out.
|
|
57
|
+
*/
|
|
58
|
+
export declare function sealTarget(device: string, credential?: DeviceCredential): string;
|
|
59
|
+
/** Seal `plaintext` from `senderSk` to `device`, by `sealTarget`. */
|
|
60
|
+
export declare function sealTo(plaintext: string, senderSk: Uint8Array, device: string, credential?: DeviceCredential): string;
|
|
61
|
+
/**
|
|
62
|
+
* Open something sealed to this device by `sender`: under each seal key in
|
|
63
|
+
* turn, then under the device key. Throws when none of them opens it.
|
|
64
|
+
* NIP-44's MAC verifies under the right key only, so a wrong one fails
|
|
65
|
+
* rather than producing garbage.
|
|
66
|
+
*/
|
|
67
|
+
export declare function openSealed(ciphertext: string, sender: string, deviceSk: Uint8Array, sealSks?: readonly Uint8Array[]): string;
|
|
68
|
+
/**
|
|
69
|
+
* The newer of two credentials: the later expiry, then the later
|
|
70
|
+
* `created_at`. Expiry first because some remote signers restamp
|
|
71
|
+
* `created_at` with their own clock (kithmoot#205), while the expiry is the
|
|
72
|
+
* device's own request, inside the signature either way. Ties keep `a`.
|
|
73
|
+
*
|
|
74
|
+
* Neither is checked here; compare only credentials already verified for
|
|
75
|
+
* the same device.
|
|
76
|
+
*/
|
|
77
|
+
export declare function newerCredential(a: DeviceCredential, b: DeviceCredential): DeviceCredential;
|