@forgesworn/fold-kit 0.9.0 → 0.12.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/CHANGELOG.md +357 -0
- package/EXTRACTION.md +549 -0
- package/README.md +198 -15
- package/dist/epoch.d.ts +28 -1
- package/dist/epoch.js +99 -60
- package/dist/index.d.ts +7 -2
- package/dist/index.js +5 -1
- package/dist/invitation-decline.d.ts +26 -0
- package/dist/invitation-decline.js +74 -0
- package/dist/invitation.d.ts +61 -5
- package/dist/invitation.js +300 -57
- package/dist/live-persistent-admission.d.ts +52 -0
- package/dist/live-persistent-admission.js +183 -0
- package/dist/mls-admission.d.ts +79 -0
- package/dist/mls-admission.js +400 -0
- package/dist/persistent-invitation-evidence.d.ts +83 -0
- package/dist/persistent-invitation-evidence.js +381 -0
- package/dist/quiet-reply.d.ts +45 -0
- package/dist/quiet-reply.js +82 -0
- package/docs/extraction-plan-excerpt.md +56 -0
- package/docs/live-persistent-admission.md +192 -0
- package/docs/member-epoch-catch-up.md +346 -0
- package/docs/mls-admission-v4.md +84 -0
- package/docs/persistent-invitation-evidence.md +135 -0
- package/docs/quiet-replies.md +81 -0
- package/docs/room-destruct.md +142 -0
- package/docs/scheduled-rekey.md +142 -0
- package/docs/seal-key.md +104 -0
- package/llms.txt +97 -9
- package/package.json +24 -4
- package/vectors/fold-vectors.json +894 -0
- package/vectors/invitation-account-vectors.json +134 -0
- package/vectors/invitation-decline-vectors.json +110 -0
- package/vectors/live-persistent-vectors.json +1240 -0
- package/vectors/mls-admission-v4.json +25 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type Event } from 'nostr-tools/pure';
|
|
2
|
+
import { type RoomInvitation } from './invitation.js';
|
|
3
|
+
import { type PersistentRoomAdmission } from './persistent-invitation.js';
|
|
4
|
+
export declare const LIVE_PERSISTENT_REQUEST_SECONDS = 90;
|
|
5
|
+
export declare const LIVE_PERSISTENT_RESPONSE_SECONDS = 30;
|
|
6
|
+
export interface LivePersistentContext {
|
|
7
|
+
invitation: RoomInvitation;
|
|
8
|
+
/** From the separate routing descriptor; authenticated again in the answer. */
|
|
9
|
+
roomId: string;
|
|
10
|
+
}
|
|
11
|
+
export interface LivePersistentRequest {
|
|
12
|
+
requestId: string;
|
|
13
|
+
requester: string;
|
|
14
|
+
createdAt: number;
|
|
15
|
+
expiresAt: number;
|
|
16
|
+
}
|
|
17
|
+
/** Capability evidence only. The caller must still perform epoch admission. */
|
|
18
|
+
export interface LivePersistentAnswer {
|
|
19
|
+
admission: PersistentRoomAdmission;
|
|
20
|
+
epochHint: number;
|
|
21
|
+
requestId: string;
|
|
22
|
+
expiresAt: number;
|
|
23
|
+
}
|
|
24
|
+
/** For raw input boundaries: bound bytes before parsing, retain exact fields,
|
|
25
|
+
* and reject duplicate JSON keys before a general event reader can discard them. */
|
|
26
|
+
export declare function parseLivePersistentEvent(json: string, request: boolean): Event | null;
|
|
27
|
+
/** Routing metadata only; never advertise the invitation bearer. */
|
|
28
|
+
export declare function encodeLivePersistentDescriptor(opts: LivePersistentContext): string;
|
|
29
|
+
export declare function decodeLivePersistentDescriptor(encoded: string, invitation: RoomInvitation): LivePersistentContext | null;
|
|
30
|
+
export declare function encodeLivePersistentRequest(opts: LivePersistentContext & {
|
|
31
|
+
requesterSk: Uint8Array;
|
|
32
|
+
now: number;
|
|
33
|
+
}): Event;
|
|
34
|
+
export declare function decodeLivePersistentRequest(event: Event, opts: LivePersistentContext & {
|
|
35
|
+
now: number;
|
|
36
|
+
}): LivePersistentRequest | null;
|
|
37
|
+
/** Codec only: callers must hold the durable lifecycle lock through handoff.
|
|
38
|
+
* This function cannot prove that the root's invitation is active. */
|
|
39
|
+
export declare function encodeLivePersistentAnswer(opts: LivePersistentContext & {
|
|
40
|
+
request: Event;
|
|
41
|
+
invitationEvent: Event;
|
|
42
|
+
inviterSk: Uint8Array;
|
|
43
|
+
epoch: number;
|
|
44
|
+
now: number;
|
|
45
|
+
}): Event;
|
|
46
|
+
/** Stateless proof reader. The request owner consumes a result at most once. */
|
|
47
|
+
export declare function decodeLivePersistentAnswer(event: Event, opts: LivePersistentContext & {
|
|
48
|
+
request: Event;
|
|
49
|
+
requesterSk: Uint8Array;
|
|
50
|
+
now: number;
|
|
51
|
+
}): LivePersistentAnswer | null;
|
|
52
|
+
export declare const LIVE_PERSISTENT_ADMISSION_LABELS: readonly ["kithmoot/v1/persistent-live/request-key"];
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { hkdf } from '@noble/hashes/hkdf';
|
|
2
|
+
import { sha256 } from '@noble/hashes/sha2';
|
|
3
|
+
import { base64urlnopad } from '@scure/base';
|
|
4
|
+
import { nip44 } from 'nostr-tools';
|
|
5
|
+
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
|
6
|
+
import { deriveInvitationId } from './invitation.js';
|
|
7
|
+
import { decodePersistentInvitation } from './persistent-invitation.js';
|
|
8
|
+
import { deriveRoom } from './room.js';
|
|
9
|
+
import { verifyEventUncached } from './verify.js';
|
|
10
|
+
import { KINDS } from './kinds.js';
|
|
11
|
+
const REQUEST_KEY = 'kithmoot/v1/persistent-live/request-key';
|
|
12
|
+
const PROFILE = 'persistent-live';
|
|
13
|
+
const HEX = /^[0-9a-f]{64}$/;
|
|
14
|
+
const SIG = /^[0-9a-f]{128}$/;
|
|
15
|
+
const utf8 = new TextEncoder();
|
|
16
|
+
const text = new TextDecoder('utf-8', { fatal: true });
|
|
17
|
+
export const LIVE_PERSISTENT_REQUEST_SECONDS = 90;
|
|
18
|
+
export const LIVE_PERSISTENT_RESPONSE_SECONDS = 30;
|
|
19
|
+
const SKEW = 5;
|
|
20
|
+
function check(ok) {
|
|
21
|
+
if (!ok)
|
|
22
|
+
throw new Error('invalid live persistent admission');
|
|
23
|
+
}
|
|
24
|
+
function integer(n) {
|
|
25
|
+
return typeof n === 'number' && Number.isSafeInteger(n) && n >= 0;
|
|
26
|
+
}
|
|
27
|
+
function clock(now) {
|
|
28
|
+
check(integer(now) && now <= Number.MAX_SAFE_INTEGER - LIVE_PERSISTENT_REQUEST_SECONDS);
|
|
29
|
+
}
|
|
30
|
+
function context(opts) {
|
|
31
|
+
check(opts.invitation.persistent === true && opts.invitation.bearer.length === 32);
|
|
32
|
+
check(HEX.test(opts.invitation.inviter) && HEX.test(opts.roomId));
|
|
33
|
+
}
|
|
34
|
+
function fields(raw, names) {
|
|
35
|
+
check(typeof raw === 'object' && raw !== null && !Array.isArray(raw));
|
|
36
|
+
const keys = Object.keys(raw);
|
|
37
|
+
check(keys.length === names.length && keys.every(k => names.includes(k)));
|
|
38
|
+
}
|
|
39
|
+
/** Minified JSON also rejects duplicate fields and ambiguous number spellings. */
|
|
40
|
+
function body(raw, names) {
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
fields(parsed, names);
|
|
43
|
+
check(JSON.stringify(parsed) === raw);
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
function key(invitation) {
|
|
47
|
+
return hkdf(sha256, invitation.bearer, undefined, REQUEST_KEY, 32);
|
|
48
|
+
}
|
|
49
|
+
function wire(event) {
|
|
50
|
+
return { id: event.id, pubkey: event.pubkey, created_at: event.created_at,
|
|
51
|
+
kind: event.kind, tags: event.tags, content: event.content, sig: event.sig };
|
|
52
|
+
}
|
|
53
|
+
/** Bound every field before serialising or verifying an attacker-supplied object. */
|
|
54
|
+
function eventBounds(raw, contentLimit, byteLimit, tagsLimit) {
|
|
55
|
+
fields(raw, ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig']);
|
|
56
|
+
check(typeof raw.id === 'string' && HEX.test(raw.id) && typeof raw.pubkey === 'string' && HEX.test(raw.pubkey));
|
|
57
|
+
check(typeof raw.sig === 'string' && SIG.test(raw.sig) && integer(raw.kind) && integer(raw.created_at));
|
|
58
|
+
check(typeof raw.content === 'string' && raw.content.length <= contentLimit);
|
|
59
|
+
check(Array.isArray(raw.tags) && raw.tags.length <= tagsLimit);
|
|
60
|
+
for (const tag of raw.tags) {
|
|
61
|
+
check(Array.isArray(tag) && tag.length <= 4 && tag.every(v => typeof v === 'string' && v.length <= 256));
|
|
62
|
+
}
|
|
63
|
+
check(utf8.encode(JSON.stringify(raw)).length <= byteLimit);
|
|
64
|
+
}
|
|
65
|
+
/** For raw input boundaries: bound bytes before parsing, retain exact fields,
|
|
66
|
+
* and reject duplicate JSON keys before a general event reader can discard them. */
|
|
67
|
+
export function parseLivePersistentEvent(json, request) {
|
|
68
|
+
try {
|
|
69
|
+
const max = request ? 4096 : 20480;
|
|
70
|
+
check(typeof json === 'string' && json.length <= max && utf8.encode(json).length <= max);
|
|
71
|
+
const raw = JSON.parse(json);
|
|
72
|
+
check(JSON.stringify(raw) === json);
|
|
73
|
+
eventBounds(raw, request ? 2048 : 16384, max, 3);
|
|
74
|
+
return raw;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function envelope(event, opts, kind, recipient, now) {
|
|
81
|
+
context(opts);
|
|
82
|
+
clock(now);
|
|
83
|
+
const request = kind === KINDS.INVITATION_REQUEST;
|
|
84
|
+
eventBounds(event, request ? 2048 : 16384, request ? 4096 : 20480, 3);
|
|
85
|
+
check(event.kind === kind && event.created_at <= now + SKEW);
|
|
86
|
+
check(event.tags.length === 3 && event.tags.every(t => t.length === 2));
|
|
87
|
+
const tag = (name) => {
|
|
88
|
+
const matches = event.tags.filter(t => t[0] === name);
|
|
89
|
+
check(matches.length === 1);
|
|
90
|
+
return matches[0][1];
|
|
91
|
+
};
|
|
92
|
+
check(tag('d') === deriveInvitationId(opts.invitation) && tag('p') === recipient);
|
|
93
|
+
const expiresAt = Number(tag('expiration'));
|
|
94
|
+
check(integer(expiresAt) && String(expiresAt) === tag('expiration') && expiresAt > now);
|
|
95
|
+
check(verifyEventUncached(event));
|
|
96
|
+
return expiresAt;
|
|
97
|
+
}
|
|
98
|
+
/** Routing metadata only; never advertise the invitation bearer. */
|
|
99
|
+
export function encodeLivePersistentDescriptor(opts) {
|
|
100
|
+
context(opts);
|
|
101
|
+
return base64urlnopad.encode(utf8.encode(JSON.stringify({ v: 1, room: opts.roomId,
|
|
102
|
+
invitation: deriveInvitationId(opts.invitation), inviter: opts.invitation.inviter })));
|
|
103
|
+
}
|
|
104
|
+
export function decodeLivePersistentDescriptor(encoded, invitation) {
|
|
105
|
+
try {
|
|
106
|
+
check(typeof encoded === 'string' && encoded.length <= 512);
|
|
107
|
+
const bytes = base64urlnopad.decode(encoded);
|
|
108
|
+
check(bytes.length <= 384 && base64urlnopad.encode(bytes) === encoded);
|
|
109
|
+
const raw = body(text.decode(bytes), ['v', 'room', 'invitation', 'inviter']);
|
|
110
|
+
check(raw.v === 1 && typeof raw.room === 'string');
|
|
111
|
+
const opts = { invitation, roomId: raw.room };
|
|
112
|
+
context(opts);
|
|
113
|
+
check(raw.invitation === deriveInvitationId(invitation) && raw.inviter === invitation.inviter);
|
|
114
|
+
return opts;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export function encodeLivePersistentRequest(opts) {
|
|
121
|
+
context(opts);
|
|
122
|
+
clock(opts.now);
|
|
123
|
+
return finalizeEvent({ kind: KINDS.INVITATION_REQUEST, created_at: opts.now,
|
|
124
|
+
tags: [['d', deriveInvitationId(opts.invitation)], ['p', opts.invitation.inviter],
|
|
125
|
+
['expiration', String(opts.now + LIVE_PERSISTENT_REQUEST_SECONDS)]],
|
|
126
|
+
content: nip44.v2.encrypt(JSON.stringify({ v: 1, profile: PROFILE, room: opts.roomId,
|
|
127
|
+
requester: getPublicKey(opts.requesterSk) }), key(opts.invitation)),
|
|
128
|
+
}, opts.requesterSk);
|
|
129
|
+
}
|
|
130
|
+
export function decodeLivePersistentRequest(event, opts) {
|
|
131
|
+
try {
|
|
132
|
+
const expiresAt = envelope(event, opts, KINDS.INVITATION_REQUEST, opts.invitation.inviter, opts.now);
|
|
133
|
+
check(expiresAt === event.created_at + LIVE_PERSISTENT_REQUEST_SECONDS);
|
|
134
|
+
const raw = body(nip44.v2.decrypt(event.content, key(opts.invitation)), ['v', 'profile', 'room', 'requester']);
|
|
135
|
+
check(raw.v === 1 && raw.profile === PROFILE && raw.room === opts.roomId && raw.requester === event.pubkey);
|
|
136
|
+
return { requestId: event.id, requester: event.pubkey, createdAt: event.created_at, expiresAt };
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function invitationOf(event, opts, now, responseAt) {
|
|
143
|
+
eventBounds(event, 6144, 8192, 8);
|
|
144
|
+
check(event.created_at <= Math.min(now, responseAt) + SKEW);
|
|
145
|
+
const admission = decodePersistentInvitation(event, opts.invitation);
|
|
146
|
+
check(admission !== null && deriveRoom(admission.secret).roomId === opts.roomId);
|
|
147
|
+
check(admission.endsAt === undefined || admission.endsAt > now);
|
|
148
|
+
return admission;
|
|
149
|
+
}
|
|
150
|
+
/** Codec only: callers must hold the durable lifecycle lock through handoff.
|
|
151
|
+
* This function cannot prove that the root's invitation is active. */
|
|
152
|
+
export function encodeLivePersistentAnswer(opts) {
|
|
153
|
+
const request = decodeLivePersistentRequest(opts.request, opts);
|
|
154
|
+
check(request !== null && integer(opts.epoch) && getPublicKey(opts.inviterSk) === opts.invitation.inviter);
|
|
155
|
+
check(opts.now >= request.createdAt - SKEW);
|
|
156
|
+
invitationOf(opts.invitationEvent, opts, opts.now, opts.now);
|
|
157
|
+
const event = finalizeEvent({ kind: KINDS.INVITATION_GRANT, created_at: opts.now,
|
|
158
|
+
tags: [['d', deriveInvitationId(opts.invitation)], ['p', request.requester],
|
|
159
|
+
['expiration', String(Math.min(request.expiresAt, opts.now + LIVE_PERSISTENT_RESPONSE_SECONDS))]],
|
|
160
|
+
content: nip44.v2.encrypt(JSON.stringify({ v: 1, profile: PROFILE, request: request.requestId,
|
|
161
|
+
room: opts.roomId, epoch: opts.epoch, invitation: wire(opts.invitationEvent) }), nip44.v2.utils.getConversationKey(opts.inviterSk, request.requester)),
|
|
162
|
+
}, opts.inviterSk);
|
|
163
|
+
eventBounds(event, 16384, 20480, 3);
|
|
164
|
+
return event;
|
|
165
|
+
}
|
|
166
|
+
/** Stateless proof reader. The request owner consumes a result at most once. */
|
|
167
|
+
export function decodeLivePersistentAnswer(event, opts) {
|
|
168
|
+
try {
|
|
169
|
+
const request = decodeLivePersistentRequest(opts.request, opts);
|
|
170
|
+
check(request !== null && getPublicKey(opts.requesterSk) === request.requester);
|
|
171
|
+
const expiresAt = envelope(event, opts, KINDS.INVITATION_GRANT, request.requester, opts.now);
|
|
172
|
+
check(event.pubkey === opts.invitation.inviter && event.created_at >= request.createdAt - SKEW);
|
|
173
|
+
check(expiresAt === Math.min(request.expiresAt, event.created_at + LIVE_PERSISTENT_RESPONSE_SECONDS));
|
|
174
|
+
const raw = body(nip44.v2.decrypt(event.content, nip44.v2.utils.getConversationKey(opts.requesterSk, opts.invitation.inviter)), ['v', 'profile', 'request', 'room', 'epoch', 'invitation']);
|
|
175
|
+
check(raw.v === 1 && raw.profile === PROFILE && raw.request === request.requestId && raw.room === opts.roomId && integer(raw.epoch));
|
|
176
|
+
const admission = invitationOf(raw.invitation, opts, opts.now, event.created_at);
|
|
177
|
+
return { admission, epochHint: raw.epoch, requestId: request.requestId, expiresAt };
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export const LIVE_PERSISTENT_ADMISSION_LABELS = [REQUEST_KEY];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { UnsignedEvent } from './identity.js';
|
|
2
|
+
export declare const MLS_ADMISSION_LABELS: readonly string[];
|
|
3
|
+
export declare const MLS_ADMISSION_PLAINTEXT_BYTES = 16384;
|
|
4
|
+
export declare const MLS_ADMISSION_CIPHERTEXT_CHARACTERS = 21936;
|
|
5
|
+
/** Evidence only. No admission/capability constructor is exported. */
|
|
6
|
+
export interface VerifiedMlsAdmissionRequest {
|
|
7
|
+
readonly __verifiedMlsAdmissionRequest: unique symbol;
|
|
8
|
+
}
|
|
9
|
+
export interface VerifiedAdmissionAnswer {
|
|
10
|
+
readonly __verifiedAdmissionAnswer: unique symbol;
|
|
11
|
+
}
|
|
12
|
+
/** Link fragment only; caller owns CSPRNG generation and secret lifetime. */
|
|
13
|
+
export declare function encodeMlsInvitationV4(root: string, bearer: Uint8Array): string;
|
|
14
|
+
export declare function readMlsInvitationV4(fragment: string): {
|
|
15
|
+
root: string;
|
|
16
|
+
bearer: Uint8Array;
|
|
17
|
+
invitationId: string;
|
|
18
|
+
};
|
|
19
|
+
/** Templates only. Account/device signers remain caller-owned and settle under
|
|
20
|
+
* the application's actual custody. Returned templates never confer authority.
|
|
21
|
+
* core is the exact canonical request array or, with request, Allow array. */
|
|
22
|
+
export declare function mlsAdmissionProofTemplatesV4(opts: {
|
|
23
|
+
fragment: string;
|
|
24
|
+
core: string;
|
|
25
|
+
now: number;
|
|
26
|
+
request?: VerifiedMlsAdmissionRequest;
|
|
27
|
+
}): {
|
|
28
|
+
pubkey: string;
|
|
29
|
+
event: UnsignedEvent;
|
|
30
|
+
}[];
|
|
31
|
+
/** proofs is canonical JSON of the three seven-field signed proof arrays. */
|
|
32
|
+
export declare function encodeMlsAdmissionRequestV4(opts: {
|
|
33
|
+
fragment: string;
|
|
34
|
+
core: string;
|
|
35
|
+
proofs: string;
|
|
36
|
+
requestSecretKey: Uint8Array;
|
|
37
|
+
now: number;
|
|
38
|
+
}): string;
|
|
39
|
+
/** Either endpoint may decode its exact request; only this decoder issues the
|
|
40
|
+
* nominal result, after fresh outer/inner checks. Caller secrets are not kept. */
|
|
41
|
+
export declare function decodeMlsAdmissionRequestV4(opts: {
|
|
42
|
+
fragment: string;
|
|
43
|
+
event: string;
|
|
44
|
+
localSecretKey: Uint8Array;
|
|
45
|
+
side: 'root' | 'joiner';
|
|
46
|
+
now: number;
|
|
47
|
+
}): VerifiedMlsAdmissionRequest;
|
|
48
|
+
/** Returned strings are copies of evidence, not a rehydration format. */
|
|
49
|
+
export declare function readMlsAdmissionRequestV4(handle: VerifiedMlsAdmissionRequest): Readonly<{
|
|
50
|
+
core: string;
|
|
51
|
+
digest: string;
|
|
52
|
+
eventId: string;
|
|
53
|
+
}>;
|
|
54
|
+
/** Root correlation and signed keeper consent only. This encoder cannot attest
|
|
55
|
+
* current keeper role; actual vault/box custody and fresh checks stay external. */
|
|
56
|
+
export declare function encodeMlsAdmissionAnswerV4(opts: {
|
|
57
|
+
fragment: string;
|
|
58
|
+
request: VerifiedMlsAdmissionRequest;
|
|
59
|
+
core: string;
|
|
60
|
+
proofs: string;
|
|
61
|
+
rootSecretKey: Uint8Array;
|
|
62
|
+
now: number;
|
|
63
|
+
}): string;
|
|
64
|
+
export declare function decodeMlsAdmissionAnswerV4(opts: {
|
|
65
|
+
fragment: string;
|
|
66
|
+
request: VerifiedMlsAdmissionRequest;
|
|
67
|
+
event: string;
|
|
68
|
+
requestSecretKey: Uint8Array;
|
|
69
|
+
now: number;
|
|
70
|
+
}): VerifiedAdmissionAnswer;
|
|
71
|
+
/** Cryptographic evidence only: no usable capability, child or admission. The
|
|
72
|
+
* application must obtain current role witness and consume the operation once. */
|
|
73
|
+
export declare function readMlsAdmissionAnswerV4(handle: VerifiedAdmissionAnswer): Readonly<{
|
|
74
|
+
decision: 'allow' | 'deny';
|
|
75
|
+
core: string;
|
|
76
|
+
digest: string;
|
|
77
|
+
eventId: string;
|
|
78
|
+
requestEventId: string;
|
|
79
|
+
}>;
|