@tavrn/e2ee 0.0.0-stage → 0.2.1
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/LICENSE +373 -0
- package/README.md +269 -2
- package/package.json +39 -3
- package/src/account.ts +457 -0
- package/src/cleanup.ts +336 -0
- package/src/commands.ts +95 -0
- package/src/components.ts +155 -0
- package/src/content.ts +152 -0
- package/src/embeds.ts +148 -0
- package/src/errors.ts +31 -0
- package/src/files.ts +346 -0
- package/src/history.ts +710 -0
- package/src/identity.ts +216 -0
- package/src/index.ts +75 -0
- package/src/keystore.ts +126 -0
- package/src/lru.ts +113 -0
- package/src/mls.ts +2617 -0
- package/src/node.ts +3 -0
- package/src/plaintext.ts +141 -0
- package/src/poll.ts +434 -0
- package/src/sealed.ts +64 -0
- package/src/search/backend.ts +82 -0
- package/src/search/contract.ts +126 -0
- package/src/search/index.ts +6 -0
- package/src/search/keys.ts +108 -0
- package/src/search/searchIndex.ts +844 -0
- package/src/search/tokenize.ts +294 -0
- package/src/session.ts +172 -0
- package/src/storage/file.ts +273 -0
- package/src/storage/memory.ts +95 -0
- package/src/tabs.ts +367 -0
- package/src/trust.ts +129 -0
- package/src/wasm.ts +38 -0
package/src/content.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Encrypted attachments (ENCRYPTION.md §6) and validation of decrypted content objects. The node only
|
|
4
|
+
* sees the ciphertext of a file; name, type, size and the file key travel inside the encrypted message.
|
|
5
|
+
*
|
|
6
|
+
* Attachments are one-shot AES-256-GCM under a random file key with the AAD `"tavrn file v1"`, done with WebCrypto
|
|
7
|
+
* (native AES, no copy of the file through the WebAssembly memory, which never shrinks); the bytes are those of the
|
|
8
|
+
* crypto core's `encryptFile` (the `file` test vector).
|
|
9
|
+
*/
|
|
10
|
+
import { b64urlDecode, b64urlEncode, type Attachment } from "@tavrn/sdk";
|
|
11
|
+
|
|
12
|
+
/** One entry of `content.attachments` in an encrypted message. */
|
|
13
|
+
export interface ContentAttachment {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
mime: string;
|
|
17
|
+
/** Plaintext size in bytes. */
|
|
18
|
+
size: number;
|
|
19
|
+
key: string;
|
|
20
|
+
nonce: string;
|
|
21
|
+
/** base64url SHA-256 of the uploaded ciphertext. */
|
|
22
|
+
sha256: string;
|
|
23
|
+
/** Shown blurred until clicked (design note 4.13); absent = false. */
|
|
24
|
+
spoiler?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ULID = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
28
|
+
const MIME = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/;
|
|
29
|
+
|
|
30
|
+
/** File name for display and download: no control characters or path separators, at most 255 characters. */
|
|
31
|
+
export function safeFileName(name: string): string {
|
|
32
|
+
const cleaned = Array.from(name)
|
|
33
|
+
.filter((c) => c.charCodeAt(0) >= 0x20 && c.charCodeAt(0) !== 0x7f)
|
|
34
|
+
.map((c) => (c === "/" || c === "\\" ? "_" : c))
|
|
35
|
+
.join("")
|
|
36
|
+
.trim()
|
|
37
|
+
.replace(/^\.+/, "")
|
|
38
|
+
.slice(0, 255);
|
|
39
|
+
return cleaned || "file";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isB64(s: unknown, bytes: number): s is string {
|
|
43
|
+
if (typeof s !== "string") return false;
|
|
44
|
+
try {
|
|
45
|
+
return b64urlDecode(s).length === bytes;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The attachments of a decrypted content object that match the envelope: same blob id and ciphertext
|
|
53
|
+
* hash, well-formed key and nonce. Anything else is dropped (a member cannot point others at blobs
|
|
54
|
+
* that are not part of the message).
|
|
55
|
+
*/
|
|
56
|
+
export function contentAttachments(content: Record<string, unknown>, envelope: Attachment[] | undefined): ContentAttachment[] {
|
|
57
|
+
const list = Array.isArray(content.attachments) ? content.attachments : [];
|
|
58
|
+
const out: ContentAttachment[] = [];
|
|
59
|
+
for (const raw of list.slice(0, 10)) {
|
|
60
|
+
if (!raw || typeof raw !== "object") continue;
|
|
61
|
+
const a = raw as Record<string, unknown>;
|
|
62
|
+
if (typeof a.id !== "string" || !ULID.test(a.id)) continue;
|
|
63
|
+
const env = envelope?.find((e) => e.id === a.id);
|
|
64
|
+
if (!env || env.sha256 !== a.sha256) continue;
|
|
65
|
+
if (!isB64(a.key, 32) || !isB64(a.nonce, 12) || !isB64(a.sha256, 32)) continue;
|
|
66
|
+
const mime = typeof a.mime === "string" && MIME.test(a.mime.toLowerCase()) ? a.mime.toLowerCase() : "application/octet-stream";
|
|
67
|
+
const size = typeof a.size === "number" && Number.isSafeInteger(a.size) && a.size >= 0 ? a.size : 0;
|
|
68
|
+
const entry: ContentAttachment = { id: a.id, name: safeFileName(typeof a.name === "string" ? a.name : ""), mime, size, key: a.key, nonce: a.nonce, sha256: a.sha256 };
|
|
69
|
+
if (a.spoiler === true) entry.spoiler = true;
|
|
70
|
+
out.push(entry);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** AAD of every attachment ciphertext (ENCRYPTION.md §6). */
|
|
76
|
+
const FILE_AAD = new TextEncoder().encode("tavrn file v1");
|
|
77
|
+
|
|
78
|
+
function cryptoError(name: "invalid_input" | "decryption_failed", message: string): Error {
|
|
79
|
+
const e = new Error(message);
|
|
80
|
+
e.name = name;
|
|
81
|
+
return e;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function fileKey(key: Uint8Array, use: KeyUsage): Promise<CryptoKey> {
|
|
85
|
+
if (key.length !== 32) throw cryptoError("invalid_input", "file key");
|
|
86
|
+
return crypto.subtle.importKey("raw", key as Uint8Array<ArrayBuffer>, "AES-GCM", false, [use]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Encrypts attachment bytes under `key` and `nonce` (ENCRYPTION.md §6): the ciphertext with its tag and the SHA-256 of
|
|
91
|
+
* the ciphertext. {@link encryptAttachment} draws both at random; a nonce must never repeat under a key.
|
|
92
|
+
*/
|
|
93
|
+
export async function encryptAttachmentBytes(plain: Uint8Array, key: Uint8Array, nonce: Uint8Array): Promise<{ ciphertext: Uint8Array; sha256: Uint8Array }> {
|
|
94
|
+
if (nonce.length !== 12) throw cryptoError("invalid_input", "nonce");
|
|
95
|
+
const k = await fileKey(key, "encrypt");
|
|
96
|
+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce as Uint8Array<ArrayBuffer>, additionalData: FILE_AAD, tagLength: 128 }, k, plain as Uint8Array<ArrayBuffer>));
|
|
97
|
+
const sha256 = new Uint8Array(await crypto.subtle.digest("SHA-256", ciphertext));
|
|
98
|
+
return { ciphertext, sha256 };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Encrypts a file for upload: the ciphertext blob and the metadata for the content (without the blob id). */
|
|
102
|
+
export async function encryptAttachment(file: File): Promise<{ blob: Blob; meta: Omit<ContentAttachment, "id"> }> {
|
|
103
|
+
const plain = new Uint8Array(await file.arrayBuffer());
|
|
104
|
+
const key = crypto.getRandomValues(new Uint8Array(32));
|
|
105
|
+
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
106
|
+
try {
|
|
107
|
+
const { ciphertext, sha256 } = await encryptAttachmentBytes(plain, key, nonce);
|
|
108
|
+
const meta = { name: safeFileName(file.name), mime: file.type || "application/octet-stream", size: file.size, key: b64urlEncode(key), nonce: b64urlEncode(nonce), sha256: b64urlEncode(sha256) };
|
|
109
|
+
return { blob: new Blob([ciphertext as Uint8Array<ArrayBuffer>], { type: "application/octet-stream" }), meta };
|
|
110
|
+
} finally {
|
|
111
|
+
plain.fill(0);
|
|
112
|
+
key.fill(0);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class AttachmentIntegrityError extends Error {
|
|
117
|
+
constructor() {
|
|
118
|
+
super("attachment hash mismatch");
|
|
119
|
+
this.name = "AttachmentIntegrityError";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Checks the bytes of a plaintext attachment against its `sha256` (base64url), which a signed plaintext message covers
|
|
125
|
+
* (FEDERATION.md §10.3): throws {@link AttachmentIntegrityError} when the node served other bytes.
|
|
126
|
+
*/
|
|
127
|
+
export async function verifyPlainAttachment(bytes: Uint8Array, sha256: string): Promise<void> {
|
|
128
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>));
|
|
129
|
+
if (b64urlEncode(digest) !== sha256) throw new AttachmentIntegrityError();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Checks the SHA-256 of the downloaded ciphertext (§6 step 4), then decrypts it. Throws
|
|
134
|
+
* {@link AttachmentIntegrityError} for other bytes, an `Error` named `decryption_failed` for a wrong key or nonce.
|
|
135
|
+
*/
|
|
136
|
+
export async function decryptAttachment(ciphertext: Uint8Array, a: Pick<ContentAttachment, "key" | "nonce" | "sha256">): Promise<Uint8Array> {
|
|
137
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", ciphertext as Uint8Array<ArrayBuffer>));
|
|
138
|
+
if (b64urlEncode(digest) !== a.sha256) throw new AttachmentIntegrityError();
|
|
139
|
+
const key = b64urlDecode(a.key);
|
|
140
|
+
const nonce = b64urlDecode(a.nonce);
|
|
141
|
+
try {
|
|
142
|
+
if (nonce.length !== 12) throw cryptoError("invalid_input", "nonce");
|
|
143
|
+
const k = await fileKey(key, "decrypt");
|
|
144
|
+
try {
|
|
145
|
+
return new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce as Uint8Array<ArrayBuffer>, additionalData: FILE_AAD, tagLength: 128 }, k, ciphertext as Uint8Array<ArrayBuffer>));
|
|
146
|
+
} catch {
|
|
147
|
+
throw cryptoError("decryption_failed", "attachment does not decrypt");
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
key.fill(0);
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/embeds.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Embeds in `text` content (protocol/APPLICATIONS.md §6.4): the client half of the shared content codec. The node
|
|
4
|
+
* checks plaintext content with the same rules (`server/src/embeds.rs`); encrypted content is invisible to it, so
|
|
5
|
+
* senders check before encrypting ({@link assertEmbeds}) and receivers drop invalid embeds after decrypting
|
|
6
|
+
* ({@link withValidEmbeds}). {@link readEmbeds} gives a renderer the checked embeds of any content object.
|
|
7
|
+
*
|
|
8
|
+
* Embeds carry text, one `https://` link and a colour, never images: clients never load anything from foreign
|
|
9
|
+
* hosts for them.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const MAX_EMBEDS = 10;
|
|
13
|
+
export const MAX_EMBED_CHARS = 6000;
|
|
14
|
+
export const MAX_EMBED_TITLE = 256;
|
|
15
|
+
export const MAX_EMBED_DESCRIPTION = 4096;
|
|
16
|
+
export const MAX_EMBED_URL = 2048;
|
|
17
|
+
export const MAX_EMBED_FIELDS = 25;
|
|
18
|
+
export const MAX_EMBED_FIELD_NAME = 256;
|
|
19
|
+
export const MAX_EMBED_FIELD_VALUE = 1024;
|
|
20
|
+
export const MAX_EMBED_FOOTER = 2048;
|
|
21
|
+
export const MAX_EMBED_COLOR = 0xffffff;
|
|
22
|
+
|
|
23
|
+
export interface EmbedField {
|
|
24
|
+
name: string;
|
|
25
|
+
value: string;
|
|
26
|
+
inline?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A checked embed. */
|
|
30
|
+
export interface MessageEmbed {
|
|
31
|
+
title?: string;
|
|
32
|
+
/** Markdown subset, rendered through the same sanitizer as message bodies. */
|
|
33
|
+
description?: string;
|
|
34
|
+
/** `https://` only. */
|
|
35
|
+
url?: string;
|
|
36
|
+
/** 0–16777215 (`0xRRGGBB`). */
|
|
37
|
+
color?: number;
|
|
38
|
+
fields?: EmbedField[];
|
|
39
|
+
footer?: string;
|
|
40
|
+
/** RFC 3339. */
|
|
41
|
+
timestamp?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Thrown by {@link assertEmbeds} (the node answers `400 message.embeds_invalid` for plaintext). */
|
|
45
|
+
export class EmbedsInvalidError extends Error {
|
|
46
|
+
readonly code = "message.embeds_invalid";
|
|
47
|
+
constructor(detail: string) {
|
|
48
|
+
super(detail);
|
|
49
|
+
this.name = "EmbedsInvalidError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Characters as the node counts them (Unicode scalar values). */
|
|
54
|
+
const chars = (s: string) => Array.from(s).length;
|
|
55
|
+
|
|
56
|
+
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/i;
|
|
57
|
+
|
|
58
|
+
/** `https://` with a host, without credentials or whitespace, at most 2048 characters. */
|
|
59
|
+
export function validEmbedUrl(url: unknown): url is string {
|
|
60
|
+
if (typeof url !== "string" || url.length > MAX_EMBED_URL || !url.startsWith("https://") || /\s/.test(url)) return false;
|
|
61
|
+
try {
|
|
62
|
+
const u = new URL(url);
|
|
63
|
+
return u.protocol === "https:" && !!u.hostname && !u.username && !u.password;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Why `embeds` is invalid, or `null` when it follows §6.4. */
|
|
70
|
+
export function embedsProblem(embeds: unknown): string | null {
|
|
71
|
+
if (!Array.isArray(embeds)) return "embeds must be an array";
|
|
72
|
+
if (embeds.length > MAX_EMBEDS) return `at most ${MAX_EMBEDS} embeds`;
|
|
73
|
+
let total = 0;
|
|
74
|
+
for (const raw of embeds) {
|
|
75
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "every embed is an object";
|
|
76
|
+
const e = raw as Record<string, unknown>;
|
|
77
|
+
const text = (key: string, max: number): number | string => {
|
|
78
|
+
const v = e[key];
|
|
79
|
+
if (v === undefined || v === null) return 0;
|
|
80
|
+
if (typeof v !== "string") return `${key} must be a string`;
|
|
81
|
+
const n = chars(v);
|
|
82
|
+
return n > max ? `${key} has at most ${max} characters` : n;
|
|
83
|
+
};
|
|
84
|
+
const title = text("title", MAX_EMBED_TITLE);
|
|
85
|
+
const description = text("description", MAX_EMBED_DESCRIPTION);
|
|
86
|
+
const footer = text("footer", MAX_EMBED_FOOTER);
|
|
87
|
+
for (const t of [title, description, footer]) if (typeof t === "string") return t;
|
|
88
|
+
total += (title as number) + (description as number) + (footer as number);
|
|
89
|
+
if (e.url !== undefined && e.url !== null && !validEmbedUrl(e.url)) return "url must be an https:// URL without credentials";
|
|
90
|
+
if (e.color !== undefined && e.color !== null && !(Number.isInteger(e.color) && (e.color as number) >= 0 && (e.color as number) <= MAX_EMBED_COLOR)) return "color is an integer from 0 to 16777215";
|
|
91
|
+
if (e.timestamp !== undefined && e.timestamp !== null && !(typeof e.timestamp === "string" && e.timestamp.length <= 64 && RFC3339.test(e.timestamp) && !Number.isNaN(Date.parse(e.timestamp)))) return "timestamp must be an RFC 3339 time";
|
|
92
|
+
let hasFields = false;
|
|
93
|
+
if (e.fields !== undefined && e.fields !== null) {
|
|
94
|
+
if (!Array.isArray(e.fields)) return "fields must be an array";
|
|
95
|
+
if (e.fields.length > MAX_EMBED_FIELDS) return `at most ${MAX_EMBED_FIELDS} fields`;
|
|
96
|
+
for (const f of e.fields) {
|
|
97
|
+
if (!f || typeof f !== "object" || Array.isArray(f)) return "every field is an object";
|
|
98
|
+
const field = f as Record<string, unknown>;
|
|
99
|
+
if (typeof field.name !== "string" || typeof field.value !== "string") return "fields need a name and a value";
|
|
100
|
+
const [n, v] = [chars(field.name), chars(field.value)];
|
|
101
|
+
if (n === 0 || v === 0) return "fields need a name and a value";
|
|
102
|
+
if (n > MAX_EMBED_FIELD_NAME || v > MAX_EMBED_FIELD_VALUE) return "field too long";
|
|
103
|
+
if (field.inline !== undefined && field.inline !== null && typeof field.inline !== "boolean") return "inline must be a boolean";
|
|
104
|
+
total += n + v;
|
|
105
|
+
}
|
|
106
|
+
hasFields = e.fields.length > 0;
|
|
107
|
+
}
|
|
108
|
+
if ((title as number) + (description as number) + (footer as number) === 0 && !hasFields) return "an embed needs a title, a description, fields or a footer";
|
|
109
|
+
}
|
|
110
|
+
return total > MAX_EMBED_CHARS ? `at most ${MAX_EMBED_CHARS} characters in total` : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Sender side: throws {@link EmbedsInvalidError} when a `text` content object carries invalid embeds (or another
|
|
115
|
+
* type carries any). Content without `embeds` passes.
|
|
116
|
+
*/
|
|
117
|
+
export function assertEmbeds(content: Record<string, unknown>): void {
|
|
118
|
+
if (content.embeds === undefined || content.embeds === null) return;
|
|
119
|
+
if (content.type !== "text") throw new EmbedsInvalidError("only text content carries embeds");
|
|
120
|
+
const problem = embedsProblem(content.embeds);
|
|
121
|
+
if (problem) throw new EmbedsInvalidError(problem);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Receiver side: the content without its `embeds` when they are invalid (they are then never shown). */
|
|
125
|
+
export function withValidEmbeds<T extends Record<string, unknown>>(content: T): T {
|
|
126
|
+
if (content.embeds === undefined) return content;
|
|
127
|
+
if (content.type === "text" && embedsProblem(content.embeds) === null) return content;
|
|
128
|
+
const { embeds: _dropped, ...rest } = content;
|
|
129
|
+
return rest as T;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The checked embeds of a content object for rendering (none when invalid); only known fields are kept. */
|
|
133
|
+
export function readEmbeds(content: unknown): MessageEmbed[] {
|
|
134
|
+
if (!content || typeof content !== "object") return [];
|
|
135
|
+
const c = content as Record<string, unknown>;
|
|
136
|
+
if (c.type !== "text" || !Array.isArray(c.embeds) || c.embeds.length === 0 || embedsProblem(c.embeds) !== null) return [];
|
|
137
|
+
return (c.embeds as Record<string, unknown>[]).map((e) => {
|
|
138
|
+
const out: MessageEmbed = {};
|
|
139
|
+
if (typeof e.title === "string" && e.title) out.title = e.title;
|
|
140
|
+
if (typeof e.description === "string" && e.description) out.description = e.description;
|
|
141
|
+
if (typeof e.url === "string") out.url = e.url;
|
|
142
|
+
if (typeof e.color === "number") out.color = e.color;
|
|
143
|
+
if (Array.isArray(e.fields)) out.fields = (e.fields as Record<string, unknown>[]).map((f) => ({ name: f.name as string, value: f.value as string, ...(f.inline === true ? { inline: true } : {}) }));
|
|
144
|
+
if (typeof e.footer === "string" && e.footer) out.footer = e.footer;
|
|
145
|
+
if (typeof e.timestamp === "string") out.timestamp = e.timestamp;
|
|
146
|
+
return out;
|
|
147
|
+
});
|
|
148
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/** Errors of the account flows (ENCRYPTION.md §2.4, §9, §9a) with stable codes for the UI. */
|
|
3
|
+
|
|
4
|
+
export type E2eeErrorCode =
|
|
5
|
+
| "recovery_key_invalid"
|
|
6
|
+
| "recovery_key_wrong"
|
|
7
|
+
| "passkey_wrong"
|
|
8
|
+
| "no_recovery_entry"
|
|
9
|
+
| "no_passkey_entry"
|
|
10
|
+
| "identity_mismatch"
|
|
11
|
+
| "rotation_devices"
|
|
12
|
+
| "locked"
|
|
13
|
+
/** Setup refused: this device holds the account's master secret, or the node has its recovery entry (unlock). */
|
|
14
|
+
| "already_set_up"
|
|
15
|
+
/**
|
|
16
|
+
* The session's device has a key on the node that this device no longer holds (its local key store was lost):
|
|
17
|
+
* a device key is immutable (ENCRYPTION.md §3.1), so encryption needs a new device, i.e. signing in again.
|
|
18
|
+
*/
|
|
19
|
+
| "device_key_lost"
|
|
20
|
+
/**
|
|
21
|
+
* A local secret that must not be reset silently (the key pins, the verified contacts) decrypts but cannot be read:
|
|
22
|
+
* the manager does not start over with empty pins (that would trust every changed key again). The blob stays stored.
|
|
23
|
+
*/
|
|
24
|
+
| "state_unreadable";
|
|
25
|
+
|
|
26
|
+
export class E2eeError extends Error {
|
|
27
|
+
constructor(readonly code: E2eeErrorCode) {
|
|
28
|
+
super(code);
|
|
29
|
+
this.name = "E2eeError";
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* End-to-end encrypted files channels (FILES.md §8, ENCRYPTION.md §5c).
|
|
4
|
+
*
|
|
5
|
+
* Content: every file gets a random 32-byte file key; its content (part `content`) and its client-made thumbnail
|
|
6
|
+
* (part `thumbnail`) are encrypted in chunks of 1 MiB with AES-256-GCM under
|
|
7
|
+
* `part_key = HKDF-SHA256(file_key, salt = 32 zero bytes, info = "tavrn file chunk v1" || 0x00 || part)`, nonce
|
|
8
|
+
* `u32(0) || u64(index)` and `aad = "tavrn file chunk v1" || 0x00 || u8(last)`. Everything here is WebCrypto, so it
|
|
9
|
+
* runs in browsers, workers and Node.js without the WebAssembly module; chunks are processed one at a time
|
|
10
|
+
* (bounded memory: a Blob of encrypted chunks for uploads, a TransformStream for downloads).
|
|
11
|
+
*
|
|
12
|
+
* Records: the name, type and file key travel in an entry record, content format 1 (ENCRYPTION.md §5.2) of
|
|
13
|
+
* `{ type: "files.entry", v: 1, entry, kind, name, mime?, size?, key?, thumbnail? }` under the channel's current
|
|
14
|
+
* epoch. Encrypting goes through {@link MlsManager.encrypt} (forwarded to the tab that owns the MLS state);
|
|
15
|
+
* decrypting stays local ({@link MlsManager.decrypt}).
|
|
16
|
+
*/
|
|
17
|
+
import { b64urlDecode, b64urlEncode } from "@tavrn/sdk";
|
|
18
|
+
import type { DecryptFailure, MlsManager } from "./mls";
|
|
19
|
+
|
|
20
|
+
export const FILE_CHUNK = 1 << 20;
|
|
21
|
+
export const FILE_TAG = 16;
|
|
22
|
+
export const FILE_CHUNK_LABEL = "tavrn file chunk v1";
|
|
23
|
+
export type FilePart = "content" | "thumbnail";
|
|
24
|
+
export const FILE_RECORD_TYPE = "files.entry";
|
|
25
|
+
/** Longest name in characters (FILES.md §2). */
|
|
26
|
+
export const MAX_ENTRY_NAME = 255;
|
|
27
|
+
|
|
28
|
+
const enc = new TextEncoder();
|
|
29
|
+
const subtle = (): SubtleCrypto => globalThis.crypto.subtle;
|
|
30
|
+
const buf = (b: Uint8Array): Uint8Array<ArrayBuffer> => (b.buffer instanceof ArrayBuffer && b.byteOffset === 0 && b.byteLength === b.buffer.byteLength ? (b as Uint8Array<ArrayBuffer>) : new Uint8Array(b));
|
|
31
|
+
|
|
32
|
+
/** A fresh random file key. */
|
|
33
|
+
export function newFileKey(): Uint8Array {
|
|
34
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(32));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The AES-256-GCM key of one part of a file. */
|
|
38
|
+
export async function filePartKey(fileKey: Uint8Array, part: FilePart): Promise<CryptoKey> {
|
|
39
|
+
if (fileKey.length !== 32) throw new RangeError("file keys have 32 bytes");
|
|
40
|
+
const ikm = await subtle().importKey("raw", buf(fileKey), "HKDF", false, ["deriveKey"]);
|
|
41
|
+
const info = new Uint8Array([...enc.encode(FILE_CHUNK_LABEL), 0, ...enc.encode(part)]);
|
|
42
|
+
return subtle().deriveKey({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(32), info }, ikm, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `u32(0) || u64(index)`. */
|
|
46
|
+
export function chunkNonce(index: number): Uint8Array {
|
|
47
|
+
if (!Number.isSafeInteger(index) || index < 0) throw new RangeError("chunk index");
|
|
48
|
+
const n = new Uint8Array(12);
|
|
49
|
+
new DataView(n.buffer).setBigUint64(4, BigInt(index));
|
|
50
|
+
return n;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `"tavrn file chunk v1" || 0x00 || u8(last)`. */
|
|
54
|
+
export function chunkAad(last: boolean): Uint8Array {
|
|
55
|
+
return new Uint8Array([...enc.encode(FILE_CHUNK_LABEL), 0, last ? 1 : 0]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Number of chunks of a part with `n` plaintext bytes (an empty part is one empty chunk). */
|
|
59
|
+
export function chunkCount(n: number): number {
|
|
60
|
+
return Math.max(1, Math.ceil(n / FILE_CHUNK));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Stored (ciphertext) size of a part with `n` plaintext bytes. */
|
|
64
|
+
export function encryptedSize(n: number): number {
|
|
65
|
+
return n + FILE_TAG * chunkCount(n);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Plaintext size of a stored part, or null when no plaintext size encrypts to `stored` bytes. */
|
|
69
|
+
export function plaintextSize(stored: number): number | null {
|
|
70
|
+
if (!Number.isSafeInteger(stored) || stored < FILE_TAG) return null;
|
|
71
|
+
const chunks = Math.max(1, Math.ceil(stored / (FILE_CHUNK + FILE_TAG)));
|
|
72
|
+
const n = stored - FILE_TAG * chunks;
|
|
73
|
+
if (n < 0 || (chunks > 1 && n <= (chunks - 1) * FILE_CHUNK)) return null;
|
|
74
|
+
return n;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function encryptChunk(key: CryptoKey, index: number, last: boolean, data: Uint8Array): Promise<Uint8Array> {
|
|
78
|
+
return new Uint8Array(await subtle().encrypt({ name: "AES-GCM", iv: buf(chunkNonce(index)), additionalData: buf(chunkAad(last)), tagLength: 128 }, key, buf(data)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class FileDecryptError extends Error {
|
|
82
|
+
constructor(message = "file chunk does not decrypt") {
|
|
83
|
+
super(message);
|
|
84
|
+
this.name = "FileDecryptError";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function decryptChunk(key: CryptoKey, index: number, last: boolean, data: Uint8Array): Promise<Uint8Array> {
|
|
89
|
+
try {
|
|
90
|
+
return new Uint8Array(await subtle().decrypt({ name: "AES-GCM", iv: buf(chunkNonce(index)), additionalData: buf(chunkAad(last)), tagLength: 128 }, key, buf(data)));
|
|
91
|
+
} catch {
|
|
92
|
+
throw new FileDecryptError();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Encrypts a whole part held in memory (thumbnails, tests). */
|
|
97
|
+
export async function encryptFileBytes(fileKey: Uint8Array, part: FilePart, plain: Uint8Array): Promise<Uint8Array> {
|
|
98
|
+
const key = await filePartKey(fileKey, part);
|
|
99
|
+
const chunks = chunkCount(plain.length);
|
|
100
|
+
const out = new Uint8Array(encryptedSize(plain.length));
|
|
101
|
+
for (let i = 0; i < chunks; i++) {
|
|
102
|
+
const c = await encryptChunk(key, i, i === chunks - 1, plain.subarray(i * FILE_CHUNK, (i + 1) * FILE_CHUNK));
|
|
103
|
+
out.set(c, i * (FILE_CHUNK + FILE_TAG));
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Decrypts a whole part held in memory. */
|
|
109
|
+
export async function decryptFileBytes(fileKey: Uint8Array, part: FilePart, stored: Uint8Array): Promise<Uint8Array> {
|
|
110
|
+
const n = plaintextSize(stored.length);
|
|
111
|
+
if (n === null) throw new FileDecryptError("invalid ciphertext size");
|
|
112
|
+
const key = await filePartKey(fileKey, part);
|
|
113
|
+
const chunks = chunkCount(n);
|
|
114
|
+
const out = new Uint8Array(n);
|
|
115
|
+
for (let i = 0; i < chunks; i++) {
|
|
116
|
+
const from = i * (FILE_CHUNK + FILE_TAG);
|
|
117
|
+
const p = await decryptChunk(key, i, i === chunks - 1, stored.subarray(from, Math.min(from + FILE_CHUNK + FILE_TAG, stored.length)));
|
|
118
|
+
out.set(p, i * FILE_CHUNK);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Encrypts a file for upload chunk by chunk (one chunk in memory at a time): the ciphertext as a Blob of encrypted
|
|
125
|
+
* chunks, the file key (caller zeroes it after writing the record) and the plaintext size.
|
|
126
|
+
*/
|
|
127
|
+
export async function encryptFileStream(file: Blob, opts: { fileKey?: Uint8Array; part?: FilePart; onProgress?: (fraction: number) => void; signal?: AbortSignal } = {}): Promise<{ blob: Blob; key: Uint8Array; size: number }> {
|
|
128
|
+
const fileKey = opts.fileKey ?? newFileKey();
|
|
129
|
+
const key = await filePartKey(fileKey, opts.part ?? "content");
|
|
130
|
+
const chunks = chunkCount(file.size);
|
|
131
|
+
const parts: BlobPart[] = [];
|
|
132
|
+
for (let i = 0; i < chunks; i++) {
|
|
133
|
+
if (opts.signal?.aborted) throw new DOMException("aborted", "AbortError");
|
|
134
|
+
const plain = new Uint8Array(await file.slice(i * FILE_CHUNK, (i + 1) * FILE_CHUNK).arrayBuffer());
|
|
135
|
+
parts.push(buf(await encryptChunk(key, i, i === chunks - 1, plain)));
|
|
136
|
+
plain.fill(0);
|
|
137
|
+
opts.onProgress?.((i + 1) / chunks);
|
|
138
|
+
}
|
|
139
|
+
return { blob: new Blob(parts, { type: "application/octet-stream" }), key: fileKey, size: file.size };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A stream that decrypts the stored chunks `firstChunk … endChunk` (default: to the end) of a part with `storedSize`
|
|
144
|
+
* ciphertext bytes, whatever the network's chunking. The input starts at the first byte of `firstChunk` (a range
|
|
145
|
+
* request, see {@link ciphertextRange}); a stream that ends early or carries a damaged chunk errors with
|
|
146
|
+
* {@link FileDecryptError}.
|
|
147
|
+
*/
|
|
148
|
+
export function decryptFileChunks(fileKey: Uint8Array, part: FilePart, storedSize: number, firstChunk = 0, endChunk?: number): TransformStream<Uint8Array, Uint8Array> {
|
|
149
|
+
const n = plaintextSize(storedSize);
|
|
150
|
+
const lastIndex = n === null ? -1 : chunkCount(n) - 1;
|
|
151
|
+
const stopAt = Math.min(endChunk ?? lastIndex, lastIndex);
|
|
152
|
+
const keyP = filePartKey(fileKey, part);
|
|
153
|
+
// Received bytes not decrypted yet, as a list (no quadratic copying of small network pieces).
|
|
154
|
+
let parts: Uint8Array[] = [];
|
|
155
|
+
let buffered = 0;
|
|
156
|
+
let index = firstChunk;
|
|
157
|
+
const sizeOf = (i: number) => (i === lastIndex ? storedSize - i * (FILE_CHUNK + FILE_TAG) : FILE_CHUNK + FILE_TAG);
|
|
158
|
+
const take = (size: number): Uint8Array => {
|
|
159
|
+
const out = new Uint8Array(size);
|
|
160
|
+
let at = 0;
|
|
161
|
+
while (at < size) {
|
|
162
|
+
const head = parts[0]!;
|
|
163
|
+
const n = Math.min(head.length, size - at);
|
|
164
|
+
out.set(head.subarray(0, n), at);
|
|
165
|
+
at += n;
|
|
166
|
+
if (n === head.length) parts.shift();
|
|
167
|
+
else parts[0] = head.subarray(n);
|
|
168
|
+
}
|
|
169
|
+
buffered -= size;
|
|
170
|
+
return out;
|
|
171
|
+
};
|
|
172
|
+
const drain = async (controller: TransformStreamDefaultController<Uint8Array>, flush: boolean) => {
|
|
173
|
+
const key = await keyP;
|
|
174
|
+
while (index <= stopAt && buffered >= sizeOf(index)) {
|
|
175
|
+
const size = sizeOf(index);
|
|
176
|
+
controller.enqueue(await decryptChunk(key, index, index === lastIndex, take(size)));
|
|
177
|
+
index++;
|
|
178
|
+
}
|
|
179
|
+
if (flush && buffered > 0) throw new FileDecryptError("trailing bytes");
|
|
180
|
+
};
|
|
181
|
+
return new TransformStream<Uint8Array, Uint8Array>({
|
|
182
|
+
start() {
|
|
183
|
+
if (n === null) throw new FileDecryptError("invalid ciphertext size");
|
|
184
|
+
},
|
|
185
|
+
async transform(chunk, controller) {
|
|
186
|
+
if (chunk.length === 0) return;
|
|
187
|
+
parts.push(chunk);
|
|
188
|
+
buffered += chunk.length;
|
|
189
|
+
await drain(controller, false);
|
|
190
|
+
// Bytes after the last expected chunk are refused at once, not buffered until the stream ends.
|
|
191
|
+
if (index > stopAt && buffered > 0) throw new FileDecryptError("trailing bytes");
|
|
192
|
+
},
|
|
193
|
+
async flush(controller) {
|
|
194
|
+
await drain(controller, true);
|
|
195
|
+
parts = [];
|
|
196
|
+
if (firstChunk <= stopAt && index !== stopAt + 1) throw new FileDecryptError("the file ends early");
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The stored byte range (inclusive) that holds the plaintext bytes `[from, to]` of a part, the first and last chunk
|
|
203
|
+
* index and how many plaintext bytes to skip in the first chunk. `null` when the range is outside the part.
|
|
204
|
+
*/
|
|
205
|
+
export function ciphertextRange(from: number, to: number, storedSize: number): { start: number; end: number; firstChunk: number; lastChunk: number; skip: number } | null {
|
|
206
|
+
const n = plaintextSize(storedSize);
|
|
207
|
+
if (n === null || from < 0 || to < from || from >= n) return null;
|
|
208
|
+
const last = Math.min(to, n - 1);
|
|
209
|
+
const firstChunk = Math.floor(from / FILE_CHUNK);
|
|
210
|
+
const lastChunk = Math.floor(last / FILE_CHUNK);
|
|
211
|
+
return {
|
|
212
|
+
start: firstChunk * (FILE_CHUNK + FILE_TAG),
|
|
213
|
+
end: Math.min((lastChunk + 1) * (FILE_CHUNK + FILE_TAG), storedSize) - 1,
|
|
214
|
+
firstChunk,
|
|
215
|
+
lastChunk,
|
|
216
|
+
skip: from - firstChunk * FILE_CHUNK,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---- Entry records (FILES.md §8.4) --------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
export interface FileThumbnailMeta {
|
|
223
|
+
mime: string;
|
|
224
|
+
width: number;
|
|
225
|
+
height: number;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A checked record. Files carry `mime`, `size` (plaintext bytes) and `key`; folders only the name. */
|
|
229
|
+
export interface FileRecord {
|
|
230
|
+
entry: string;
|
|
231
|
+
kind: "file" | "folder";
|
|
232
|
+
name: string;
|
|
233
|
+
mime: string;
|
|
234
|
+
size: number;
|
|
235
|
+
key: string;
|
|
236
|
+
thumbnail?: FileThumbnailMeta;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** What a record is checked against: the entry as the node lists it. */
|
|
240
|
+
export interface RecordEntry {
|
|
241
|
+
id: string;
|
|
242
|
+
kind: string;
|
|
243
|
+
/** Stored (ciphertext) size. */
|
|
244
|
+
size: number;
|
|
245
|
+
record?: string | null;
|
|
246
|
+
record_epoch?: number | null;
|
|
247
|
+
record_author?: string | null;
|
|
248
|
+
record_device_id?: string | null;
|
|
249
|
+
record_at?: string | null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const MIME = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/;
|
|
253
|
+
const RASTER = /^image\/(png|jpeg|gif|webp|avif|bmp)$/;
|
|
254
|
+
// Bidirectional controls that can disguise an extension (FILES.md §2).
|
|
255
|
+
const BIDI = /[--]/;
|
|
256
|
+
// eslint-disable-next-line no-control-regex
|
|
257
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
258
|
+
|
|
259
|
+
/** The rules of FILES.md §2 for a name: NFC, trimmed, 1–255 characters, no `/`, `\`, controls or bidi controls, not `.`/`..`. */
|
|
260
|
+
export function validEntryName(name: unknown): name is string {
|
|
261
|
+
if (typeof name !== "string") return false;
|
|
262
|
+
const n = name.normalize("NFC").trim();
|
|
263
|
+
if (n !== name || !n || Array.from(n).length > MAX_ENTRY_NAME) return false;
|
|
264
|
+
if (n.includes("/") || n.includes("\\") || CONTROL.test(n) || BIDI.test(n)) return false;
|
|
265
|
+
return n !== "." && n !== "..";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Normalises a name for a record (NFC, trimmed) and checks it; null when it breaks FILES.md §2. */
|
|
269
|
+
export function entryName(raw: string): string | null {
|
|
270
|
+
const n = raw.normalize("NFC").trim();
|
|
271
|
+
return validEntryName(n) ? n : null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The JSON object of a record (written by the uploader or a renaming member). */
|
|
275
|
+
export function fileRecordContent(r: FileRecord): Record<string, unknown> {
|
|
276
|
+
if (!validEntryName(r.name)) throw new RangeError("invalid entry name");
|
|
277
|
+
if (r.kind === "folder") return { type: FILE_RECORD_TYPE, v: 1, entry: r.entry, kind: "folder", name: r.name };
|
|
278
|
+
const out: Record<string, unknown> = { type: FILE_RECORD_TYPE, v: 1, entry: r.entry, kind: "file", name: r.name, mime: MIME.test(r.mime) ? r.mime : "application/octet-stream", size: r.size, key: r.key };
|
|
279
|
+
if (r.thumbnail) out.thumbnail = { mime: r.thumbnail.mime, width: r.thumbnail.width, height: r.thumbnail.height };
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Checks a decrypted record against its entry (FILES.md §8.4); null when the record must be refused. */
|
|
284
|
+
export function parseFileRecord(content: Record<string, unknown>, entry: Pick<RecordEntry, "id" | "kind" | "size">): FileRecord | null {
|
|
285
|
+
if (content.type !== FILE_RECORD_TYPE || content.v !== 1) return null;
|
|
286
|
+
if (content.entry !== entry.id || content.kind !== entry.kind) return null;
|
|
287
|
+
if (!validEntryName(content.name)) return null;
|
|
288
|
+
if (content.kind === "folder") return { entry: entry.id, kind: "folder", name: content.name, mime: "", size: 0, key: "" };
|
|
289
|
+
if (content.kind !== "file") return null;
|
|
290
|
+
const size = content.size;
|
|
291
|
+
if (typeof size !== "number" || !Number.isSafeInteger(size) || size < 0 || encryptedSize(size) !== entry.size) return null;
|
|
292
|
+
if (typeof content.key !== "string") return null;
|
|
293
|
+
try {
|
|
294
|
+
if (b64urlDecode(content.key).length !== 32) return null;
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
const mime = typeof content.mime === "string" && MIME.test(content.mime) ? content.mime : "application/octet-stream";
|
|
299
|
+
const out: FileRecord = { entry: entry.id, kind: "file", name: content.name, mime, size, key: content.key };
|
|
300
|
+
const t = content.thumbnail as Record<string, unknown> | undefined;
|
|
301
|
+
if (t && typeof t === "object" && typeof t.mime === "string" && RASTER.test(t.mime)) {
|
|
302
|
+
const w = Number(t.width);
|
|
303
|
+
const h = Number(t.height);
|
|
304
|
+
if (Number.isSafeInteger(w) && Number.isSafeInteger(h) && w > 0 && h > 0 && w <= 4096 && h <= 4096) out.thumbnail = { mime: t.mime, width: w, height: h };
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Encrypts a record under the channel's current epoch (forwarded to the owning tab like every state change). */
|
|
310
|
+
export async function encryptFileRecord(mls: Pick<MlsManager, "encrypt">, conversationId: string, r: FileRecord): Promise<{ record: string; record_epoch: number }> {
|
|
311
|
+
const { content, epoch } = await mls.encrypt(conversationId, fileRecordContent(r));
|
|
312
|
+
return { record: content, record_epoch: epoch };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export type FileRecordResult = { ok: true; record: FileRecord } | { ok: false; reason: DecryptFailure | "withheld" };
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Decrypts and checks the record of an entry (local). `withheld`: the node serves no record (outside the history
|
|
319
|
+
* window or none written); the other reasons are those of message content.
|
|
320
|
+
*/
|
|
321
|
+
export async function decryptFileRecord(mls: Pick<MlsManager, "decrypt">, conversationId: string, e: RecordEntry): Promise<FileRecordResult> {
|
|
322
|
+
if (!e.record || !e.record_author || !e.record_device_id || typeof e.record_epoch !== "number") return { ok: false, reason: "withheld" };
|
|
323
|
+
const r = await mls.decrypt(conversationId, { id: `files-entry:${e.id}`, author: e.record_author, device_id: e.record_device_id, epoch: e.record_epoch, content: e.record, created_at: e.record_at ?? null });
|
|
324
|
+
if (!r.ok) return r;
|
|
325
|
+
const record = parseFileRecord(r.content, e);
|
|
326
|
+
return record ? { ok: true, record } : { ok: false, reason: "invalid" };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Entries with records as the message samples of history key checks (ENCRYPTION.md §7b, FILES.md §8.7): a files
|
|
331
|
+
* channel has no messages, a shared epoch key is checked against an entry record of that epoch instead.
|
|
332
|
+
*/
|
|
333
|
+
export function recordSamples(entries: RecordEntry[]): { author: string; device_id: string; epoch: number; content: string; encrypted: true }[] {
|
|
334
|
+
const out: { author: string; device_id: string; epoch: number; content: string; encrypted: true }[] = [];
|
|
335
|
+
for (const e of entries) {
|
|
336
|
+
if (e.record && e.record_author && e.record_device_id && typeof e.record_epoch === "number") out.push({ author: e.record_author, device_id: e.record_device_id, epoch: e.record_epoch, content: e.record, encrypted: true });
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The file key of a record as bytes (caller zeroes it). */
|
|
342
|
+
export function recordKey(r: FileRecord): Uint8Array {
|
|
343
|
+
return b64urlDecode(r.key);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export { b64urlEncode as fileKeyText };
|