@telecrypt-io/storage 0.4.0 → 0.5.7
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 +54 -9
- package/dist/TeleCryptIOStorage.d.ts +86 -45
- package/dist/TeleCryptIOStorage.js +1418 -230
- package/dist/core/constants.d.ts +16 -0
- package/dist/core/constants.js +54 -0
- package/dist/core/errors.d.ts +66 -0
- package/dist/core/errors.js +108 -0
- package/dist/core/http.d.ts +24 -0
- package/dist/core/http.js +199 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/oidc.d.ts +47 -69
- package/dist/core/oidc.js +1196 -137
- package/dist/core/operations.d.ts +31 -32
- package/dist/core/operations.js +1270 -219
- package/dist/core/poll.d.ts +2 -1
- package/dist/core/poll.js +59 -3
- package/dist/core/types.d.ts +14 -0
- package/dist/core/validation.d.ts +3 -0
- package/dist/core/validation.js +10 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +11 -9
|
@@ -1,13 +1,430 @@
|
|
|
1
|
-
import { ClientEvent, createClient, EventType, MatrixEvent, SyncState } from "matrix-js-sdk";
|
|
1
|
+
import { ClientEvent, createClient, EventType, MatrixEvent, MatrixError, Preset, RoomCreateTypeField, RoomType, SyncState, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
|
|
2
2
|
import { Method } from "matrix-js-sdk/lib/http-api/method.js";
|
|
3
3
|
import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
|
|
4
4
|
import { encryptAttachment, decryptAttachment } from "matrix-encrypt-attachment";
|
|
5
5
|
import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
|
|
6
|
-
|
|
6
|
+
import { FileTooLargeError, RecoveryAlreadyConfiguredError, RecoveryRestoreError, RecoverySetupAmbiguousError, RecoverySetupError, RecoveryRestoreAmbiguousError, MutationOutcomeUnknownError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, UndecryptableFileError, } from "./core/errors.js";
|
|
7
|
+
import { MAX_MEDIA_FILE_BYTES, MAX_MATRIX_IDENTIFIER_LENGTH, validateCanonicalMatrixUserId, validateMatrixDeviceId, validateMatrixEventId, validateMatrixRoomId, } from "./core/constants.js";
|
|
8
|
+
import { raceWithAbort, readBoundedResponseBody } from "./core/http.js";
|
|
9
|
+
import { validateName } from "./core/validation.js";
|
|
10
|
+
const recoveryMutationQueues = new WeakMap();
|
|
11
|
+
const treeMutationQueues = new WeakMap();
|
|
12
|
+
const TREE_SYNC_TIMEOUT_MS = 15000;
|
|
13
|
+
const MEDIA_TIMEOUT_MS = 30000;
|
|
14
|
+
const MATRIX_HTTP_TIMEOUT_MS = 30000;
|
|
15
|
+
const CLEANUP_TIMEOUT_MS = 30000;
|
|
16
|
+
const RECOVERY_CRYPTO_TIMEOUT_MS = 60000;
|
|
17
|
+
const MAX_MEDIA_REDIRECTS = 5;
|
|
18
|
+
const MAX_MATRIX_TOKEN_LENGTH = 8192;
|
|
19
|
+
const MAX_MIMETYPE_LENGTH = 255;
|
|
20
|
+
const MAX_MATRIX_STATE_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
21
|
+
const MAX_MATRIX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
22
|
+
const MAX_MATRIX_STATE_EVENTS = 10000;
|
|
23
|
+
const MAX_MATRIX_MEMBERS = 10000;
|
|
24
|
+
const MAX_MATRIX_JOINED_ROOMS = 4096;
|
|
25
|
+
function cloneCryptoCallbacks(callbacks) {
|
|
26
|
+
// MatrixClient retains this object for the lifetime of the client. Keep the
|
|
27
|
+
// caller's object immutable: recovery temporarily installs key callbacks on
|
|
28
|
+
// the client-owned copy, never on a shared options object that another
|
|
29
|
+
// client could observe concurrently.
|
|
30
|
+
return callbacks ? { ...callbacks } : {};
|
|
31
|
+
}
|
|
32
|
+
/** Waits for one Matrix mutation to settle without aborting the shared client.
|
|
33
|
+
* If cancellation arrives after the mutation starts, the outcome is explicitly
|
|
34
|
+
* unknown: the caller must reconcile and may safely retry idempotent work. */
|
|
35
|
+
export function withMatrixMutationAbort(_client, operation, signal, operationName = "Matrix mutation") {
|
|
36
|
+
if (!signal)
|
|
37
|
+
return operation();
|
|
38
|
+
if (signal.aborted)
|
|
39
|
+
return Promise.reject(new StorageError("operation cancelled"));
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
let settled = false;
|
|
42
|
+
let started = false;
|
|
43
|
+
let cancelled = false;
|
|
44
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
45
|
+
const finish = (fn) => {
|
|
46
|
+
if (settled)
|
|
47
|
+
return;
|
|
48
|
+
settled = true;
|
|
49
|
+
cleanup();
|
|
50
|
+
fn();
|
|
51
|
+
};
|
|
52
|
+
const onAbort = () => {
|
|
53
|
+
// MatrixClient owns one HTTP abort controller for all of its requests;
|
|
54
|
+
// aborting it here would cancel unrelated work. Wait for this request's
|
|
55
|
+
// own promise, then report the outcome as unknown if it was in flight.
|
|
56
|
+
if (started)
|
|
57
|
+
cancelled = true;
|
|
58
|
+
};
|
|
59
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
60
|
+
let pending;
|
|
61
|
+
started = true;
|
|
62
|
+
try {
|
|
63
|
+
pending = operation();
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
finish(() => reject(cancelled ? new MutationOutcomeUnknownError(operationName) : error));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
pending.then((value) => finish(() => (cancelled ? reject(new MutationOutcomeUnknownError(operationName)) : resolve(value))), (error) => finish(() => reject(cancelled ? new MutationOutcomeUnknownError(operationName) : error)));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
async function readBoundedMatrixResponse(response, maxBytes, signal, abortError) {
|
|
73
|
+
const body = await readBoundedResponseBody(response, maxBytes, signal, {
|
|
74
|
+
abortError,
|
|
75
|
+
deferReaderRelease: true,
|
|
76
|
+
});
|
|
77
|
+
if (body.truncated)
|
|
78
|
+
throw new Error("Matrix response body is too large");
|
|
79
|
+
if (!response.body)
|
|
80
|
+
return response;
|
|
81
|
+
return new Response(body.bytes, {
|
|
82
|
+
status: response.status,
|
|
83
|
+
statusText: response.statusText,
|
|
84
|
+
headers: response.headers,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function isMatrixStateOrMembershipUrl(input) {
|
|
88
|
+
const url = typeof input === "string"
|
|
89
|
+
? new URL(input, "https://matrix.invalid")
|
|
90
|
+
: input instanceof URL
|
|
91
|
+
? input
|
|
92
|
+
: new URL(input.url, "https://matrix.invalid");
|
|
93
|
+
return /\/rooms\/[^/]+\/(?:state|members)(?:\/|$)/.test(url.pathname);
|
|
94
|
+
}
|
|
95
|
+
function boundedMatrixFetch(fetchFn) {
|
|
96
|
+
const wrapped = ((input, init) => {
|
|
97
|
+
const pending = (async () => {
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const externalSignal = init?.signal;
|
|
100
|
+
const abortExternal = () => controller.abort(externalSignal?.reason);
|
|
101
|
+
let timedOut = false;
|
|
102
|
+
const timeout = setTimeout(() => {
|
|
103
|
+
timedOut = true;
|
|
104
|
+
controller.abort(new Error("Matrix request timed out"));
|
|
105
|
+
}, MATRIX_HTTP_TIMEOUT_MS);
|
|
106
|
+
if (externalSignal?.aborted)
|
|
107
|
+
abortExternal();
|
|
108
|
+
else
|
|
109
|
+
externalSignal?.addEventListener("abort", abortExternal, { once: true });
|
|
110
|
+
try {
|
|
111
|
+
const response = await raceWithAbort(fetchFn(input, { ...init, redirect: "manual", signal: controller.signal }), controller.signal, () => undefined, () => (timedOut ? new Error("Matrix request timed out") : new DOMException("The operation was aborted", "AbortError")));
|
|
112
|
+
if (response.redirected ||
|
|
113
|
+
response.type === "opaqueredirect" ||
|
|
114
|
+
(response.status >= 300 && response.status < 400)) {
|
|
115
|
+
response.body?.cancel().catch(() => undefined);
|
|
116
|
+
throw new Error("Matrix redirect rejected");
|
|
117
|
+
}
|
|
118
|
+
return await readBoundedMatrixResponse(response, isMatrixStateOrMembershipUrl(input)
|
|
119
|
+
? MAX_MATRIX_STATE_RESPONSE_BYTES
|
|
120
|
+
: MAX_MATRIX_RESPONSE_BYTES, controller.signal, () => (timedOut ? new Error("Matrix request timed out") : new DOMException("The operation was aborted", "AbortError")));
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
clearTimeout(timeout);
|
|
124
|
+
externalSignal?.removeEventListener("abort", abortExternal);
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
// Preserve fetch-style rejection for callers while observing an immediate
|
|
128
|
+
// abort until the caller has had a chance to attach its own handler.
|
|
129
|
+
void pending.catch(() => undefined);
|
|
130
|
+
return pending;
|
|
131
|
+
});
|
|
132
|
+
return wrapped;
|
|
133
|
+
}
|
|
134
|
+
function matrixRequestTimeout(options) {
|
|
135
|
+
const value = options?.timeoutMs ?? options?.localTimeoutMs ?? MATRIX_HTTP_TIMEOUT_MS;
|
|
136
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
137
|
+
throw new Error("invalid Matrix request timeout");
|
|
138
|
+
return Math.min(value, MATRIX_HTTP_TIMEOUT_MS);
|
|
139
|
+
}
|
|
140
|
+
function matrixRequestSignal(options) {
|
|
141
|
+
return options?.signal ?? options?.abortSignal;
|
|
142
|
+
}
|
|
143
|
+
function requireMatrixHttpTransport(client) {
|
|
144
|
+
const http = client.http;
|
|
145
|
+
if (!http || typeof http.authedRequest !== "function") {
|
|
146
|
+
throw new StorageError("Matrix HTTP transport unavailable");
|
|
147
|
+
}
|
|
148
|
+
return http;
|
|
149
|
+
}
|
|
150
|
+
function isLoopbackHostname(hostname) {
|
|
151
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
152
|
+
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
|
153
|
+
}
|
|
154
|
+
function validateHomeserverUrl(value) {
|
|
155
|
+
if (typeof value !== "string" ||
|
|
156
|
+
value.length === 0 ||
|
|
157
|
+
value.length > 2048 ||
|
|
158
|
+
value.trim() !== value ||
|
|
159
|
+
/[\s\u0000-\u001f\u007f]/.test(value)) {
|
|
160
|
+
throw new Error("invalid Matrix homeserver URL");
|
|
161
|
+
}
|
|
162
|
+
let url;
|
|
163
|
+
try {
|
|
164
|
+
url = new URL(value);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
throw new Error("invalid Matrix homeserver URL");
|
|
168
|
+
}
|
|
169
|
+
if ((url.protocol !== "http:" && url.protocol !== "https:") ||
|
|
170
|
+
(url.protocol === "http:" && !isLoopbackHostname(url.hostname)) ||
|
|
171
|
+
!url.hostname ||
|
|
172
|
+
url.username ||
|
|
173
|
+
url.password ||
|
|
174
|
+
url.search ||
|
|
175
|
+
url.hash) {
|
|
176
|
+
throw new Error("invalid Matrix homeserver URL");
|
|
177
|
+
}
|
|
178
|
+
return url;
|
|
179
|
+
}
|
|
180
|
+
function validateMatrixToken(value) {
|
|
181
|
+
if (typeof value !== "string" ||
|
|
182
|
+
value.length === 0 ||
|
|
183
|
+
value.length > MAX_MATRIX_TOKEN_LENGTH ||
|
|
184
|
+
value.trim() !== value ||
|
|
185
|
+
/[\s\u0000-\u001f\u007f]/.test(value)) {
|
|
186
|
+
throw new Error("invalid Matrix token");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function validateMatrixIdentifier(value, name) {
|
|
190
|
+
if (typeof value !== "string" ||
|
|
191
|
+
value.length === 0 ||
|
|
192
|
+
value.length > MAX_MATRIX_IDENTIFIER_LENGTH ||
|
|
193
|
+
/[\s\u0000-\u001f\u007f]/.test(value)) {
|
|
194
|
+
throw new Error(`invalid Matrix ${name}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function validateFileName(value) {
|
|
198
|
+
validateName(value, "file name");
|
|
199
|
+
}
|
|
200
|
+
function validateMimetype(value) {
|
|
201
|
+
if (typeof value !== "string" ||
|
|
202
|
+
value.length === 0 ||
|
|
203
|
+
value.length > MAX_MIMETYPE_LENGTH ||
|
|
204
|
+
/[\u0000-\u001f\u007f]/.test(value) ||
|
|
205
|
+
!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+\/[!#$%&'*+.^_`|~0-9A-Za-z-]+(?:\s*;\s*[!#$%&'*+.^_`|~0-9A-Za-z-]+=[^;\r\n]+)*$/.test(value)) {
|
|
206
|
+
throw new Error("invalid MIME type");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function isRecord(value) {
|
|
210
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
211
|
+
}
|
|
212
|
+
function validateSecretStorageStatus(value) {
|
|
213
|
+
if (!isRecord(value))
|
|
214
|
+
throw new RecoverySetupAmbiguousError();
|
|
215
|
+
const defaultKeyId = value.defaultKeyId;
|
|
216
|
+
if (defaultKeyId !== null && (typeof defaultKeyId !== "string" || defaultKeyId.length === 0)) {
|
|
217
|
+
throw new RecoverySetupAmbiguousError();
|
|
218
|
+
}
|
|
219
|
+
if (typeof value.ready !== "boolean")
|
|
220
|
+
throw new RecoverySetupAmbiguousError();
|
|
221
|
+
return { defaultKeyId, ready: value.ready };
|
|
222
|
+
}
|
|
223
|
+
function validateBackupVersion(value) {
|
|
224
|
+
if (value !== null && (typeof value !== "string" || value.length === 0)) {
|
|
225
|
+
throw new RecoverySetupAmbiguousError();
|
|
226
|
+
}
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
function validateCrossSigningStatus(value) {
|
|
230
|
+
if (!isRecord(value) || typeof value.publicKeysOnDevice !== "boolean" ||
|
|
231
|
+
typeof value.privateKeysInSecretStorage !== "boolean" ||
|
|
232
|
+
!isRecord(value.privateKeysCachedLocally)) {
|
|
233
|
+
throw new RecoverySetupAmbiguousError();
|
|
234
|
+
}
|
|
235
|
+
const cached = value.privateKeysCachedLocally;
|
|
236
|
+
if (typeof cached.masterKey !== "boolean" ||
|
|
237
|
+
typeof cached.selfSigningKey !== "boolean" ||
|
|
238
|
+
typeof cached.userSigningKey !== "boolean") {
|
|
239
|
+
throw new RecoverySetupAmbiguousError();
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
publicKeysOnDevice: value.publicKeysOnDevice,
|
|
243
|
+
privateKeysCachedLocally: {
|
|
244
|
+
masterKey: cached.masterKey,
|
|
245
|
+
selfSigningKey: cached.selfSigningKey,
|
|
246
|
+
userSigningKey: cached.userSigningKey,
|
|
247
|
+
},
|
|
248
|
+
privateKeysInSecretStorage: value.privateKeysInSecretStorage,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
class RecoveryCryptoTimeoutError extends Error {
|
|
252
|
+
constructor(operation) {
|
|
253
|
+
super(`recovery ${operation} timed out`);
|
|
254
|
+
this.name = "RecoveryCryptoTimeoutError";
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* matrix-js-sdk's CryptoApi methods do not currently accept an AbortSignal.
|
|
259
|
+
* Keep that implementation detail behind one bounded adapter so a hung WASM
|
|
260
|
+
* or transport call cannot outlive the public recovery operation indefinitely.
|
|
261
|
+
* The underlying promise is still observed after cancellation; some crypto
|
|
262
|
+
* calls are one-way and must be treated as ambiguous by their caller.
|
|
263
|
+
*/
|
|
264
|
+
async function withRecoveryCryptoDeadline(operation, signal, operationName) {
|
|
265
|
+
if (signal?.aborted)
|
|
266
|
+
return Promise.reject(new StorageError("operation cancelled"));
|
|
267
|
+
const controller = new AbortController();
|
|
268
|
+
let timedOut = false;
|
|
269
|
+
const timeout = setTimeout(() => {
|
|
270
|
+
timedOut = true;
|
|
271
|
+
controller.abort(new Error("recovery crypto deadline exceeded"));
|
|
272
|
+
}, RECOVERY_CRYPTO_TIMEOUT_MS);
|
|
273
|
+
const abortExternal = () => controller.abort(signal?.reason);
|
|
274
|
+
if (signal?.aborted)
|
|
275
|
+
abortExternal();
|
|
276
|
+
else
|
|
277
|
+
signal?.addEventListener("abort", abortExternal, { once: true });
|
|
278
|
+
const pending = Promise.resolve().then(operation);
|
|
279
|
+
try {
|
|
280
|
+
return await raceWithAbort(pending, controller.signal, () => undefined, () => timedOut
|
|
281
|
+
? new RecoveryCryptoTimeoutError(operationName)
|
|
282
|
+
: new StorageError("operation cancelled"));
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
clearTimeout(timeout);
|
|
286
|
+
signal?.removeEventListener("abort", abortExternal);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function parseMatrixMembersResponse(value) {
|
|
290
|
+
if (!isRecord(value) || !Array.isArray(value.chunk)) {
|
|
291
|
+
throw new Error("invalid Matrix members response");
|
|
292
|
+
}
|
|
293
|
+
if (value.chunk.length > MAX_MATRIX_MEMBERS) {
|
|
294
|
+
throw new Error("Matrix members response is too large");
|
|
295
|
+
}
|
|
296
|
+
return value.chunk.map((entry) => {
|
|
297
|
+
if (!isRecord(entry) ||
|
|
298
|
+
typeof entry.state_key !== "string" ||
|
|
299
|
+
!isRecord(entry.content) ||
|
|
300
|
+
typeof entry.content.membership !== "string") {
|
|
301
|
+
throw new Error("invalid Matrix members response");
|
|
302
|
+
}
|
|
303
|
+
validateMatrixIdentifier(entry.state_key, "member ID");
|
|
304
|
+
validateMatrixIdentifier(entry.content.membership, "membership");
|
|
305
|
+
return {
|
|
306
|
+
state_key: entry.state_key,
|
|
307
|
+
content: { membership: entry.content.membership },
|
|
308
|
+
};
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
function parseMatrixStateResponse(value) {
|
|
312
|
+
if (!Array.isArray(value))
|
|
313
|
+
throw new Error("invalid Matrix room state response");
|
|
314
|
+
if (value.length > MAX_MATRIX_STATE_EVENTS) {
|
|
315
|
+
throw new Error("Matrix room state response is too large");
|
|
316
|
+
}
|
|
317
|
+
return value.map((event) => {
|
|
318
|
+
if (!isRecord(event) ||
|
|
319
|
+
typeof event.type !== "string" ||
|
|
320
|
+
typeof event.state_key !== "string" ||
|
|
321
|
+
!isRecord(event.content)) {
|
|
322
|
+
throw new Error("invalid Matrix room state response");
|
|
323
|
+
}
|
|
324
|
+
validateMatrixIdentifier(event.type, "state event type");
|
|
325
|
+
if (event.state_key.length > MAX_MATRIX_IDENTIFIER_LENGTH ||
|
|
326
|
+
/[\s\u0000-\u001f\u007f]/.test(event.state_key)) {
|
|
327
|
+
throw new Error("invalid Matrix state event key");
|
|
328
|
+
}
|
|
329
|
+
return event;
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
function parseMatrixPowerLevels(value) {
|
|
333
|
+
if (!isRecord(value))
|
|
334
|
+
throw new Error("invalid Matrix power-level response");
|
|
335
|
+
for (const name of ["users_default", "events_default"]) {
|
|
336
|
+
const field = value[name];
|
|
337
|
+
if (field !== undefined && (typeof field !== "number" || !Number.isFinite(field))) {
|
|
338
|
+
throw new Error("invalid Matrix power-level response");
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
for (const name of ["events", "users"]) {
|
|
342
|
+
const field = value[name];
|
|
343
|
+
if (field === undefined)
|
|
344
|
+
continue;
|
|
345
|
+
if (!isRecord(field))
|
|
346
|
+
throw new Error("invalid Matrix power-level response");
|
|
347
|
+
if (Object.keys(field).length > MAX_MATRIX_MEMBERS) {
|
|
348
|
+
throw new Error("Matrix power-level response is too large");
|
|
349
|
+
}
|
|
350
|
+
for (const key of Object.keys(field))
|
|
351
|
+
validateMatrixIdentifier(key, `${name} key`);
|
|
352
|
+
for (const level of Object.values(field)) {
|
|
353
|
+
if (typeof level !== "number" || !Number.isFinite(level)) {
|
|
354
|
+
throw new Error("invalid Matrix power-level response");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return value;
|
|
359
|
+
}
|
|
360
|
+
function parseJoinedRoomsResponse(value) {
|
|
361
|
+
if (!isRecord(value) ||
|
|
362
|
+
!Array.isArray(value.joined_rooms) ||
|
|
363
|
+
value.joined_rooms.length > MAX_MATRIX_JOINED_ROOMS) {
|
|
364
|
+
throw new Error("invalid Matrix joined-room response");
|
|
365
|
+
}
|
|
366
|
+
return value.joined_rooms.map((roomId) => {
|
|
367
|
+
try {
|
|
368
|
+
return validateMatrixRoomId(roomId, "joined-room response");
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
throw new Error("invalid Matrix joined-room response");
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
function isGoneRoomError(error) {
|
|
376
|
+
return (error instanceof MatrixError &&
|
|
377
|
+
(error.errcode === "M_NOT_FOUND" || error.errcode === "M_UNKNOWN"));
|
|
378
|
+
}
|
|
379
|
+
function throwWithCleanupDetail(error, roomId) {
|
|
380
|
+
const cleanup = new RoomCleanupIncompleteError(roomId);
|
|
381
|
+
if (error instanceof Error) {
|
|
382
|
+
try {
|
|
383
|
+
Object.defineProperty(error, "cleanupIncomplete", {
|
|
384
|
+
value: true,
|
|
385
|
+
enumerable: false,
|
|
386
|
+
});
|
|
387
|
+
Object.defineProperty(error, "cleanupError", {
|
|
388
|
+
value: cleanup,
|
|
389
|
+
enumerable: false,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// Preserve the original mutation error even if an unusual Error object
|
|
394
|
+
// is non-extensible; the cleanup detail remains in the cause chain only
|
|
395
|
+
// when it could be attached safely.
|
|
396
|
+
}
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
throw cleanup;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Serializes all tree mutations for one MatrixClient. A single queue is
|
|
403
|
+
* intentional: separate per-room locks can deadlock when a parent mutation
|
|
404
|
+
* needs to inspect or update a child while another operation does the inverse.
|
|
405
|
+
* Each invocation still creates its own room; Matrix has no server-side
|
|
406
|
+
* room-creation idempotency key for MSC3089.
|
|
407
|
+
*/
|
|
408
|
+
export async function withTreeMutation(client, operation, signal) {
|
|
409
|
+
const previous = treeMutationQueues.get(client) ?? Promise.resolve();
|
|
410
|
+
const run = previous.catch(() => undefined).then(() => {
|
|
411
|
+
if (signal?.aborted)
|
|
412
|
+
throw new StorageError("operation cancelled");
|
|
413
|
+
return operation(signal);
|
|
414
|
+
});
|
|
415
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
416
|
+
treeMutationQueues.set(client, settled);
|
|
417
|
+
return run;
|
|
418
|
+
}
|
|
7
419
|
export class TeleCryptIOStorage {
|
|
8
420
|
client;
|
|
421
|
+
decoratedTreeSpaces = new WeakSet();
|
|
9
422
|
constructor(client) {
|
|
10
423
|
this.client = client;
|
|
424
|
+
// Advanced callers may construct a MatrixClient themselves. The SDK does
|
|
425
|
+
// not mutate matrix-js-sdk internals: configure that client with the
|
|
426
|
+
// supported createClient({ fetchFn, localTimeoutMs }) options, or use
|
|
427
|
+
// TeleCryptIOStorage.create()/createFromOidc().
|
|
11
428
|
}
|
|
12
429
|
/** The underlying matrix-js-sdk client (e.g. to stop it, or for advanced/interop use). */
|
|
13
430
|
getClient() {
|
|
@@ -19,12 +436,46 @@ export class TeleCryptIOStorage {
|
|
|
19
436
|
* otherwise list a stale state snapshot immediately after another process
|
|
20
437
|
* changed a file name or branch.
|
|
21
438
|
*/
|
|
22
|
-
async refreshRoomState(roomId) {
|
|
439
|
+
async refreshRoomState(roomId, options) {
|
|
23
440
|
const room = this.client.getRoom(roomId);
|
|
24
441
|
if (!room)
|
|
25
|
-
throw new Error(
|
|
26
|
-
const
|
|
27
|
-
|
|
442
|
+
throw new Error("unknown room");
|
|
443
|
+
const http = requireMatrixHttpTransport(this.client);
|
|
444
|
+
const stateEvents = await http.authedRequest(Method.Get, `/rooms/${encodeURIComponent(roomId)}/state`, undefined, undefined, {
|
|
445
|
+
prefix: ClientPrefix.V3,
|
|
446
|
+
localTimeoutMs: matrixRequestTimeout(options),
|
|
447
|
+
abortSignal: matrixRequestSignal(options),
|
|
448
|
+
});
|
|
449
|
+
const parsedStateEvents = parseMatrixStateResponse(stateEvents);
|
|
450
|
+
if (typeof room.currentState.setStateEvents === "function") {
|
|
451
|
+
const refreshed = parsedStateEvents.map((event) => new MatrixEvent(event));
|
|
452
|
+
// RoomState.setStateEvents overwrites matching tuples, but deliberately
|
|
453
|
+
// retains tuples absent from the input. Replace those stale local
|
|
454
|
+
// events with empty-content state events so a refresh is an exact server
|
|
455
|
+
// snapshot instead of a union with old state. This is important for
|
|
456
|
+
// relation checks: a stale child/parent link must not authorize a
|
|
457
|
+
// destructive operation.
|
|
458
|
+
const currentEvents = room.currentState.events;
|
|
459
|
+
if (currentEvents instanceof Map) {
|
|
460
|
+
const present = new Set(refreshed.map((event) => `${event.getType()}\u0000${event.getStateKey() ?? ""}`));
|
|
461
|
+
for (const [eventType, byStateKey] of currentEvents) {
|
|
462
|
+
for (const [stateKey, event] of byStateKey) {
|
|
463
|
+
if (present.has(`${eventType}\u0000${stateKey}`))
|
|
464
|
+
continue;
|
|
465
|
+
if (refreshed.length >= MAX_MATRIX_STATE_EVENTS) {
|
|
466
|
+
throw new Error("Matrix room state response is too large");
|
|
467
|
+
}
|
|
468
|
+
const original = event.getEffectiveEvent();
|
|
469
|
+
refreshed.push(new MatrixEvent({
|
|
470
|
+
...original,
|
|
471
|
+
content: {},
|
|
472
|
+
unsigned: { ...original.unsigned },
|
|
473
|
+
}));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
room.currentState.setStateEvents(refreshed);
|
|
478
|
+
}
|
|
28
479
|
}
|
|
29
480
|
/**
|
|
30
481
|
* Recommended entry point: builds the MatrixClient with a persistent crypto
|
|
@@ -37,14 +488,21 @@ export class TeleCryptIOStorage {
|
|
|
37
488
|
* construction time (see `cryptoCallbacks` above for why: matrix-js-sdk
|
|
38
489
|
* captures a single callbacks object at MatrixClient construction, so it
|
|
39
490
|
* must exist before `initRustCrypto` runs, not be added afterwards).
|
|
40
|
-
|
|
491
|
+
*/
|
|
41
492
|
static async create(opts) {
|
|
493
|
+
const baseUrl = validateHomeserverUrl(opts.baseUrl).toString();
|
|
494
|
+
validateCanonicalMatrixUserId(opts.userId, opts.serverName);
|
|
495
|
+
validateMatrixDeviceId(opts.deviceId);
|
|
496
|
+
validateMatrixToken(opts.accessToken);
|
|
497
|
+
TeleCryptIOStorage.throwIfAborted(opts.signal);
|
|
42
498
|
const client = createClient({
|
|
43
|
-
baseUrl
|
|
499
|
+
baseUrl,
|
|
44
500
|
userId: opts.userId,
|
|
45
501
|
accessToken: opts.accessToken,
|
|
46
502
|
deviceId: opts.deviceId,
|
|
47
|
-
|
|
503
|
+
fetchFn: boundedMatrixFetch(globalThis.fetch.bind(globalThis)),
|
|
504
|
+
localTimeoutMs: MATRIX_HTTP_TIMEOUT_MS,
|
|
505
|
+
cryptoCallbacks: cloneCryptoCallbacks(opts.cryptoCallbacks),
|
|
48
506
|
});
|
|
49
507
|
return TeleCryptIOStorage.bootstrap(client, opts);
|
|
50
508
|
}
|
|
@@ -52,19 +510,28 @@ export class TeleCryptIOStorage {
|
|
|
52
510
|
* As `create()`, but the resulting `MatrixClient` was authenticated via
|
|
53
511
|
* OIDC/MAS (device-code or authorization-code+PKCE — see
|
|
54
512
|
* `src/core/oidc.ts`) rather than `m.login.password`. If `refreshToken` +
|
|
55
|
-
* `
|
|
513
|
+
* `tokenRefreshFunction` are both given, the client's `tokenRefreshFunction` is
|
|
56
514
|
* wired so an expired access token is transparently refreshed mid-request
|
|
57
515
|
* — same mechanism matrix-js-sdk uses for any refresh-token-capable login.
|
|
58
|
-
|
|
516
|
+
*/
|
|
59
517
|
static async createFromOidc(opts) {
|
|
518
|
+
const baseUrl = validateHomeserverUrl(opts.baseUrl).toString();
|
|
519
|
+
validateCanonicalMatrixUserId(opts.userId, opts.serverName);
|
|
520
|
+
validateMatrixDeviceId(opts.deviceId);
|
|
521
|
+
validateMatrixToken(opts.accessToken);
|
|
522
|
+
if (opts.refreshToken !== undefined)
|
|
523
|
+
validateMatrixToken(opts.refreshToken);
|
|
524
|
+
TeleCryptIOStorage.throwIfAborted(opts.signal);
|
|
60
525
|
const client = createClient({
|
|
61
|
-
baseUrl
|
|
526
|
+
baseUrl,
|
|
62
527
|
userId: opts.userId,
|
|
63
528
|
accessToken: opts.accessToken,
|
|
64
529
|
deviceId: opts.deviceId,
|
|
65
530
|
refreshToken: opts.refreshToken,
|
|
66
531
|
tokenRefreshFunction: opts.refreshToken && opts.tokenRefreshFunction ? opts.tokenRefreshFunction : undefined,
|
|
67
|
-
|
|
532
|
+
fetchFn: boundedMatrixFetch(globalThis.fetch.bind(globalThis)),
|
|
533
|
+
localTimeoutMs: MATRIX_HTTP_TIMEOUT_MS,
|
|
534
|
+
cryptoCallbacks: cloneCryptoCallbacks(opts.cryptoCallbacks),
|
|
68
535
|
});
|
|
69
536
|
return TeleCryptIOStorage.bootstrap(client, opts);
|
|
70
537
|
}
|
|
@@ -73,52 +540,128 @@ export class TeleCryptIOStorage {
|
|
|
73
540
|
static async bootstrap(client, opts) {
|
|
74
541
|
const progress = opts.onProgress ?? (() => { });
|
|
75
542
|
const persistent = opts.persistentCryptoStore ?? true;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
543
|
+
try {
|
|
544
|
+
progress("Loading encryption engine (WASM)…");
|
|
545
|
+
await TeleCryptIOStorage.withTimeout(client.initRustCrypto({
|
|
546
|
+
useIndexedDB: persistent,
|
|
547
|
+
cryptoDatabasePrefix: opts.cryptoDatabasePrefix ?? `telecrypt-io-storage::${opts.userId}::${opts.deviceId}`,
|
|
548
|
+
}), opts.initTimeoutMs ?? 60000, "crypto init", opts.signal);
|
|
549
|
+
progress("Encryption ready — opening secure store…");
|
|
550
|
+
progress("Starting Matrix client…");
|
|
551
|
+
// Mark this before awaiting: startClient can start its sync loop and then
|
|
552
|
+
// reject while reporting an error from the initial request.
|
|
553
|
+
await TeleCryptIOStorage.withTimeout(client.startClient({ initialSyncLimit: opts.initialSyncLimit ?? 10 }), opts.syncTimeoutMs ?? 15000, "client start", opts.signal);
|
|
554
|
+
progress("Waiting for first sync with homeserver…");
|
|
555
|
+
await TeleCryptIOStorage.waitForFirstSync(client, opts.syncTimeoutMs ?? 15000, opts.signal);
|
|
556
|
+
TeleCryptIOStorage.throwIfAborted(opts.signal);
|
|
557
|
+
progress("Sync complete.");
|
|
558
|
+
return new TeleCryptIOStorage(client);
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
// stopClient is idempotent in matrix-js-sdk and is also required after
|
|
562
|
+
// init/timeout failures: crypto startup can have installed listeners or
|
|
563
|
+
// a sync task before the awaited operation rejects.
|
|
564
|
+
try {
|
|
565
|
+
client.stopClient();
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
// Preserve the original bootstrap failure.
|
|
569
|
+
}
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
static throwIfAborted(signal) {
|
|
574
|
+
if (signal?.aborted)
|
|
575
|
+
throw new Error("storage bootstrap cancelled");
|
|
576
|
+
}
|
|
577
|
+
static withTimeout(promise, ms, label, signal) {
|
|
578
|
+
return new Promise((resolve, reject) => {
|
|
579
|
+
let settled = false;
|
|
580
|
+
const cleanup = () => {
|
|
581
|
+
clearTimeout(timer);
|
|
582
|
+
signal?.removeEventListener("abort", onAbort);
|
|
583
|
+
};
|
|
584
|
+
const settle = (fn) => {
|
|
585
|
+
if (settled)
|
|
586
|
+
return;
|
|
587
|
+
settled = true;
|
|
588
|
+
cleanup();
|
|
589
|
+
fn();
|
|
590
|
+
};
|
|
591
|
+
const onAbort = () => settle(() => reject(new Error("storage bootstrap cancelled")));
|
|
592
|
+
const timer = setTimeout(() => {
|
|
593
|
+
settle(() => reject(new Error(`${label} timeout after ${ms}ms`)));
|
|
594
|
+
}, ms);
|
|
595
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
596
|
+
if (signal?.aborted) {
|
|
597
|
+
onAbort();
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
promise.then((value) => settle(() => resolve(value)), (error) => settle(() => reject(error)));
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
/** Bounds a Matrix mutation without cancelling a shared client's other
|
|
604
|
+
* requests. A timeout means the server may have committed the mutation. */
|
|
605
|
+
static withMutationTimeout(promise, ms, operation) {
|
|
606
|
+
return new Promise((resolve, reject) => {
|
|
607
|
+
let settled = false;
|
|
608
|
+
const timer = setTimeout(() => {
|
|
609
|
+
if (settled)
|
|
610
|
+
return;
|
|
611
|
+
settled = true;
|
|
612
|
+
reject(new MutationOutcomeUnknownError(operation));
|
|
613
|
+
}, ms);
|
|
614
|
+
promise.then((value) => {
|
|
615
|
+
if (settled)
|
|
616
|
+
return;
|
|
617
|
+
settled = true;
|
|
618
|
+
clearTimeout(timer);
|
|
619
|
+
resolve(value);
|
|
620
|
+
}, (error) => {
|
|
621
|
+
if (settled)
|
|
622
|
+
return;
|
|
623
|
+
settled = true;
|
|
624
|
+
clearTimeout(timer);
|
|
625
|
+
reject(error);
|
|
626
|
+
});
|
|
627
|
+
});
|
|
96
628
|
}
|
|
97
629
|
/** Wait until the client reaches Prepared/Syncing. Ignores transient
|
|
98
630
|
* pre-sync states instead of rejecting on the first Sync event (which can
|
|
99
631
|
* race `startClient`), and handles the case where sync already completed
|
|
100
632
|
* before the listener was attached. */
|
|
101
|
-
static waitForFirstSync(client, timeoutMs) {
|
|
633
|
+
static waitForFirstSync(client, timeoutMs, signal) {
|
|
634
|
+
TeleCryptIOStorage.throwIfAborted(signal);
|
|
102
635
|
const ready = (state) => state === SyncState.Prepared || state === SyncState.Syncing;
|
|
103
636
|
if (ready(client.getSyncState())) {
|
|
104
637
|
return Promise.resolve();
|
|
105
638
|
}
|
|
106
639
|
return new Promise((resolve, reject) => {
|
|
107
|
-
|
|
108
|
-
if (ready(state)) {
|
|
109
|
-
cleanup();
|
|
110
|
-
resolve();
|
|
111
|
-
}
|
|
112
|
-
};
|
|
640
|
+
let settled = false;
|
|
113
641
|
const cleanup = () => {
|
|
114
642
|
clearTimeout(timeout);
|
|
115
643
|
client.removeListener(ClientEvent.Sync, onSync);
|
|
644
|
+
signal?.removeEventListener("abort", onAbort);
|
|
116
645
|
};
|
|
117
|
-
const
|
|
646
|
+
const settle = (fn) => {
|
|
647
|
+
if (settled)
|
|
648
|
+
return;
|
|
649
|
+
settled = true;
|
|
118
650
|
cleanup();
|
|
119
|
-
|
|
651
|
+
fn();
|
|
652
|
+
};
|
|
653
|
+
const onSync = (state) => {
|
|
654
|
+
if (ready(state))
|
|
655
|
+
settle(() => resolve());
|
|
656
|
+
};
|
|
657
|
+
const onAbort = () => settle(() => reject(new Error("storage bootstrap cancelled")));
|
|
658
|
+
const timeout = setTimeout(() => {
|
|
659
|
+
settle(() => reject(new Error("sync timeout")));
|
|
120
660
|
}, timeoutMs);
|
|
121
661
|
client.on(ClientEvent.Sync, onSync);
|
|
662
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
663
|
+
if (signal?.aborted)
|
|
664
|
+
onAbort();
|
|
122
665
|
});
|
|
123
666
|
}
|
|
124
667
|
requireCrypto() {
|
|
@@ -135,26 +678,49 @@ export class TeleCryptIOStorage {
|
|
|
135
678
|
* failure. Requires that the client was constructed with a cryptoCallbacks
|
|
136
679
|
* object in the first place (see `create()`'s doc comment).
|
|
137
680
|
*/
|
|
138
|
-
async withSecretStorageKey(privateKey, fn) {
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
681
|
+
async withSecretStorageKey(privateKey, fn, signal) {
|
|
682
|
+
const crypto = this.requireCrypto();
|
|
683
|
+
const rawStatus = await withRecoveryCryptoDeadline(() => crypto.getSecretStorageStatus(), signal, "secret-storage status");
|
|
684
|
+
const status = validateSecretStorageStatus(rawStatus);
|
|
685
|
+
// A restore must use the account's current default key. During setup the
|
|
686
|
+
// default does not exist yet, so the SDK permits exactly one requested key
|
|
687
|
+
// and pins that ID for the remainder of the operation. Any ambiguous or
|
|
688
|
+
// unrelated request fails closed instead of handing the same private key
|
|
689
|
+
// to an arbitrary secret-storage slot.
|
|
690
|
+
let selectedKeyId = status.defaultKeyId ?? undefined;
|
|
691
|
+
const callbacks = this.client.cryptoCallbacks;
|
|
692
|
+
const prevGetKey = callbacks.getSecretStorageKey;
|
|
693
|
+
const prevCache = callbacks.cacheSecretStorageKey;
|
|
694
|
+
callbacks.getSecretStorageKey = async ({ keys }) => {
|
|
695
|
+
const keyIds = Object.keys(keys);
|
|
696
|
+
if (keyIds.length === 0)
|
|
697
|
+
return null;
|
|
698
|
+
if (selectedKeyId === undefined) {
|
|
699
|
+
if (keyIds.length !== 1)
|
|
700
|
+
return null;
|
|
701
|
+
selectedKeyId = keyIds[0];
|
|
155
702
|
}
|
|
703
|
+
if (!Object.prototype.hasOwnProperty.call(keys, selectedKeyId))
|
|
704
|
+
return null;
|
|
705
|
+
return [selectedKeyId, privateKey];
|
|
706
|
+
};
|
|
707
|
+
callbacks.cacheSecretStorageKey = () => { };
|
|
708
|
+
try {
|
|
709
|
+
return await fn();
|
|
710
|
+
}
|
|
711
|
+
finally {
|
|
712
|
+
callbacks.getSecretStorageKey = prevGetKey;
|
|
713
|
+
callbacks.cacheSecretStorageKey = prevCache;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
async withRecoveryMutation(operation, signal) {
|
|
717
|
+
const previous = recoveryMutationQueues.get(this.client) ?? Promise.resolve();
|
|
718
|
+
const run = previous.catch(() => undefined).then(() => {
|
|
719
|
+
if (signal?.aborted)
|
|
720
|
+
throw new StorageError("operation cancelled");
|
|
721
|
+
return operation(signal);
|
|
156
722
|
});
|
|
157
|
-
|
|
723
|
+
recoveryMutationQueues.set(this.client, run.then(() => undefined, () => undefined));
|
|
158
724
|
return run;
|
|
159
725
|
}
|
|
160
726
|
/**
|
|
@@ -162,50 +728,231 @@ export class TeleCryptIOStorage {
|
|
|
162
728
|
* key backup, and returns the Recovery Key for the caller to show the user.
|
|
163
729
|
* This is what makes `restoreFromRecoveryKey` on another device possible.
|
|
164
730
|
*/
|
|
165
|
-
async keysSetupRecovery() {
|
|
731
|
+
async keysSetupRecovery(signal) {
|
|
732
|
+
return this.withRecoveryMutation((queuedSignal) => this.performKeysSetupRecovery(queuedSignal), signal);
|
|
733
|
+
}
|
|
734
|
+
async performKeysSetupRecovery(signal) {
|
|
735
|
+
if (signal?.aborted)
|
|
736
|
+
throw new StorageError("operation cancelled");
|
|
166
737
|
const crypto = this.requireCrypto();
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
await
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
738
|
+
// Recovery setup is deliberately one-way for an account. Replacing an
|
|
739
|
+
// existing secret-storage key or backup can strand files on other devices;
|
|
740
|
+
// never disable or reset either resource just to mint a new key.
|
|
741
|
+
let status;
|
|
742
|
+
let backupVersion;
|
|
743
|
+
let crossSigningStatus;
|
|
744
|
+
try {
|
|
745
|
+
const crossSigningPromise = typeof crypto.getCrossSigningStatus === "function"
|
|
746
|
+
? withRecoveryCryptoDeadline(() => crypto.getCrossSigningStatus(), signal, "cross-signing status")
|
|
747
|
+
: Promise.resolve(undefined);
|
|
748
|
+
const [rawStatus, rawBackupVersion, rawCrossSigningStatus] = await Promise.all([
|
|
749
|
+
withRecoveryCryptoDeadline(() => crypto.getSecretStorageStatus(), signal, "secret-storage status"),
|
|
750
|
+
withRecoveryCryptoDeadline(() => crypto.getActiveSessionBackupVersion(), signal, "backup status"),
|
|
751
|
+
crossSigningPromise,
|
|
752
|
+
]);
|
|
753
|
+
const validatedStatus = validateSecretStorageStatus(rawStatus);
|
|
754
|
+
const validatedBackupVersion = validateBackupVersion(rawBackupVersion);
|
|
755
|
+
const validatedCrossSigningStatus = rawCrossSigningStatus === undefined
|
|
756
|
+
? undefined
|
|
757
|
+
: validateCrossSigningStatus(rawCrossSigningStatus);
|
|
758
|
+
status = validatedStatus;
|
|
759
|
+
backupVersion = validatedBackupVersion;
|
|
760
|
+
crossSigningStatus = validatedCrossSigningStatus;
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
// A failed preflight cannot prove that recovery is absent. This is a
|
|
764
|
+
// permanent fail-closed boundary: callers must obtain an independent
|
|
765
|
+
// status result before attempting another one-way setup.
|
|
766
|
+
if (signal?.aborted && error instanceof StorageError) {
|
|
767
|
+
throw error;
|
|
768
|
+
}
|
|
769
|
+
throw new RecoverySetupAmbiguousError();
|
|
770
|
+
}
|
|
771
|
+
if (status.defaultKeyId || status.ready || backupVersion !== null) {
|
|
772
|
+
throw new RecoveryAlreadyConfiguredError();
|
|
773
|
+
}
|
|
774
|
+
if (crossSigningStatus?.publicKeysOnDevice &&
|
|
775
|
+
!crossSigningStatus.privateKeysInSecretStorage &&
|
|
776
|
+
!Object.values(crossSigningStatus.privateKeysCachedLocally).every(Boolean)) {
|
|
777
|
+
throw new RecoverySetupAmbiguousError();
|
|
778
|
+
}
|
|
779
|
+
if (signal?.aborted)
|
|
780
|
+
throw new StorageError("operation cancelled");
|
|
781
|
+
try {
|
|
782
|
+
await withRecoveryCryptoDeadline(() => crypto.bootstrapCrossSigning({
|
|
783
|
+
// No existing verified device to interactively re-authenticate against
|
|
784
|
+
// for this account, so there is nothing to feed into `makeRequest`;
|
|
785
|
+
// matches the working pattern already proven in keys.test.ts.
|
|
786
|
+
authUploadDeviceSigningKeys: async () => undefined,
|
|
787
|
+
}), signal, "cross-signing bootstrap");
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
// Cross-signing setup can commit server-side state before a transport
|
|
791
|
+
// failure is observed. Do not turn that uncertainty into a retry that
|
|
792
|
+
// could replace recovery state.
|
|
793
|
+
throw new RecoverySetupAmbiguousError();
|
|
794
|
+
}
|
|
795
|
+
// CryptoApi mutations do not accept an AbortSignal. Once one has settled,
|
|
796
|
+
// do not cross the next one-way mutation boundary: the caller may already
|
|
797
|
+
// have timed out and would otherwise lose the only key it could display.
|
|
798
|
+
if (signal?.aborted)
|
|
799
|
+
throw new RecoverySetupAmbiguousError();
|
|
800
|
+
let generated;
|
|
801
|
+
try {
|
|
802
|
+
generated = await withRecoveryCryptoDeadline(() => crypto.createRecoveryKeyFromPassphrase(), signal, "recovery-key generation");
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
throw new RecoverySetupAmbiguousError();
|
|
806
|
+
}
|
|
186
807
|
if (!generated.encodedPrivateKey) {
|
|
187
|
-
throw new
|
|
808
|
+
throw new RecoverySetupAmbiguousError();
|
|
188
809
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
810
|
+
if (signal?.aborted)
|
|
811
|
+
throw new RecoverySetupAmbiguousError();
|
|
812
|
+
let generatedWasUsed = false;
|
|
813
|
+
try {
|
|
814
|
+
await this.withSecretStorageKey(generated.privateKey, async () => {
|
|
815
|
+
if (signal?.aborted)
|
|
816
|
+
throw new RecoverySetupAmbiguousError();
|
|
817
|
+
await withRecoveryCryptoDeadline(() => crypto.bootstrapSecretStorage({
|
|
818
|
+
createSecretStorageKey: async () => {
|
|
819
|
+
if (signal?.aborted)
|
|
820
|
+
throw new RecoverySetupAmbiguousError();
|
|
821
|
+
generatedWasUsed = true;
|
|
822
|
+
return generated;
|
|
823
|
+
},
|
|
824
|
+
// The preflight above proves that this account has no active
|
|
825
|
+
// backup. Matrix 42 does not create a backup when this option is
|
|
826
|
+
// omitted; it only stores a key for an already-existing backup.
|
|
827
|
+
// Keep the preflight/postflight ambiguity handling around this
|
|
828
|
+
// one-way operation: an uncertain result must never be retried.
|
|
829
|
+
// The per-client queue closes the local race; callers must inspect
|
|
830
|
+
// the resulting status before attempting setup from another client.
|
|
831
|
+
setupNewKeyBackup: true,
|
|
832
|
+
}), signal, "secret-storage bootstrap");
|
|
833
|
+
if (signal?.aborted)
|
|
834
|
+
throw new RecoverySetupAmbiguousError();
|
|
835
|
+
await withRecoveryCryptoDeadline(() => crypto.checkKeyBackupAndEnable(), signal, "key-backup enable");
|
|
836
|
+
}, signal);
|
|
837
|
+
if (signal?.aborted)
|
|
838
|
+
throw new RecoverySetupAmbiguousError();
|
|
839
|
+
if (!generatedWasUsed)
|
|
840
|
+
throw new RecoverySetupAmbiguousError();
|
|
841
|
+
}
|
|
842
|
+
catch (error) {
|
|
843
|
+
if (error instanceof RecoverySetupAmbiguousError)
|
|
844
|
+
throw error;
|
|
845
|
+
// Both calls can commit server-side state before a transport error. A
|
|
846
|
+
// status probe distinguishes a clean failure from an unsafe retry.
|
|
847
|
+
try {
|
|
848
|
+
const [rawAfterStatus, rawAfterBackup] = await Promise.all([
|
|
849
|
+
withRecoveryCryptoDeadline(() => crypto.getSecretStorageStatus(), signal, "secret-storage status"),
|
|
850
|
+
withRecoveryCryptoDeadline(() => crypto.getActiveSessionBackupVersion(), signal, "backup status"),
|
|
851
|
+
]);
|
|
852
|
+
const afterStatus = validateSecretStorageStatus(rawAfterStatus);
|
|
853
|
+
const afterBackup = validateBackupVersion(rawAfterBackup);
|
|
854
|
+
if (afterStatus.defaultKeyId || afterStatus.ready || afterBackup !== null) {
|
|
855
|
+
throw new RecoverySetupAmbiguousError();
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
catch (error) {
|
|
859
|
+
if (error instanceof RecoverySetupAmbiguousError || error instanceof RecoverySetupError)
|
|
860
|
+
throw error;
|
|
861
|
+
// A failed status probe cannot prove that the one-way operation did
|
|
862
|
+
// not commit. Never turn that uncertainty into an apparently safe
|
|
863
|
+
// retry that could replace account recovery state.
|
|
864
|
+
throw new RecoverySetupAmbiguousError();
|
|
865
|
+
}
|
|
866
|
+
throw new RecoverySetupError();
|
|
867
|
+
}
|
|
868
|
+
try {
|
|
869
|
+
const [rawAfterStatus, rawAfterBackup] = await Promise.all([
|
|
870
|
+
withRecoveryCryptoDeadline(() => crypto.getSecretStorageStatus(), signal, "secret-storage status"),
|
|
871
|
+
withRecoveryCryptoDeadline(() => crypto.getActiveSessionBackupVersion(), signal, "backup status"),
|
|
872
|
+
]);
|
|
873
|
+
const afterStatus = validateSecretStorageStatus(rawAfterStatus);
|
|
874
|
+
const afterBackup = validateBackupVersion(rawAfterBackup);
|
|
875
|
+
if (!afterStatus.ready || afterBackup === null)
|
|
876
|
+
throw new RecoverySetupAmbiguousError();
|
|
877
|
+
}
|
|
878
|
+
catch (error) {
|
|
879
|
+
if (error instanceof RecoverySetupAmbiguousError)
|
|
880
|
+
throw error;
|
|
881
|
+
throw new RecoverySetupAmbiguousError();
|
|
882
|
+
}
|
|
883
|
+
// This is the last boundary before exposing the only recovery credential
|
|
884
|
+
// to the caller. Cancellation here must not return a key that the caller
|
|
885
|
+
// may already have abandoned.
|
|
886
|
+
if (signal?.aborted)
|
|
887
|
+
throw new RecoverySetupAmbiguousError();
|
|
197
888
|
return { recoveryKey: generated.encodedPrivateKey };
|
|
198
889
|
}
|
|
199
890
|
/** Is there an active key backup and a ready secret storage right now? */
|
|
200
|
-
async keysIsRecoverySetup() {
|
|
891
|
+
async keysIsRecoverySetup(signal) {
|
|
892
|
+
return this.withRecoveryMutation((queuedSignal) => {
|
|
893
|
+
if (queuedSignal?.aborted)
|
|
894
|
+
throw new StorageError("operation cancelled");
|
|
895
|
+
return this.performKeysIsRecoverySetup(queuedSignal);
|
|
896
|
+
}, signal);
|
|
897
|
+
}
|
|
898
|
+
async performKeysIsRecoverySetup(signal) {
|
|
201
899
|
const crypto = this.client.getCrypto();
|
|
202
900
|
if (!crypto)
|
|
203
901
|
return false;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
902
|
+
try {
|
|
903
|
+
const [rawStorageStatus, rawBackupVersion] = await Promise.all([
|
|
904
|
+
withRecoveryCryptoDeadline(() => crypto.getSecretStorageStatus(), signal, "secret-storage status"),
|
|
905
|
+
withRecoveryCryptoDeadline(() => crypto.getActiveSessionBackupVersion(), signal, "backup status"),
|
|
906
|
+
]);
|
|
907
|
+
const storageStatus = validateSecretStorageStatus(rawStorageStatus);
|
|
908
|
+
const backupVersion = validateBackupVersion(rawBackupVersion);
|
|
909
|
+
return storageStatus.ready && backupVersion !== null;
|
|
910
|
+
}
|
|
911
|
+
catch {
|
|
912
|
+
throw new RecoverySetupAmbiguousError();
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
/** Returns one consistent recovery topology snapshot without exposing raw crypto callbacks. */
|
|
916
|
+
async keysGetRecoveryStatus(signal) {
|
|
917
|
+
return this.withRecoveryMutation(async (queuedSignal) => {
|
|
918
|
+
if (queuedSignal?.aborted)
|
|
919
|
+
throw new StorageError("operation cancelled");
|
|
920
|
+
const crypto = this.requireCrypto();
|
|
921
|
+
try {
|
|
922
|
+
const [rawSecretStorage, rawBackupVersion, rawCrossSigning] = await Promise.all([
|
|
923
|
+
withRecoveryCryptoDeadline(() => crypto.getSecretStorageStatus(), queuedSignal, "secret-storage status"),
|
|
924
|
+
withRecoveryCryptoDeadline(() => crypto.getActiveSessionBackupVersion(), queuedSignal, "backup status"),
|
|
925
|
+
withRecoveryCryptoDeadline(() => crypto.getCrossSigningStatus(), queuedSignal, "cross-signing status"),
|
|
926
|
+
]);
|
|
927
|
+
const secretStorage = validateSecretStorageStatus(rawSecretStorage);
|
|
928
|
+
const backupVersion = validateBackupVersion(rawBackupVersion);
|
|
929
|
+
const crossSigning = validateCrossSigningStatus(rawCrossSigning);
|
|
930
|
+
const privateKeysCachedLocally = Object.values(crossSigning.privateKeysCachedLocally).every(Boolean);
|
|
931
|
+
const ready = crossSigning.publicKeysOnDevice &&
|
|
932
|
+
(privateKeysCachedLocally || crossSigning.privateKeysInSecretStorage) &&
|
|
933
|
+
secretStorage.ready &&
|
|
934
|
+
backupVersion !== null;
|
|
935
|
+
const unconfigured = !crossSigning.publicKeysOnDevice &&
|
|
936
|
+
!secretStorage.defaultKeyId &&
|
|
937
|
+
backupVersion === null;
|
|
938
|
+
return {
|
|
939
|
+
state: ready ? "ready" : unconfigured ? "unconfigured" : "partial",
|
|
940
|
+
crossSigning: {
|
|
941
|
+
publicKeysOnDevice: crossSigning.publicKeysOnDevice,
|
|
942
|
+
privateKeysCachedLocally,
|
|
943
|
+
privateKeysInSecretStorage: crossSigning.privateKeysInSecretStorage,
|
|
944
|
+
},
|
|
945
|
+
secretStorage: {
|
|
946
|
+
ready: secretStorage.ready,
|
|
947
|
+
defaultKeyId: secretStorage.defaultKeyId,
|
|
948
|
+
},
|
|
949
|
+
backupVersion,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
catch {
|
|
953
|
+
throw new RecoverySetupAmbiguousError();
|
|
954
|
+
}
|
|
955
|
+
}, signal);
|
|
209
956
|
}
|
|
210
957
|
/**
|
|
211
958
|
* On a new device: unlocks secret storage with the Recovery Key, loads the
|
|
@@ -216,157 +963,401 @@ export class TeleCryptIOStorage {
|
|
|
216
963
|
* recovery key is malformed or does not unlock this account's secret
|
|
217
964
|
* storage / key backup.
|
|
218
965
|
*/
|
|
219
|
-
async keysRestoreFromRecoveryKey(recoveryKey) {
|
|
966
|
+
async keysRestoreFromRecoveryKey(recoveryKey, signal) {
|
|
967
|
+
return this.withRecoveryMutation((queuedSignal) => this.performKeysRestoreFromRecoveryKey(recoveryKey, queuedSignal), signal);
|
|
968
|
+
}
|
|
969
|
+
async performKeysRestoreFromRecoveryKey(recoveryKey, signal) {
|
|
970
|
+
if (signal?.aborted)
|
|
971
|
+
throw new StorageError("operation cancelled");
|
|
220
972
|
const crypto = this.requireCrypto();
|
|
973
|
+
if (typeof recoveryKey !== "string" ||
|
|
974
|
+
recoveryKey.length === 0 ||
|
|
975
|
+
recoveryKey.length > 256 ||
|
|
976
|
+
/[^A-Za-z0-9 ]/.test(recoveryKey)) {
|
|
977
|
+
throw new RecoveryRestoreError();
|
|
978
|
+
}
|
|
221
979
|
let privateKey;
|
|
222
980
|
try {
|
|
223
981
|
privateKey = decodeRecoveryKey(recoveryKey);
|
|
224
982
|
}
|
|
225
|
-
catch
|
|
226
|
-
throw new
|
|
983
|
+
catch {
|
|
984
|
+
throw new RecoveryRestoreError();
|
|
227
985
|
}
|
|
986
|
+
if (signal?.aborted)
|
|
987
|
+
throw new StorageError("operation cancelled");
|
|
228
988
|
return this.withSecretStorageKey(privateKey, async () => {
|
|
229
989
|
try {
|
|
230
|
-
await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
|
|
990
|
+
await withRecoveryCryptoDeadline(() => crypto.loadSessionBackupPrivateKeyFromSecretStorage(), signal, "backup-key load");
|
|
991
|
+
if (signal?.aborted)
|
|
992
|
+
throw new RecoveryRestoreAmbiguousError();
|
|
231
993
|
}
|
|
232
|
-
catch (
|
|
233
|
-
|
|
994
|
+
catch (error) {
|
|
995
|
+
if (error instanceof RecoveryRestoreAmbiguousError)
|
|
996
|
+
throw error;
|
|
997
|
+
throw new RecoveryRestoreError();
|
|
234
998
|
}
|
|
235
999
|
try {
|
|
236
|
-
const result = await crypto.restoreKeyBackup();
|
|
1000
|
+
const result = await withRecoveryCryptoDeadline(() => crypto.restoreKeyBackup(), signal, "key-backup restore");
|
|
1001
|
+
if (signal?.aborted)
|
|
1002
|
+
throw new RecoveryRestoreAmbiguousError();
|
|
1003
|
+
if (!Number.isSafeInteger(result.imported) || !Number.isSafeInteger(result.total) || result.imported < 0 || result.total < result.imported) {
|
|
1004
|
+
throw new RecoveryRestoreError();
|
|
1005
|
+
}
|
|
237
1006
|
return { imported: result.imported, total: result.total };
|
|
238
1007
|
}
|
|
239
|
-
catch (
|
|
240
|
-
|
|
1008
|
+
catch (error) {
|
|
1009
|
+
if (error instanceof RecoveryRestoreAmbiguousError)
|
|
1010
|
+
throw error;
|
|
1011
|
+
throw new RecoveryRestoreError();
|
|
241
1012
|
}
|
|
242
|
-
});
|
|
1013
|
+
}, signal);
|
|
243
1014
|
}
|
|
244
1015
|
/** Key-management API: setup, status, and restore for server-side Secure Backup. */
|
|
245
1016
|
get keys() {
|
|
246
1017
|
return {
|
|
247
|
-
setupRecovery: () => this.keysSetupRecovery(),
|
|
248
|
-
isRecoverySetup: () => this.keysIsRecoverySetup(),
|
|
249
|
-
|
|
1018
|
+
setupRecovery: (signal) => this.keysSetupRecovery(signal),
|
|
1019
|
+
isRecoverySetup: (signal) => this.keysIsRecoverySetup(signal),
|
|
1020
|
+
getStatus: (signal) => this.keysGetRecoveryStatus(signal),
|
|
1021
|
+
restoreFromRecoveryKey: (recoveryKey, signal) => this.keysRestoreFromRecoveryKey(recoveryKey, signal),
|
|
250
1022
|
};
|
|
251
1023
|
}
|
|
252
1024
|
/**
|
|
253
1025
|
* Creates a new top-level shared vault (tree space).
|
|
254
1026
|
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
* `createRoom()` HTTP call, then immediately wraps the result by looking
|
|
258
|
-
* the room up in the *local* client store (`new MSC3089TreeSpace(this,
|
|
259
|
-
* roomId)` calls `client.getRoom(roomId)` and throws `Error("Unknown
|
|
260
|
-
* room")` if it's not there yet). The local store is only populated by
|
|
261
|
-
* the client's background sync processing, which is asynchronous and can
|
|
262
|
-
* easily not have caught up in the few milliseconds between the HTTP
|
|
263
|
-
* response and that lookup — especially right after a client has just
|
|
264
|
-
* started. The room really was created server-side by that point (that's
|
|
265
|
-
* how we get "Unknown room" at all); this method waits for the client's
|
|
266
|
-
* own ongoing sync loop to surface it, then wraps it directly via
|
|
267
|
-
* `unstableGetFileTreeSpace`, instead of surfacing a spurious failure for
|
|
268
|
-
* a vault that in fact exists.
|
|
1027
|
+
* Uses Matrix's public `createRoom()` response as the authoritative room
|
|
1028
|
+
* identity, then waits for that exact room to appear in local sync state.
|
|
269
1029
|
*/
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
1030
|
+
treeRoomOptions(name) {
|
|
1031
|
+
validateName(name, "name");
|
|
1032
|
+
const userId = this.client.getUserId();
|
|
1033
|
+
if (!userId)
|
|
1034
|
+
throw new Error("createTree: Matrix client has no user ID");
|
|
1035
|
+
return {
|
|
1036
|
+
name,
|
|
1037
|
+
preset: Preset.PrivateChat,
|
|
1038
|
+
power_level_content_override: {
|
|
1039
|
+
invite: 100,
|
|
1040
|
+
kick: 100,
|
|
1041
|
+
ban: 100,
|
|
1042
|
+
redact: 50,
|
|
1043
|
+
state_default: 50,
|
|
1044
|
+
events_default: 50,
|
|
1045
|
+
users_default: 0,
|
|
1046
|
+
events: {
|
|
1047
|
+
[EventType.RoomPowerLevels]: 100,
|
|
1048
|
+
[EventType.RoomHistoryVisibility]: 100,
|
|
1049
|
+
[EventType.RoomTombstone]: 100,
|
|
1050
|
+
[EventType.RoomEncryption]: 100,
|
|
1051
|
+
[EventType.RoomName]: 50,
|
|
1052
|
+
[EventType.RoomMessage]: 50,
|
|
1053
|
+
[EventType.RoomMessageEncrypted]: 50,
|
|
1054
|
+
[EventType.Sticker]: 50,
|
|
1055
|
+
},
|
|
1056
|
+
users: { [userId]: 100 },
|
|
1057
|
+
},
|
|
1058
|
+
creation_content: { [RoomCreateTypeField]: RoomType.Space },
|
|
1059
|
+
initial_state: [
|
|
1060
|
+
{
|
|
1061
|
+
type: UNSTABLE_MSC3088_PURPOSE.name,
|
|
1062
|
+
state_key: UNSTABLE_MSC3089_TREE_SUBTYPE.name,
|
|
1063
|
+
content: { [UNSTABLE_MSC3088_ENABLED.name]: true },
|
|
1064
|
+
},
|
|
1065
|
+
{
|
|
1066
|
+
type: EventType.RoomEncryption,
|
|
1067
|
+
state_key: "",
|
|
1068
|
+
content: { algorithm: "m.megolm.v1.aes-sha2" },
|
|
1069
|
+
},
|
|
1070
|
+
],
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
async waitForTreeSpace(roomId, operation, signal) {
|
|
1074
|
+
const deadline = Date.now() + TREE_SYNC_TIMEOUT_MS;
|
|
1075
|
+
for (;;) {
|
|
1076
|
+
if (signal?.aborted)
|
|
1077
|
+
throw new StorageError("operation cancelled");
|
|
1078
|
+
try {
|
|
1079
|
+
const tree = this.client.unstableGetFileTreeSpace(roomId);
|
|
1080
|
+
if (tree)
|
|
1081
|
+
return this.decorateTreeSpace(tree);
|
|
1082
|
+
}
|
|
1083
|
+
catch {
|
|
1084
|
+
// The room can be present before its complete state has been applied
|
|
1085
|
+
// to this client's local store. Keep polling the exact returned ID.
|
|
1086
|
+
}
|
|
1087
|
+
if (Date.now() >= deadline) {
|
|
1088
|
+
throw new Error(`${operation}: created room did not become a file tree space in local sync state`);
|
|
1089
|
+
}
|
|
1090
|
+
await new Promise((resolve, reject) => {
|
|
1091
|
+
let timer;
|
|
1092
|
+
const onAbort = () => {
|
|
1093
|
+
clearTimeout(timer);
|
|
1094
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1095
|
+
reject(new StorageError("operation cancelled"));
|
|
294
1096
|
};
|
|
295
|
-
|
|
1097
|
+
timer = setTimeout(() => {
|
|
1098
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1099
|
+
resolve();
|
|
1100
|
+
}, 100);
|
|
1101
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1102
|
+
if (signal?.aborted)
|
|
1103
|
+
onAbort();
|
|
296
1104
|
});
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* MSC3089's built-in createDirectory only sends the two relationship events;
|
|
1109
|
+
* it does not update either room's local state after the homeserver accepts
|
|
1110
|
+
* them. Wrap returned spaces so child creation goes through createSubtree,
|
|
1111
|
+
* which refreshes the exact parent/child state before reporting success.
|
|
1112
|
+
*/
|
|
1113
|
+
decorateTreeSpace(tree) {
|
|
1114
|
+
if (this.decoratedTreeSpaces.has(tree))
|
|
301
1115
|
return tree;
|
|
1116
|
+
this.decoratedTreeSpaces.add(tree);
|
|
1117
|
+
const originalGetDirectories = tree.getDirectories.bind(tree);
|
|
1118
|
+
tree.getDirectories = () => originalGetDirectories().map((child) => this.decorateTreeSpace(child));
|
|
1119
|
+
tree.createDirectory = (name) => this.createSubtree(tree, name);
|
|
1120
|
+
return tree;
|
|
1121
|
+
}
|
|
1122
|
+
/** Cleans up only a room created by this operation and reports incomplete cleanup. */
|
|
1123
|
+
async cleanupCreatedRoom(roomId, _signal) {
|
|
1124
|
+
// Cleanup is compensating work. It must not inherit the caller's already
|
|
1125
|
+
// aborted deadline, or a timed-out create could never even attempt leave.
|
|
1126
|
+
const cleanupSignal = new AbortController().signal;
|
|
1127
|
+
let incomplete = false;
|
|
1128
|
+
let safeToForget = true;
|
|
1129
|
+
try {
|
|
1130
|
+
await TeleCryptIOStorage.withTimeout(withMatrixMutationAbort(this.client, () => this.client.leave(roomId), cleanupSignal), CLEANUP_TIMEOUT_MS, "room leave cleanup", cleanupSignal);
|
|
1131
|
+
}
|
|
1132
|
+
catch (error) {
|
|
1133
|
+
if (!isGoneRoomError(error)) {
|
|
1134
|
+
incomplete = true;
|
|
1135
|
+
// A timed-out or failed leave may still be in flight. Do not race a
|
|
1136
|
+
// forget request against it; the caller must retry cleanup later.
|
|
1137
|
+
safeToForget = false;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
if (safeToForget) {
|
|
1141
|
+
try {
|
|
1142
|
+
await TeleCryptIOStorage.withTimeout(withMatrixMutationAbort(this.client, () => this.client.forget(roomId), cleanupSignal), CLEANUP_TIMEOUT_MS, "room forget cleanup", cleanupSignal);
|
|
1143
|
+
}
|
|
1144
|
+
catch (error) {
|
|
1145
|
+
if (!isGoneRoomError(error))
|
|
1146
|
+
incomplete = true;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (incomplete) {
|
|
1150
|
+
throw new RoomCleanupIncompleteError(roomId);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
async removeRelationLink(roomId, eventType, stateKey, signal) {
|
|
1154
|
+
const room = this.client.getRoom(roomId);
|
|
1155
|
+
const raw = room?.currentState.getStateEvents(eventType, stateKey);
|
|
1156
|
+
const event = Array.isArray(raw) ? raw[0] : raw;
|
|
1157
|
+
if (!event)
|
|
1158
|
+
return false;
|
|
1159
|
+
// This path is used only after an ambiguous send without an event ID. A
|
|
1160
|
+
// state-key match alone is unsafe: another actor may have replaced the
|
|
1161
|
+
// relation while the failed send was in flight. Redact only an event whose
|
|
1162
|
+
// sender is provably this client; otherwise leave it in place and report
|
|
1163
|
+
// incomplete cleanup to the caller.
|
|
1164
|
+
const currentUserId = this.client.getUserId();
|
|
1165
|
+
const getSender = event.getSender;
|
|
1166
|
+
if (!currentUserId || typeof getSender !== "function" || getSender() !== currentUserId)
|
|
1167
|
+
return false;
|
|
1168
|
+
const eventId = validateMatrixEventId(event.getId(), "state event ID");
|
|
1169
|
+
await withMatrixMutationAbort(this.client, () => this.client.redactEvent(roomId, eventId), signal);
|
|
1170
|
+
return true;
|
|
1171
|
+
}
|
|
1172
|
+
async removeChildLink(parentId, childId, signal) {
|
|
1173
|
+
return this.removeRelationLink(parentId, EventType.SpaceChild, childId, signal);
|
|
1174
|
+
}
|
|
1175
|
+
async removeParentLink(childId, parentId, signal) {
|
|
1176
|
+
return this.removeRelationLink(childId, EventType.SpaceParent, parentId, signal);
|
|
1177
|
+
}
|
|
1178
|
+
async createTreeSpace(name, operation, signal) {
|
|
1179
|
+
if (signal?.aborted)
|
|
1180
|
+
throw new StorageError("operation cancelled");
|
|
1181
|
+
const response = await TeleCryptIOStorage.withMutationTimeout(withMatrixMutationAbort(this.client, () => this.client.createRoom(this.treeRoomOptions(name)), signal, `${operation} room creation`), MATRIX_HTTP_TIMEOUT_MS, `${operation} room creation`).catch((error) => {
|
|
1182
|
+
// createRoom is a server-side mutation. A rejected HTTP promise does
|
|
1183
|
+
// not prove that the request was rejected before commit, so every
|
|
1184
|
+
// non-validation failure follows the reconciliation path instead of a
|
|
1185
|
+
// retryable generic error that could orphan a second room.
|
|
1186
|
+
if (error instanceof RoomCreationAmbiguousError)
|
|
1187
|
+
throw error;
|
|
1188
|
+
throw new RoomCreationAmbiguousError(operation);
|
|
1189
|
+
});
|
|
1190
|
+
let roomId;
|
|
1191
|
+
try {
|
|
1192
|
+
roomId = validateMatrixRoomId(response?.room_id, "room creation response");
|
|
1193
|
+
}
|
|
1194
|
+
catch {
|
|
1195
|
+
throw new RoomCreationAmbiguousError(operation);
|
|
1196
|
+
}
|
|
1197
|
+
try {
|
|
1198
|
+
return await this.waitForTreeSpace(roomId, operation, signal);
|
|
302
1199
|
}
|
|
1200
|
+
catch (error) {
|
|
1201
|
+
try {
|
|
1202
|
+
await this.cleanupCreatedRoom(roomId, signal);
|
|
1203
|
+
}
|
|
1204
|
+
catch {
|
|
1205
|
+
throwWithCleanupDetail(error, roomId);
|
|
1206
|
+
}
|
|
1207
|
+
// The room existed even if cleanup happened to succeed. Keep the
|
|
1208
|
+
// caller on the reconciliation path: a retry based on a local timeout
|
|
1209
|
+
// or malformed response must never assume that no room was created.
|
|
1210
|
+
throw new RoomCreationAmbiguousError(operation);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Creates one top-level shared vault per invocation. Same-name calls are
|
|
1215
|
+
* serialized for this MatrixClient, but display names are not identities.
|
|
1216
|
+
*/
|
|
1217
|
+
async createTree(name, signal) {
|
|
1218
|
+
return withTreeMutation(this.client, (queuedSignal) => this.createTreeSpace(name, "createTree", queuedSignal), signal);
|
|
303
1219
|
}
|
|
304
|
-
async listTrees() {
|
|
1220
|
+
async listTrees(signal) {
|
|
1221
|
+
if (signal?.aborted)
|
|
1222
|
+
throw new StorageError("operation cancelled");
|
|
305
1223
|
const rooms = this.client.getRooms();
|
|
1224
|
+
if (rooms.length > MAX_MATRIX_JOINED_ROOMS)
|
|
1225
|
+
throw new StorageError("room list is too large");
|
|
306
1226
|
const trees = [];
|
|
307
1227
|
for (const room of rooms) {
|
|
1228
|
+
if (signal?.aborted)
|
|
1229
|
+
throw new StorageError("operation cancelled");
|
|
308
1230
|
const tree = this.client.unstableGetFileTreeSpace(room.roomId);
|
|
309
|
-
if (tree)
|
|
310
|
-
trees.push(tree);
|
|
311
|
-
}
|
|
1231
|
+
if (tree)
|
|
1232
|
+
trees.push(this.decorateTreeSpace(tree));
|
|
312
1233
|
}
|
|
313
1234
|
return trees;
|
|
314
1235
|
}
|
|
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
|
-
|
|
1236
|
+
/** Creates and links a child tree space using the exact Matrix room ID. */
|
|
1237
|
+
async createSubtree(parent, name, signal) {
|
|
1238
|
+
return withTreeMutation(this.client, async (queuedSignal) => {
|
|
1239
|
+
const effectiveSignal = queuedSignal ?? signal;
|
|
1240
|
+
await this.refreshRoomState(parent.id, { signal: effectiveSignal });
|
|
1241
|
+
const currentParent = this.getTree(parent.id);
|
|
1242
|
+
if (!currentParent)
|
|
1243
|
+
throw new Error("createSubtree: parent is unavailable");
|
|
1244
|
+
const tree = await this.createTreeSpace(name, "createSubtree", effectiveSignal);
|
|
1245
|
+
let parentLinkAttempted = false;
|
|
1246
|
+
let parentLinkEventId;
|
|
1247
|
+
let childLinkAttempted = false;
|
|
1248
|
+
let childLinkEventId;
|
|
1249
|
+
try {
|
|
1250
|
+
const via = this.client.getDomain();
|
|
1251
|
+
if (!via)
|
|
1252
|
+
throw new Error("createSubtree: Matrix client has no server domain");
|
|
1253
|
+
parentLinkAttempted = true;
|
|
1254
|
+
const parentLink = await TeleCryptIOStorage.withMutationTimeout(withMatrixMutationAbort(this.client, () => this.client.sendStateEvent(currentParent.id, EventType.SpaceChild, { via: [via] }, tree.id), effectiveSignal), MATRIX_HTTP_TIMEOUT_MS, "parent link");
|
|
1255
|
+
if (parentLink?.event_id !== undefined) {
|
|
1256
|
+
parentLinkEventId = validateMatrixEventId(parentLink.event_id, "parent link response event ID");
|
|
1257
|
+
}
|
|
1258
|
+
childLinkAttempted = true;
|
|
1259
|
+
const childLink = await TeleCryptIOStorage.withMutationTimeout(withMatrixMutationAbort(this.client, () => this.client.sendStateEvent(tree.id, EventType.SpaceParent, { via: [via] }, currentParent.id), effectiveSignal), MATRIX_HTTP_TIMEOUT_MS, "child link");
|
|
1260
|
+
if (childLink?.event_id !== undefined) {
|
|
1261
|
+
childLinkEventId = validateMatrixEventId(childLink.event_id, "child link response event ID");
|
|
1262
|
+
}
|
|
1263
|
+
// sendStateEvent resolves when the server accepts the event, not when
|
|
1264
|
+
// this client's sync loop has applied it. Refresh both exact rooms so
|
|
1265
|
+
// isTopLevel/getDirectories and recursive invites observe the link.
|
|
1266
|
+
await this.refreshRoomState(currentParent.id, { signal: effectiveSignal });
|
|
1267
|
+
if (this.client.getRoom(tree.id)) {
|
|
1268
|
+
await this.refreshRoomState(tree.id, { signal: effectiveSignal });
|
|
1269
|
+
}
|
|
1270
|
+
return tree;
|
|
1271
|
+
}
|
|
1272
|
+
catch (error) {
|
|
1273
|
+
// Compensation is independent of the caller's cancelled deadline.
|
|
1274
|
+
// Reconcile and retry these idempotent relation updates with a fresh
|
|
1275
|
+
// signal, then report incomplete cleanup if they cannot be verified.
|
|
1276
|
+
const compensationSignal = new AbortController().signal;
|
|
1277
|
+
let rollbackIncomplete = false;
|
|
1278
|
+
// A sendStateEvent rejection is ambiguous: the homeserver may have
|
|
1279
|
+
// committed the event before the client observed a transport error.
|
|
1280
|
+
// Refresh the parent before probing even when no event ID was
|
|
1281
|
+
// returned. A sendStateEvent rejection can race the sync loop, so the
|
|
1282
|
+
// pre-refresh local state is not authoritative. A missing link after
|
|
1283
|
+
// the refresh is the expected outcome for a rejection before commit.
|
|
1284
|
+
if (parentLinkAttempted) {
|
|
1285
|
+
try {
|
|
1286
|
+
if (parentLinkEventId) {
|
|
1287
|
+
await TeleCryptIOStorage.withTimeout(withMatrixMutationAbort(this.client, () => this.client.redactEvent(currentParent.id, parentLinkEventId), compensationSignal), MATRIX_HTTP_TIMEOUT_MS, "parent link rollback", compensationSignal);
|
|
1288
|
+
await this.refreshRoomState(currentParent.id, { signal: compensationSignal });
|
|
1289
|
+
const remaining = this.client
|
|
1290
|
+
.getRoom(currentParent.id)
|
|
1291
|
+
?.currentState.getStateEvents(EventType.SpaceChild, tree.id);
|
|
1292
|
+
if (remaining &&
|
|
1293
|
+
(Array.isArray(remaining) ? remaining : [remaining]).some((event) => {
|
|
1294
|
+
const content = event.getContent?.();
|
|
1295
|
+
return (typeof content === "object" &&
|
|
1296
|
+
content !== null &&
|
|
1297
|
+
!Array.isArray(content) &&
|
|
1298
|
+
Object.keys(content).length > 0);
|
|
1299
|
+
})) {
|
|
1300
|
+
rollbackIncomplete = true;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
else {
|
|
1304
|
+
await this.refreshRoomState(currentParent.id, { signal: compensationSignal });
|
|
1305
|
+
const removed = await this.removeChildLink(currentParent.id, tree.id, compensationSignal);
|
|
1306
|
+
// Without an event ID, a missing link after one refresh does not
|
|
1307
|
+
// prove that a request which timed out will never commit.
|
|
1308
|
+
if (!removed)
|
|
1309
|
+
rollbackIncomplete = true;
|
|
1310
|
+
}
|
|
347
1311
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
return;
|
|
1312
|
+
catch {
|
|
1313
|
+
rollbackIncomplete = true;
|
|
351
1314
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
1315
|
+
}
|
|
1316
|
+
if (childLinkAttempted) {
|
|
1317
|
+
try {
|
|
1318
|
+
if (childLinkEventId) {
|
|
1319
|
+
await TeleCryptIOStorage.withTimeout(withMatrixMutationAbort(this.client, () => this.client.redactEvent(tree.id, childLinkEventId), compensationSignal), MATRIX_HTTP_TIMEOUT_MS, "child link rollback", compensationSignal);
|
|
1320
|
+
await this.refreshRoomState(tree.id, { signal: compensationSignal });
|
|
1321
|
+
const remaining = this.client
|
|
1322
|
+
.getRoom(tree.id)
|
|
1323
|
+
?.currentState.getStateEvents(EventType.SpaceParent, currentParent.id);
|
|
1324
|
+
if (remaining &&
|
|
1325
|
+
(Array.isArray(remaining) ? remaining : [remaining]).some((event) => {
|
|
1326
|
+
const content = event.getContent?.();
|
|
1327
|
+
return (typeof content === "object" &&
|
|
1328
|
+
content !== null &&
|
|
1329
|
+
!Array.isArray(content) &&
|
|
1330
|
+
Object.keys(content).length > 0);
|
|
1331
|
+
})) {
|
|
1332
|
+
rollbackIncomplete = true;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
else {
|
|
1336
|
+
await this.refreshRoomState(tree.id, { signal: compensationSignal });
|
|
1337
|
+
const removed = await this.removeParentLink(tree.id, currentParent.id, compensationSignal);
|
|
1338
|
+
if (!removed)
|
|
1339
|
+
rollbackIncomplete = true;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
catch {
|
|
1343
|
+
rollbackIncomplete = true;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
try {
|
|
1347
|
+
await this.cleanupCreatedRoom(tree.id, effectiveSignal);
|
|
1348
|
+
}
|
|
1349
|
+
catch {
|
|
1350
|
+
throwWithCleanupDetail(error, tree.id);
|
|
1351
|
+
}
|
|
1352
|
+
if (rollbackIncomplete)
|
|
1353
|
+
throwWithCleanupDetail(error, tree.id);
|
|
1354
|
+
throw error;
|
|
359
1355
|
}
|
|
360
|
-
|
|
361
|
-
// could send the space.child / space.parent link events. Send them
|
|
362
|
-
// now so the subfolder is actually reachable from its parent.
|
|
363
|
-
await this.client.sendStateEvent(parent.id, EventType.SpaceChild, { via: [this.client.getDomain()] }, newRoomId);
|
|
364
|
-
await this.client.sendStateEvent(newRoomId, EventType.SpaceParent, { via: [this.client.getDomain()] }, parent.id);
|
|
365
|
-
return tree;
|
|
366
|
-
}
|
|
1356
|
+
}, signal);
|
|
367
1357
|
}
|
|
368
1358
|
getTree(roomId) {
|
|
369
|
-
|
|
1359
|
+
const tree = this.client.unstableGetFileTreeSpace(roomId);
|
|
1360
|
+
return tree ? this.decorateTreeSpace(tree) : null;
|
|
370
1361
|
}
|
|
371
1362
|
/**
|
|
372
1363
|
* Lists the participants of a shared vault (tree space) and their role,
|
|
@@ -385,18 +1376,22 @@ export class TeleCryptIOStorage {
|
|
|
385
1376
|
* `MSC3089TreeSpace.getPermissions()` exactly (userLevel >= adminLevel ->
|
|
386
1377
|
* owner, >= editLevel -> editor, else viewer).
|
|
387
1378
|
*/
|
|
388
|
-
async listMembers(tree) {
|
|
1379
|
+
async listMembers(tree, options) {
|
|
389
1380
|
const roomId = encodeURIComponent(tree.id);
|
|
390
1381
|
const membersPath = `/rooms/${roomId}/members`;
|
|
391
1382
|
const powerLevelsPath = `/rooms/${roomId}/state/m.room.power_levels/`;
|
|
392
|
-
const reqOpts = {
|
|
1383
|
+
const reqOpts = {
|
|
1384
|
+
prefix: ClientPrefix.V3,
|
|
1385
|
+
localTimeoutMs: matrixRequestTimeout(options),
|
|
1386
|
+
abortSignal: matrixRequestSignal(options),
|
|
1387
|
+
};
|
|
1388
|
+
const http = requireMatrixHttpTransport(this.client);
|
|
393
1389
|
const [membersBody, plsRaw] = await Promise.all([
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
.authedRequest(Method.Get, powerLevelsPath, undefined, undefined, reqOpts)
|
|
397
|
-
.catch(() => ({})),
|
|
1390
|
+
http.authedRequest(Method.Get, membersPath, undefined, undefined, reqOpts),
|
|
1391
|
+
http.authedRequest(Method.Get, powerLevelsPath, undefined, undefined, reqOpts),
|
|
398
1392
|
]);
|
|
399
|
-
const
|
|
1393
|
+
const members = parseMatrixMembersResponse(membersBody);
|
|
1394
|
+
const pls = parseMatrixPowerLevels(plsRaw);
|
|
400
1395
|
const viewLevel = pls.users_default ?? 0;
|
|
401
1396
|
const editLevel = pls.events_default ?? 50;
|
|
402
1397
|
const adminLevel = pls.events?.["m.room.power_levels"] ?? 100;
|
|
@@ -408,20 +1403,78 @@ export class TeleCryptIOStorage {
|
|
|
408
1403
|
return "editor";
|
|
409
1404
|
return "viewer";
|
|
410
1405
|
};
|
|
411
|
-
return
|
|
412
|
-
.filter((e) => e.content.membership === "join" ||
|
|
1406
|
+
return members
|
|
1407
|
+
.filter((e) => e.content.membership === "join" ||
|
|
1408
|
+
e.content.membership === "invite" ||
|
|
1409
|
+
e.content.membership === "knock")
|
|
413
1410
|
.map((e) => ({
|
|
414
1411
|
userId: e.state_key,
|
|
415
1412
|
membership: e.content.membership,
|
|
416
1413
|
role: roleFor(e.state_key),
|
|
417
1414
|
}));
|
|
418
1415
|
}
|
|
419
|
-
|
|
1416
|
+
/**
|
|
1417
|
+
* Returns the homeserver's complete joined-room inventory. Local sync room
|
|
1418
|
+
* lists can be truncated by initialSyncLimit and are not an authoritative
|
|
1419
|
+
* basis for deletion safety.
|
|
1420
|
+
*/
|
|
1421
|
+
async listJoinedRoomIds(options) {
|
|
1422
|
+
const body = await requireMatrixHttpTransport(this.client).authedRequest(Method.Get, "/joined_rooms", undefined, undefined, {
|
|
1423
|
+
prefix: ClientPrefix.V3,
|
|
1424
|
+
localTimeoutMs: matrixRequestTimeout(options),
|
|
1425
|
+
abortSignal: matrixRequestSignal(options),
|
|
1426
|
+
});
|
|
1427
|
+
return parseJoinedRoomsResponse(body);
|
|
1428
|
+
}
|
|
1429
|
+
/** Reads one room membership from the homeserver, not the lagging local sync store. */
|
|
1430
|
+
async getRoomMembership(roomId, userId = this.client.getUserId(), options) {
|
|
1431
|
+
if (!userId)
|
|
1432
|
+
return null;
|
|
1433
|
+
const body = await requireMatrixHttpTransport(this.client).authedRequest(Method.Get, `/rooms/${encodeURIComponent(roomId)}/members`, undefined, undefined, {
|
|
1434
|
+
prefix: ClientPrefix.V3,
|
|
1435
|
+
localTimeoutMs: matrixRequestTimeout(options),
|
|
1436
|
+
abortSignal: matrixRequestSignal(options),
|
|
1437
|
+
});
|
|
1438
|
+
const member = parseMatrixMembersResponse(body).find((entry) => entry.state_key === userId);
|
|
1439
|
+
return member?.content.membership ?? null;
|
|
1440
|
+
}
|
|
1441
|
+
async uploadFile(tree, name, data, mimetype, signal) {
|
|
1442
|
+
if (signal?.aborted)
|
|
1443
|
+
throw new StorageError("operation cancelled");
|
|
1444
|
+
validateFileName(name);
|
|
1445
|
+
validateMimetype(mimetype);
|
|
1446
|
+
if (data.byteLength > MAX_MEDIA_FILE_BYTES)
|
|
1447
|
+
throw new FileTooLargeError();
|
|
420
1448
|
const encrypted = await encryptAttachment(data);
|
|
421
|
-
|
|
422
|
-
|
|
1449
|
+
if (signal?.aborted)
|
|
1450
|
+
throw new StorageError("operation cancelled");
|
|
1451
|
+
let response;
|
|
1452
|
+
try {
|
|
1453
|
+
response = await withMatrixMutationAbort(this.client, () => tree.createFile(name, new Uint8Array(encrypted.data), encrypted.info, { info: { mimetype, size: data.byteLength } }), signal, "file upload");
|
|
1454
|
+
}
|
|
1455
|
+
catch (error) {
|
|
1456
|
+
// A transport failure after the encrypted event was submitted cannot be
|
|
1457
|
+
// distinguished from a failure before commit. Do not let a caller retry
|
|
1458
|
+
// this operation and create a duplicate file; only the explicit typed
|
|
1459
|
+
// mutation outcome is safe to propagate unchanged.
|
|
1460
|
+
if (error instanceof MutationOutcomeUnknownError)
|
|
1461
|
+
throw error;
|
|
1462
|
+
throw new MutationOutcomeUnknownError("file upload");
|
|
1463
|
+
}
|
|
1464
|
+
if (signal?.aborted)
|
|
1465
|
+
throw new StorageError("operation cancelled");
|
|
1466
|
+
try {
|
|
1467
|
+
return validateMatrixEventId(response?.event_id, "file upload response event ID");
|
|
1468
|
+
}
|
|
1469
|
+
catch {
|
|
1470
|
+
// An invalid response can still follow a committed event. Treat it as
|
|
1471
|
+
// unknown rather than presenting a retryable generic upload failure.
|
|
1472
|
+
throw new MutationOutcomeUnknownError("file upload");
|
|
1473
|
+
}
|
|
423
1474
|
}
|
|
424
|
-
async downloadFile(branch) {
|
|
1475
|
+
async downloadFile(branch, signal) {
|
|
1476
|
+
if (signal?.aborted)
|
|
1477
|
+
throw new StorageError("operation cancelled");
|
|
425
1478
|
let info;
|
|
426
1479
|
try {
|
|
427
1480
|
({ info } = await branch.getFileInfo());
|
|
@@ -432,32 +1485,167 @@ export class TeleCryptIOStorage {
|
|
|
432
1485
|
// device it hands back a placeholder with no `file` block, so that
|
|
433
1486
|
// read throws an opaque "Cannot read properties of undefined" instead
|
|
434
1487
|
// of a useful error. Surface the real cause.
|
|
435
|
-
throw new
|
|
1488
|
+
throw new UndecryptableFileError();
|
|
436
1489
|
}
|
|
437
1490
|
// Also reject an incomplete placeholder if matrix-js-sdk returns one.
|
|
438
1491
|
if (!info || typeof info.url !== "string") {
|
|
439
|
-
throw new
|
|
1492
|
+
throw new UndecryptableFileError();
|
|
1493
|
+
}
|
|
1494
|
+
const declaredSize = info.size;
|
|
1495
|
+
if (declaredSize !== undefined &&
|
|
1496
|
+
(typeof declaredSize !== "number" ||
|
|
1497
|
+
!Number.isSafeInteger(declaredSize) ||
|
|
1498
|
+
declaredSize < 0 ||
|
|
1499
|
+
declaredSize > MAX_MEDIA_FILE_BYTES)) {
|
|
1500
|
+
throw new FileTooLargeError();
|
|
440
1501
|
}
|
|
441
1502
|
const mxcUrl = info.url;
|
|
442
1503
|
const clientAny = this.client;
|
|
1504
|
+
const accessToken = clientAny.getAccessToken();
|
|
1505
|
+
validateMatrixToken(accessToken);
|
|
1506
|
+
let trustedOrigin;
|
|
1507
|
+
try {
|
|
1508
|
+
trustedOrigin = validateHomeserverUrl(clientAny.getHomeserverUrl()).origin;
|
|
1509
|
+
}
|
|
1510
|
+
catch {
|
|
1511
|
+
throw new Error("failed to build media URL");
|
|
1512
|
+
}
|
|
443
1513
|
const downloadUrl = clientAny.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, false, true, true);
|
|
444
1514
|
if (!downloadUrl)
|
|
445
1515
|
throw new Error("failed to build media URL");
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
1516
|
+
let currentUrl;
|
|
1517
|
+
try {
|
|
1518
|
+
currentUrl = new URL(downloadUrl);
|
|
1519
|
+
}
|
|
1520
|
+
catch {
|
|
1521
|
+
throw new Error("failed to build media URL");
|
|
1522
|
+
}
|
|
1523
|
+
if ((currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") ||
|
|
1524
|
+
currentUrl.username !== "" ||
|
|
1525
|
+
currentUrl.password !== "") {
|
|
1526
|
+
throw new Error("failed to build media URL");
|
|
1527
|
+
}
|
|
1528
|
+
if (currentUrl.origin !== trustedOrigin)
|
|
1529
|
+
throw new Error("media download origin is untrusted");
|
|
1530
|
+
const controller = new AbortController();
|
|
1531
|
+
const timeout = setTimeout(() => controller.abort(), MEDIA_TIMEOUT_MS);
|
|
1532
|
+
const abortExternal = () => controller.abort(signal?.reason);
|
|
1533
|
+
if (signal?.aborted)
|
|
1534
|
+
abortExternal();
|
|
1535
|
+
else
|
|
1536
|
+
signal?.addEventListener("abort", abortExternal, { once: true });
|
|
1537
|
+
let ciphertext;
|
|
1538
|
+
try {
|
|
1539
|
+
for (let redirect = 0;; redirect += 1) {
|
|
1540
|
+
const res = await raceWithAbort(fetch(currentUrl, {
|
|
1541
|
+
redirect: "manual",
|
|
1542
|
+
signal: controller.signal,
|
|
1543
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1544
|
+
}), controller.signal, () => undefined, () => signal?.aborted
|
|
1545
|
+
? new StorageError("operation cancelled")
|
|
1546
|
+
: new Error("media download timed out"));
|
|
1547
|
+
if ([301, 302, 303, 307, 308].includes(res.status)) {
|
|
1548
|
+
try {
|
|
1549
|
+
void res.body?.cancel().catch(() => undefined);
|
|
1550
|
+
}
|
|
1551
|
+
catch {
|
|
1552
|
+
// The redirect response is already being rejected or discarded.
|
|
1553
|
+
}
|
|
1554
|
+
if (redirect >= MAX_MEDIA_REDIRECTS)
|
|
1555
|
+
throw new Error("media download redirect limit exceeded");
|
|
1556
|
+
const location = res.headers.get("location");
|
|
1557
|
+
if (!location)
|
|
1558
|
+
throw new Error("media download redirect missing location");
|
|
1559
|
+
try {
|
|
1560
|
+
currentUrl = new URL(location, currentUrl);
|
|
1561
|
+
}
|
|
1562
|
+
catch {
|
|
1563
|
+
throw new Error("media download redirect is invalid");
|
|
1564
|
+
}
|
|
1565
|
+
if ((currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") ||
|
|
1566
|
+
currentUrl.username !== "" ||
|
|
1567
|
+
currentUrl.password !== "") {
|
|
1568
|
+
throw new Error("media download redirect is invalid");
|
|
1569
|
+
}
|
|
1570
|
+
if (currentUrl.origin !== trustedOrigin) {
|
|
1571
|
+
throw new Error("media download redirect crossed origin");
|
|
1572
|
+
}
|
|
1573
|
+
continue;
|
|
1574
|
+
}
|
|
1575
|
+
if (!res.ok) {
|
|
1576
|
+
try {
|
|
1577
|
+
void res.body?.cancel().catch(() => undefined);
|
|
1578
|
+
}
|
|
1579
|
+
catch {
|
|
1580
|
+
// The error response is already being rejected.
|
|
1581
|
+
}
|
|
1582
|
+
throw new Error(`media download failed: ${res.status}`);
|
|
1583
|
+
}
|
|
1584
|
+
if (!res.body) {
|
|
1585
|
+
// A bodyless Response has no bounded reader. Even a plausible
|
|
1586
|
+
// Content-Length is only advisory and must not authorize an
|
|
1587
|
+
// unbounded arrayBuffer() allocation.
|
|
1588
|
+
throw new Error("media download has no bounded response body");
|
|
1589
|
+
}
|
|
1590
|
+
const body = await readBoundedResponseBody(res, MAX_MEDIA_FILE_BYTES, controller.signal);
|
|
1591
|
+
if (body.truncated)
|
|
1592
|
+
throw new FileTooLargeError();
|
|
1593
|
+
ciphertext = body.bytes.buffer;
|
|
1594
|
+
break;
|
|
1595
|
+
}
|
|
453
1596
|
}
|
|
454
|
-
|
|
455
|
-
|
|
1597
|
+
catch (error) {
|
|
1598
|
+
if (signal?.aborted)
|
|
1599
|
+
throw new StorageError("operation cancelled");
|
|
1600
|
+
if (controller.signal.aborted)
|
|
1601
|
+
throw new Error("media download timed out");
|
|
1602
|
+
if (error instanceof FileTooLargeError)
|
|
1603
|
+
throw error;
|
|
1604
|
+
throw new Error("media download failed");
|
|
1605
|
+
}
|
|
1606
|
+
finally {
|
|
1607
|
+
clearTimeout(timeout);
|
|
1608
|
+
signal?.removeEventListener("abort", abortExternal);
|
|
1609
|
+
}
|
|
1610
|
+
let data;
|
|
1611
|
+
try {
|
|
1612
|
+
// matrix-encrypt-attachment's supported AES-CTR v1/v2 formats are
|
|
1613
|
+
// size-preserving: decryption allocates exactly the ciphertext length.
|
|
1614
|
+
// The ciphertext reader above therefore bounds the allocation before
|
|
1615
|
+
// entering the non-streaming third-party decryptor; the authenticated
|
|
1616
|
+
// event size is also rejected before decryption when present.
|
|
1617
|
+
if (ciphertext.byteLength > MAX_MEDIA_FILE_BYTES)
|
|
1618
|
+
throw new FileTooLargeError();
|
|
1619
|
+
data = await decryptAttachment(ciphertext, info);
|
|
1620
|
+
}
|
|
1621
|
+
catch (error) {
|
|
1622
|
+
if (error instanceof FileTooLargeError)
|
|
1623
|
+
throw error;
|
|
1624
|
+
throw new Error("media decryption failed");
|
|
1625
|
+
}
|
|
1626
|
+
if (signal?.aborted)
|
|
1627
|
+
throw new StorageError("operation cancelled");
|
|
456
1628
|
const eventContent = (await branch.getFileEvent()).getContent();
|
|
457
|
-
|
|
1629
|
+
if (signal?.aborted)
|
|
1630
|
+
throw new StorageError("operation cancelled");
|
|
1631
|
+
if (!isRecord(eventContent))
|
|
1632
|
+
throw new Error("media metadata is invalid");
|
|
1633
|
+
const infoBlock = isRecord(eventContent["info"])
|
|
1634
|
+
? eventContent["info"]
|
|
1635
|
+
: undefined;
|
|
458
1636
|
const mimetype = typeof infoBlock?.["mimetype"] === "string"
|
|
459
1637
|
? infoBlock["mimetype"]
|
|
460
1638
|
: "application/octet-stream";
|
|
1639
|
+
try {
|
|
1640
|
+
validateMimetype(mimetype);
|
|
1641
|
+
}
|
|
1642
|
+
catch {
|
|
1643
|
+
throw new Error("media metadata is invalid");
|
|
1644
|
+
}
|
|
1645
|
+
if (data.byteLength > MAX_MEDIA_FILE_BYTES)
|
|
1646
|
+
throw new FileTooLargeError();
|
|
1647
|
+
if (signal?.aborted)
|
|
1648
|
+
throw new StorageError("operation cancelled");
|
|
461
1649
|
return { data, mimetype };
|
|
462
1650
|
}
|
|
463
1651
|
}
|