1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361 | /**
* E2E crypto for the sueta p2p stack. WebCrypto only. See docs/PROTOCOL.md §2-3.
*
* Key structure: every key is derived per-SENDER per-SESSION (peer ids are fresh
* random per page load), so no two parties ever encrypt under the same key.
* IVs are owned by a Sealer (epoch ‖ counter); there is no API that accepts a
* caller-supplied IV, making nonce reuse structurally impossible.
*/
const td = new TextDecoder();
/** Bytes always backed by a plain ArrayBuffer (what WebCrypto's types demand). */
export type Bytes = Uint8Array<ArrayBuffer>;
const utf8 = (s: string): Bytes => new TextEncoder().encode(s) as Bytes;
export function randomBytes(n: number): Bytes {
const b = new Uint8Array(n);
crypto.getRandomValues(b);
return b;
}
export function toB64(bytes: Bytes): string {
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}
export function fromB64(s: string): Bytes {
const bin = atob(s);
const b = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) b[i] = bin.charCodeAt(i);
return b;
}
export function toB64url(bytes: Bytes): string {
return toB64(bytes).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
}
export function fromB64url(s: string): Bytes {
return fromB64(s.replaceAll("-", "+").replaceAll("_", "/"));
}
export function newRoomSecret(): string {
return toB64url(randomBytes(32));
}
export function newPeerId(): string {
return Array.from(randomBytes(16), (b) => b.toString(16).padStart(2, "0")).join("");
}
export interface Envelope {
v: 1;
iv: string;
ct: string;
}
/** Owns (key, epoch, counter). The only way to encrypt in this library. */
export class Sealer {
#key: CryptoKey;
#epoch: Bytes;
#counter = 0n;
constructor(key: CryptoKey) {
this.#key = key;
this.#epoch = randomBytes(4);
}
#nextIV(): Bytes {
const iv = new Uint8Array(12);
iv.set(this.#epoch, 0);
new DataView(iv.buffer).setBigUint64(4, this.#counter++);
return iv;
}
async seal(obj: unknown, aad: Bytes): Promise<Envelope> {
const iv = this.#nextIV();
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv, additionalData: aad },
this.#key,
utf8(JSON.stringify(obj)),
);
return { v: 1, iv: toB64(iv), ct: toB64(new Uint8Array(ct)) };
}
/** Binary envelope: [0x01 | 12-byte IV | ciphertext+tag] as one ArrayBuffer. */
async sealBinary(data: Bytes, aad: Bytes): Promise<ArrayBuffer> {
const iv = this.#nextIV();
const ct = new Uint8Array(
await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad }, this.#key, data as BufferSource),
);
const out = new Uint8Array(1 + 12 + ct.length);
out[0] = 0x01;
out.set(iv, 1);
out.set(ct, 13);
return out.buffer;
}
}
/**
* At-rest AEAD for the replicated log (v2). Unlike Sealer, the key is
* ROOM-WIDE (any member must decrypt entries whose author's session is long
* gone), so epoch‖counter IVs are unsafe — two sessions could collide. Random
* 96-bit IVs are safe by the birthday bound for far more entries than any
* room will ever hold (NIST cap 2^32; a 10M-entry room is ~2^-51 risk).
* Still no caller-supplied IVs. Envelope: [0x02 | 12-byte IV | ct‖tag].
*/
export class AtRestCipher {
#key: CryptoKey;
#aad: Bytes;
constructor(key: CryptoKey, aad: Bytes) {
this.#key = key;
this.#aad = aad;
}
async seal(data: Bytes): Promise<Bytes> {
const iv = randomBytes(12);
const ct = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv as BufferSource, additionalData: this.#aad as BufferSource },
this.#key,
data as BufferSource,
),
);
const out = new Uint8Array(1 + 12 + ct.length);
out[0] = 0x02;
out.set(iv, 1);
out.set(ct, 13);
return out;
}
/** null on any failure ⇒ caller drops (protocol §3 discipline). */
async open(data: Bytes): Promise<Bytes | null> {
if (data.length < 1 + 12 + 16 || data[0] !== 0x02) return null;
try {
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: data.subarray(1, 13) as BufferSource, additionalData: this.#aad as BufferSource },
this.#key,
data.subarray(13) as BufferSource,
);
return new Uint8Array(pt);
} catch {
return null;
}
}
}
async function open(key: CryptoKey, env: Envelope, aad: Bytes): Promise<unknown | null> {
try {
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: fromB64(env.iv) as BufferSource, additionalData: aad },
key,
fromB64(env.ct) as BufferSource,
);
return JSON.parse(td.decode(pt));
} catch {
return null; // authentication failure ⇒ drop silently (protocol §3)
}
}
async function openBinary(key: CryptoKey, buf: ArrayBuffer, aad: Bytes): Promise<Uint8Array | null> {
const b = new Uint8Array(buf);
if (b.length < 1 + 12 + 16 || b[0] !== 0x01) return null;
try {
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: b.subarray(1, 13) as BufferSource, additionalData: aad },
key,
b.subarray(13) as BufferSource,
);
return new Uint8Array(pt);
} catch {
return null;
}
}
/**
* All derivations for one room. `appSalt` namespaces the application
* (e.g. "chat.ardegazu.ro/v1") so identical secrets on different apps built on
* this stack land in different rooms.
*/
export class RoomCrypto {
readonly roomId: string;
readonly peerId: string;
/** Room-scoped gossipsub topic for ephemeral traffic (presence, calls). One-way from the secret, like roomId. */
readonly roomTopic: string;
#ikm: CryptoKey;
#salt: Bytes;
#keyCache = new Map<string, Promise<CryptoKey>>();
#sigSealer: Sealer;
#msgSealer: Sealer;
private constructor(
ikm: CryptoKey,
salt: Bytes,
roomId: string,
roomTopic: string,
peerId: string,
sig: Sealer,
msg: Sealer,
) {
this.#ikm = ikm;
this.#salt = salt;
this.roomId = roomId;
this.roomTopic = roomTopic;
this.peerId = peerId;
this.#sigSealer = sig;
this.#msgSealer = msg;
}
/**
* `peerId` is the session's network id. When omitted a fresh random hex id is
* generated (v1 behavior); the libp2p stack passes its own base58 PeerId so
* per-sender keys and AADs bind to the id peers actually see on the wire.
*/
static async create(secretB64url: string, appSalt: string, peerId?: string): Promise<RoomCrypto> {
const secret = fromB64url(secretB64url);
if (secret.length < 16) throw new Error("room secret too short");
const salt = utf8(appSalt);
const ikm = await crypto.subtle.importKey("raw", secret as BufferSource, "HKDF", false, [
"deriveBits",
"deriveKey",
]);
const roomIdBits = await crypto.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt: salt as BufferSource, info: utf8("roomid") as BufferSource },
ikm,
256,
);
const topicBits = await crypto.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt: salt as BufferSource, info: utf8("topic|room") as BufferSource },
ikm,
256,
);
const pid = peerId ?? newPeerId();
const kSig = await deriveKey(ikm, salt, `signal|${pid}`);
const kMsg = await deriveKey(ikm, salt, `msg|${pid}`);
return new RoomCrypto(
ikm,
salt,
toB64url(new Uint8Array(roomIdBits)),
`sueta/2/${toB64url(new Uint8Array(topicBits))}`,
pid,
new Sealer(kSig),
new Sealer(kMsg),
);
}
#keyFor(info: string): Promise<CryptoKey> {
let p = this.#keyCache.get(info);
if (!p) {
// bounded: decrypt keys are derived per REMOTE sender id, and sender ids
// are attacker-mintable — an unbounded cache would grow for the session
// lifetime under a spam of fresh PeerIds. FIFO eviction; re-deriving an
// evicted key is cheap (one HKDF).
if (this.#keyCache.size >= 512) {
const oldest = this.#keyCache.keys().next().value;
if (oldest !== undefined) this.#keyCache.delete(oldest);
}
p = deriveKey(this.#ikm, this.#salt, info);
this.#keyCache.set(info, p);
}
return p;
}
#sigAAD(from: string, to: string): Bytes {
return utf8(`v1|${this.roomId}|${from}|${to}|sig`);
}
#msgAAD(from: string): Bytes {
return utf8(`v1|${this.roomId}|${from}|msg`);
}
/** Encrypt a signaling payload for a specific recipient. */
sealSignal(to: string, payload: unknown): Promise<Envelope> {
return this.#sigSealer.seal(payload, this.#sigAAD(this.peerId, to));
}
/** Decrypt a signaling payload from `from` addressed to me. */
async openSignal(from: string, env: Envelope): Promise<unknown | null> {
return open(await this.#keyFor(`signal|${from}`), env, this.#sigAAD(from, this.peerId));
}
/** Encrypt a datachannel JSON payload (same ciphertext broadcasts to everyone). */
sealMsg(payload: unknown): Promise<Envelope> {
return this.#msgSealer.seal(payload, this.#msgAAD(this.peerId));
}
async openMsg(from: string, env: Envelope): Promise<unknown | null> {
return open(await this.#keyFor(`msg|${from}`), env, this.#msgAAD(from));
}
sealMsgBinary(data: Bytes): Promise<ArrayBuffer> {
return this.#msgSealer.sealBinary(data, this.#msgAAD(this.peerId));
}
async openMsgBinary(from: string, buf: ArrayBuffer): Promise<Uint8Array | null> {
return openBinary(await this.#keyFor(`msg|${from}`), buf, this.#msgAAD(from));
}
// ---- v2 at-rest derivations (replicated log; room-wide keys) -------------
/** Deterministic OrbitDB database name — same for every member, like roomId. */
async dbName(): Promise<string> {
const bits = await crypto.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt: this.#salt as BufferSource, info: utf8("dbname") as BufferSource },
this.#ikm,
256,
);
return toB64url(new Uint8Array(bits));
}
async #atRest(info: string, aadSuffix: string): Promise<AtRestCipher> {
return new AtRestCipher(await this.#keyFor(info), utf8(`v2|${this.roomId}|${aadSuffix}`));
}
/** Encrypts whole log entries before they become content-addressed blocks. */
logEntryCipher(): Promise<AtRestCipher> {
return this.#atRest("log-entry", "entry");
}
/** Encrypts entry payloads inside the (already encrypted) entry. */
logPayloadCipher(): Promise<AtRestCipher> {
return this.#atRest("log-payload", "payload");
}
/** Encrypts image blobs stored as unixfs blocks. */
imgCipher(): Promise<AtRestCipher> {
return this.#atRest("img", "img");
}
// ---- offline mailbox derivations (PROTOCOL.md §9) -------------------------
/**
* Server-visible mailbox room id. Its own derivation — never the roomId,
* roomTopic or dbName: the mailbox node must not be able to correlate its
* per-room store with pubsub topics or database addresses it also sees.
*/
async mailboxRoomId(): Promise<string> {
const bits = await crypto.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt: this.#salt as BufferSource, info: utf8("mailbox|room") as BufferSource },
this.#ikm,
256,
);
return toB64url(new Uint8Array(bits));
}
/** Seals identity records in mailbox transit (they are plaintext blocks, §7b). */
mbxCipher(): Promise<AtRestCipher> {
return this.#atRest("mailbox-blob", "mbx");
}
}
function deriveKey(ikm: CryptoKey, salt: Bytes, info: string): Promise<CryptoKey> {
return crypto.subtle.deriveKey(
{ name: "HKDF", hash: "SHA-256", salt: salt as BufferSource, info: utf8(info) as BufferSource },
ikm,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
|