@telecrypt-io/storage 0.8.0 → 0.9.2
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 +1 -1
- package/README.md +57 -3
- package/dist/TeleCryptIOStorage.d.ts +32 -3
- package/dist/TeleCryptIOStorage.js +387 -70
- package/dist/core/errors.d.ts +6 -2
- package/dist/core/errors.js +4 -1
- package/dist/core/operations.d.ts +2 -0
- package/dist/core/operations.js +178 -79
- package/dist/core/types.d.ts +1 -0
- package/dist/key-safe.d.ts +62 -0
- package/dist/key-safe.js +349 -0
- package/dist/media.d.ts +3 -0
- package/dist/media.js +10 -0
- package/dist/sharing.d.ts +3 -0
- package/dist/sharing.js +18 -0
- package/package.json +2 -2
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import { ClientEvent, createClient, EventType, MatrixEvent, MatrixError, MsgType, Preset, RoomCreateTypeField, RoomType, SyncState, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
|
|
1
|
+
import { ClientEvent, createClient, EventType, JoinRule, MatrixEvent, MatrixError, MsgType, Preset, RoomCreateTypeField, RoomType, SyncState, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF, 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
|
-
import { FileTooLargeError, RecoveryAlreadyConfiguredError, RecoveryRestoreError, RecoverySetupAmbiguousError, RecoverySetupError, RecoveryRestoreAmbiguousError, MutationOutcomeUnknownError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, UndecryptableFileError, } from "./core/errors.js";
|
|
6
|
+
import { FileTooLargeError, RecoveryAlreadyConfiguredError, RecoveryRestoreError, RecoverySetupAmbiguousError, RecoverySetupError, RecoveryRestoreAmbiguousError, MutationOutcomeUnknownError, MutationPartialError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, UndecryptableFileError, } from "./core/errors.js";
|
|
7
7
|
import { MAX_MEDIA_FILE_BYTES, MAX_MATRIX_IDENTIFIER_BYTES, validateCanonicalMatrixUserId, validateMatrixDeviceId, validateMatrixEventId, validateMatrixRoomId, } from "./core/constants.js";
|
|
8
8
|
import { cancelResponseBody, raceWithAbort, readMediaResponseBody, readResponseBody, ResponseBodyReadError, } from "./core/http.js";
|
|
9
9
|
import { sanitizeDiagnosticText } from "./core/oidc.js";
|
|
10
10
|
import { validateName } from "./core/validation.js";
|
|
11
|
+
import { ConditionTimeoutError, waitForCondition } from "./core/poll.js";
|
|
11
12
|
import { isTreeDeleted } from "./deletion-markers.js";
|
|
13
|
+
import { DecryptionKeySafe } from "./key-safe.js";
|
|
14
|
+
import { prepareReaderHistorySharing } from "./sharing.js";
|
|
15
|
+
import { deleteOwnedMedia } from "./media.js";
|
|
12
16
|
const STORAGE_METADATA_EVENT_TYPE = "io.telecrypt.storage.metadata";
|
|
13
17
|
const STORAGE_METADATA_MSGTYPE = "io.telecrypt.storage.metadata";
|
|
14
18
|
const GENERIC_TREE_NAME = "Encrypted storage";
|
|
@@ -356,7 +360,7 @@ function parseMatrixStateResponse(value) {
|
|
|
356
360
|
function parseMatrixPowerLevels(value) {
|
|
357
361
|
if (!isRecord(value))
|
|
358
362
|
throw new Error("invalid Matrix power-level response");
|
|
359
|
-
for (const name of ["users_default", "events_default"]) {
|
|
363
|
+
for (const name of ["users_default", "events_default", "state_default"]) {
|
|
360
364
|
const field = value[name];
|
|
361
365
|
if (field !== undefined && (typeof field !== "number" || !Number.isFinite(field))) {
|
|
362
366
|
throw new Error("invalid Matrix power-level response");
|
|
@@ -406,12 +410,18 @@ export async function withTreeMutation(client, operation, signal) {
|
|
|
406
410
|
}
|
|
407
411
|
export class TeleCryptIOStorage {
|
|
408
412
|
client;
|
|
413
|
+
syncOptions;
|
|
409
414
|
decoratedTreeSpaces = new WeakSet();
|
|
410
415
|
decoratedFileBranches = new WeakMap();
|
|
411
416
|
treeNames = new Map();
|
|
412
417
|
fileNames = new Map();
|
|
413
|
-
|
|
418
|
+
keySafe;
|
|
419
|
+
syncStarted = false;
|
|
420
|
+
syncInFlight;
|
|
421
|
+
constructor(client, options = {}, syncOptions = {}) {
|
|
414
422
|
this.client = client;
|
|
423
|
+
this.syncOptions = syncOptions;
|
|
424
|
+
this.keySafe = new DecryptionKeySafe(client, (key, operation, signal) => this.withSecretStorageKey(key, operation, signal), options, (signal) => this.startSync(signal));
|
|
415
425
|
// Advanced callers may construct a MatrixClient themselves. The SDK does
|
|
416
426
|
// not mutate matrix-js-sdk internals: configure that client with the
|
|
417
427
|
// supported createClient({ fetchFn, localTimeoutMs }) options, or use
|
|
@@ -421,6 +431,41 @@ export class TeleCryptIOStorage {
|
|
|
421
431
|
getClient() {
|
|
422
432
|
return this.client;
|
|
423
433
|
}
|
|
434
|
+
/** Start Matrix room sync after crypto setup, for example after the current
|
|
435
|
+
* login has checked or restored its Decryption Key Safe. */
|
|
436
|
+
async startSync(signal) {
|
|
437
|
+
if (this.syncStarted)
|
|
438
|
+
return;
|
|
439
|
+
if (this.syncInFlight)
|
|
440
|
+
return this.syncInFlight;
|
|
441
|
+
const start = (async () => {
|
|
442
|
+
try {
|
|
443
|
+
TeleCryptIOStorage.throwIfAborted(signal);
|
|
444
|
+
const timeoutMs = this.syncOptions.syncTimeoutMs ?? 15000;
|
|
445
|
+
this.syncOptions.onProgress?.("Starting Matrix client…");
|
|
446
|
+
await TeleCryptIOStorage.withTimeout(this.client.startClient({ initialSyncLimit: this.syncOptions.initialSyncLimit ?? 10 }), timeoutMs, "client start", signal);
|
|
447
|
+
this.syncOptions.onProgress?.("Waiting for first sync with homeserver…");
|
|
448
|
+
await TeleCryptIOStorage.waitForFirstSync(this.client, timeoutMs, signal);
|
|
449
|
+
TeleCryptIOStorage.throwIfAborted(signal);
|
|
450
|
+
this.syncStarted = true;
|
|
451
|
+
this.syncOptions.onProgress?.("Sync complete.");
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
try {
|
|
455
|
+
this.client.stopClient();
|
|
456
|
+
}
|
|
457
|
+
catch (stopError) {
|
|
458
|
+
throw new AggregateError([error, stopError], "Matrix sync startup and cleanup failed");
|
|
459
|
+
}
|
|
460
|
+
throw error;
|
|
461
|
+
}
|
|
462
|
+
finally {
|
|
463
|
+
this.syncInFlight = undefined;
|
|
464
|
+
}
|
|
465
|
+
})();
|
|
466
|
+
this.syncInFlight = start;
|
|
467
|
+
return start;
|
|
468
|
+
}
|
|
424
469
|
fileNameKey(treeId, fileId) {
|
|
425
470
|
return `${treeId}\u0000${fileId}`;
|
|
426
471
|
}
|
|
@@ -473,8 +518,7 @@ export class TeleCryptIOStorage {
|
|
|
473
518
|
await this.client.sendStateEvent(roomId, STORAGE_METADATA_EVENT_TYPE, { event_id: metadataEventId }, "");
|
|
474
519
|
this.treeNames.set(roomId, name);
|
|
475
520
|
}
|
|
476
|
-
|
|
477
|
-
async getTreeName(roomId, options) {
|
|
521
|
+
async getTreeMetadataEvent(roomId, options) {
|
|
478
522
|
if (options?.signal?.aborted)
|
|
479
523
|
throw new StorageError("operation cancelled");
|
|
480
524
|
await this.refreshRoomState(roomId, options);
|
|
@@ -487,9 +531,22 @@ export class TeleCryptIOStorage {
|
|
|
487
531
|
throw new StorageError("encrypted storage name is unavailable");
|
|
488
532
|
}
|
|
489
533
|
const metadataEventId = validateMatrixEventId(pointerContent.event_id, "storage metadata event ID");
|
|
534
|
+
const event = await this.readEncryptedMetadataMessage(roomId, metadataEventId);
|
|
535
|
+
const content = event.getContent();
|
|
536
|
+
if (!isRecord(content) ||
|
|
537
|
+
content.msgtype !== STORAGE_METADATA_MSGTYPE ||
|
|
538
|
+
typeof content.body !== "string" ||
|
|
539
|
+
Object.hasOwn(content, "file_event_id")) {
|
|
540
|
+
throw new StorageError("encrypted storage name is invalid or unavailable");
|
|
541
|
+
}
|
|
542
|
+
validateName(content.body, "name");
|
|
543
|
+
return event;
|
|
544
|
+
}
|
|
545
|
+
/** Resolves an encrypted vault/folder name through its pointer state event. */
|
|
546
|
+
async getTreeName(roomId, options) {
|
|
490
547
|
let event;
|
|
491
548
|
try {
|
|
492
|
-
event = await this.
|
|
549
|
+
event = await this.getTreeMetadataEvent(roomId, options);
|
|
493
550
|
}
|
|
494
551
|
catch (error) {
|
|
495
552
|
// A new device can list storage before restoring room keys. Keep the
|
|
@@ -499,16 +556,78 @@ export class TeleCryptIOStorage {
|
|
|
499
556
|
throw error;
|
|
500
557
|
}
|
|
501
558
|
const content = event.getContent();
|
|
502
|
-
if (!isRecord(content) ||
|
|
503
|
-
content.msgtype !== STORAGE_METADATA_MSGTYPE ||
|
|
504
|
-
typeof content.body !== "string" ||
|
|
505
|
-
Object.hasOwn(content, "file_event_id")) {
|
|
506
|
-
throw new StorageError("encrypted storage name is invalid or unavailable");
|
|
507
|
-
}
|
|
508
|
-
validateName(content.body, "name");
|
|
509
559
|
this.treeNames.set(roomId, content.body);
|
|
510
560
|
return content.body;
|
|
511
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* Waits for the current encrypted vault/folder name, or file metadata, to
|
|
564
|
+
* have its session in the active native key backup. Checks the exact session
|
|
565
|
+
* even when encryption reuses a key and the backup engine emits no new event.
|
|
566
|
+
*/
|
|
567
|
+
async waitForKeyBackup(treeId, fileId, options) {
|
|
568
|
+
validateMatrixRoomId(treeId);
|
|
569
|
+
if (fileId !== undefined)
|
|
570
|
+
validateMatrixEventId(fileId, "file event ID");
|
|
571
|
+
const timeoutMs = matrixRequestTimeout({ timeoutMs: options?.timeoutMs ?? options?.localTimeoutMs ?? 20_000 });
|
|
572
|
+
const signal = matrixRequestSignal(options);
|
|
573
|
+
const crypto = this.client.getCrypto();
|
|
574
|
+
if (!crypto)
|
|
575
|
+
throw new StorageError("crypto is not initialized");
|
|
576
|
+
let sessionId;
|
|
577
|
+
try {
|
|
578
|
+
await waitForCondition(async (signal) => {
|
|
579
|
+
if (!sessionId) {
|
|
580
|
+
let event;
|
|
581
|
+
if (fileId === undefined) {
|
|
582
|
+
event = await this.getTreeMetadataEvent(treeId, { signal });
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
const metadataId = await this.getFileMetadataEventId(treeId, fileId, { signal });
|
|
586
|
+
if (!metadataId)
|
|
587
|
+
throw new StorageError("encrypted file name is unavailable");
|
|
588
|
+
event = metadataId === fileId
|
|
589
|
+
? await this.getOriginalFileEvent(treeId, fileId)
|
|
590
|
+
: await this.getFileRenameMetadataEvent(treeId, fileId, metadataId);
|
|
591
|
+
}
|
|
592
|
+
const wire = event.getWireContent();
|
|
593
|
+
if (event.isRedacted() || event.isDecryptionFailure() ||
|
|
594
|
+
wire.algorithm !== "m.megolm.v1.aes-sha2" || typeof wire.session_id !== "string" || !wire.session_id) {
|
|
595
|
+
throw new StorageError("encrypted storage session is unavailable");
|
|
596
|
+
}
|
|
597
|
+
sessionId = wire.session_id;
|
|
598
|
+
}
|
|
599
|
+
if (signal?.aborted)
|
|
600
|
+
throw new StorageError("operation cancelled");
|
|
601
|
+
const version = await crypto.getActiveSessionBackupVersion();
|
|
602
|
+
if (!version)
|
|
603
|
+
return null;
|
|
604
|
+
let backup;
|
|
605
|
+
try {
|
|
606
|
+
backup = await this.client.http.authedRequest(Method.Get, `/room_keys/keys/${encodeURIComponent(treeId)}/${encodeURIComponent(sessionId)}`, { version }, undefined, { prefix: ClientPrefix.V3, abortSignal: signal });
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
if (error instanceof MatrixError && error.httpStatus === 404 && error.errcode === "M_NOT_FOUND")
|
|
610
|
+
return null;
|
|
611
|
+
throw error;
|
|
612
|
+
}
|
|
613
|
+
const sessionData = isRecord(backup) ? backup.session_data : undefined;
|
|
614
|
+
if (!isRecord(backup) || !Number.isSafeInteger(backup.first_message_index) ||
|
|
615
|
+
backup.first_message_index < 0 || !isRecord(sessionData) ||
|
|
616
|
+
["ciphertext", "ephemeral", "mac"].some((key) => typeof sessionData[key] !== "string" || !sessionData[key])) {
|
|
617
|
+
throw new StorageError("server key backup session is invalid");
|
|
618
|
+
}
|
|
619
|
+
return true;
|
|
620
|
+
}, { timeoutMs, signal });
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
if (signal?.aborted)
|
|
624
|
+
throw new StorageError("operation cancelled", { cause: error });
|
|
625
|
+
if (error instanceof ConditionTimeoutError) {
|
|
626
|
+
throw new StorageError("key backup did not settle before timeout; retry key backup", { cause: error });
|
|
627
|
+
}
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
512
631
|
async writeFileName(treeId, fileId, name) {
|
|
513
632
|
validateName(name, "file name");
|
|
514
633
|
validateMatrixEventId(fileId, "file event ID");
|
|
@@ -619,30 +738,128 @@ export class TeleCryptIOStorage {
|
|
|
619
738
|
}
|
|
620
739
|
return event;
|
|
621
740
|
}
|
|
622
|
-
async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent) {
|
|
741
|
+
async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent, signal) {
|
|
623
742
|
validateFileName(name);
|
|
743
|
+
// Authoritative room refresh also repairs legacy owner permissions before
|
|
744
|
+
// publishing new encrypted content to an existing vault.
|
|
745
|
+
await this.refreshRoomState(treeId, { signal });
|
|
746
|
+
if (signal?.aborted)
|
|
747
|
+
throw new StorageError("operation cancelled");
|
|
624
748
|
const bytes = encryptedContents instanceof Uint8Array
|
|
625
749
|
? encryptedContents
|
|
626
750
|
: new Uint8Array(encryptedContents);
|
|
627
751
|
const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
752
|
+
let mediaId;
|
|
753
|
+
let fileId;
|
|
754
|
+
try {
|
|
755
|
+
const uploaded = await this.client.uploadContent(body, { includeFilename: false });
|
|
756
|
+
if (typeof uploaded.content_uri !== "string" || uploaded.content_uri.length === 0) {
|
|
757
|
+
throw new StorageError("encrypted media upload returned no content URI");
|
|
758
|
+
}
|
|
759
|
+
mediaId = uploaded.content_uri;
|
|
760
|
+
if (signal?.aborted)
|
|
761
|
+
throw new StorageError("operation cancelled");
|
|
762
|
+
const fileContent = {
|
|
763
|
+
...additionalContent,
|
|
764
|
+
msgtype: MsgType.File,
|
|
765
|
+
body: name,
|
|
766
|
+
url: mediaId,
|
|
767
|
+
file: { ...info, url: mediaId },
|
|
768
|
+
[UNSTABLE_MSC3089_LEAF.name]: {},
|
|
769
|
+
};
|
|
770
|
+
const sent = await this.client.sendMessage(treeId, fileContent);
|
|
771
|
+
fileId = validateMatrixEventId(sent?.event_id, "file upload response event ID");
|
|
772
|
+
if (signal?.aborted)
|
|
773
|
+
throw new StorageError("operation cancelled");
|
|
774
|
+
await this.client.sendStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, { active: true, metadata_event_id: fileId }, fileId);
|
|
775
|
+
if (signal?.aborted)
|
|
776
|
+
throw new StorageError("operation cancelled");
|
|
777
|
+
this.fileNames.set(this.fileNameKey(treeId, fileId), name);
|
|
778
|
+
return { event_id: fileId };
|
|
779
|
+
}
|
|
780
|
+
catch (error) {
|
|
781
|
+
const rejected = error instanceof MatrixError &&
|
|
782
|
+
error.httpStatus !== undefined && error.httpStatus >= 400 && error.httpStatus < 500 && error.httpStatus !== 408;
|
|
783
|
+
if (!mediaId) {
|
|
784
|
+
if (rejected)
|
|
785
|
+
throw error;
|
|
786
|
+
throw new MutationOutcomeUnknownError("file upload", { cause: error });
|
|
787
|
+
}
|
|
788
|
+
let cause = error;
|
|
789
|
+
if (rejected) {
|
|
790
|
+
try {
|
|
791
|
+
await this.cleanupFileUpload(treeId, mediaId, fileId);
|
|
792
|
+
}
|
|
793
|
+
catch (cleanupError) {
|
|
794
|
+
cause = new AggregateError([error, cleanupError], "file upload failed and cleanup is incomplete");
|
|
795
|
+
}
|
|
796
|
+
if (cause === error) {
|
|
797
|
+
throw new StorageError("file upload was rejected; uploaded media and attachment were removed", { cause: error });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
throw new MutationPartialError("file upload", fileId ? [mediaId, fileId] : [mediaId], `upload outcome needs reconciliation in ${treeId}; keep these media and attachment IDs and use cleanupFileUpload before uploading again`, { cause, treeId });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Discards a partial upload using its MutationPartialError.completedIds:
|
|
805
|
+
* [mediaId, fileId?]. Explicit cleanup also covers an unacknowledged listing.
|
|
806
|
+
* Keep the same IDs after failure and retry this operation, not the upload.
|
|
807
|
+
*/
|
|
808
|
+
async cleanupFileUpload(treeId, mediaId, fileId, options) {
|
|
809
|
+
validateMatrixRoomId(treeId);
|
|
810
|
+
if (!/^mxc:\/\/[^/]+\/[^/?#]+$/u.test(mediaId))
|
|
811
|
+
throw new StorageError("invalid media identifier");
|
|
812
|
+
if (fileId !== undefined)
|
|
813
|
+
validateMatrixEventId(fileId, "file upload event ID");
|
|
814
|
+
const controller = new AbortController();
|
|
815
|
+
const timeout = setTimeout(() => controller.abort(), matrixRequestTimeout(options));
|
|
816
|
+
const abort = () => controller.abort(options?.signal?.reason);
|
|
817
|
+
if (options?.signal?.aborted)
|
|
818
|
+
abort();
|
|
819
|
+
else
|
|
820
|
+
options?.signal?.addEventListener("abort", abort, { once: true });
|
|
821
|
+
try {
|
|
822
|
+
await raceWithAbort((async () => {
|
|
823
|
+
if (controller.signal.aborted)
|
|
824
|
+
throw new StorageError("operation cancelled");
|
|
825
|
+
let attachment;
|
|
826
|
+
let listing = null;
|
|
827
|
+
if (fileId) {
|
|
828
|
+
const raw = await this.client.fetchRoomEvent(treeId, fileId);
|
|
829
|
+
attachment = this.client.getEventMapper()(raw);
|
|
830
|
+
await this.client.decryptEventIfNeeded(attachment);
|
|
831
|
+
const content = attachment.getContent();
|
|
832
|
+
if (attachment.getId() !== fileId || attachment.getRoomId() !== treeId ||
|
|
833
|
+
attachment.getSender() !== this.client.getUserId() ||
|
|
834
|
+
(!attachment.isRedacted() && (attachment.getWireType() !== EventType.RoomMessageEncrypted ||
|
|
835
|
+
attachment.getType() !== EventType.RoomMessage || content.msgtype !== MsgType.File ||
|
|
836
|
+
!isRecord(content.file) || content.file.url !== mediaId))) {
|
|
837
|
+
throw new StorageError("partial upload attachment does not match the current user and media");
|
|
838
|
+
}
|
|
839
|
+
await this.refreshRoomState(treeId, { signal: controller.signal });
|
|
840
|
+
listing = this.getStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, fileId);
|
|
841
|
+
if (listing && !listing.isRedacted() &&
|
|
842
|
+
(listing.getSender() !== this.client.getUserId() || listing.getContent().metadata_event_id !== fileId)) {
|
|
843
|
+
throw new StorageError("file changed after upload; use ordinary file deletion");
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (controller.signal.aborted)
|
|
847
|
+
throw new StorageError("operation cancelled");
|
|
848
|
+
await deleteOwnedMedia(this.client, [mediaId], controller.signal);
|
|
849
|
+
if (listing && !listing.isRedacted()) {
|
|
850
|
+
await withMatrixMutationAbort(() => this.client.redactEvent(treeId, listing.getId()), controller.signal);
|
|
851
|
+
}
|
|
852
|
+
if (attachment && !attachment.isRedacted()) {
|
|
853
|
+
await withMatrixMutationAbort(() => this.client.redactEvent(treeId, fileId), controller.signal);
|
|
854
|
+
}
|
|
855
|
+
if (fileId)
|
|
856
|
+
this.fileNames.delete(this.fileNameKey(treeId, fileId));
|
|
857
|
+
})(), controller.signal, () => new MutationOutcomeUnknownError("file upload cleanup"));
|
|
858
|
+
}
|
|
859
|
+
finally {
|
|
860
|
+
clearTimeout(timeout);
|
|
861
|
+
options?.signal?.removeEventListener("abort", abort);
|
|
862
|
+
}
|
|
646
863
|
}
|
|
647
864
|
/**
|
|
648
865
|
* Refreshes a room's current state from the homeserver. The background sync
|
|
@@ -661,6 +878,11 @@ export class TeleCryptIOStorage {
|
|
|
661
878
|
abortSignal: matrixRequestSignal(options),
|
|
662
879
|
});
|
|
663
880
|
const parsedStateEvents = parseMatrixStateResponse(stateEvents);
|
|
881
|
+
if (await this.repairOwnedTreePermissions(roomId, parsedStateEvents, options)) {
|
|
882
|
+
// Read the accepted native state back instead of fabricating a local
|
|
883
|
+
// permission snapshot. An interrupted repair retries on the next load.
|
|
884
|
+
return this.refreshRoomState(roomId, options);
|
|
885
|
+
}
|
|
664
886
|
if (typeof room.currentState.setStateEvents === "function") {
|
|
665
887
|
const refreshed = parsedStateEvents.map((event) => new MatrixEvent(event));
|
|
666
888
|
// RoomState.setStateEvents overwrites matching tuples, but deliberately
|
|
@@ -688,6 +910,49 @@ export class TeleCryptIOStorage {
|
|
|
688
910
|
room.currentState.setStateEvents(refreshed);
|
|
689
911
|
}
|
|
690
912
|
}
|
|
913
|
+
async repairOwnedTreePermissions(roomId, state, options) {
|
|
914
|
+
if (isTreeDeleted(this.client, roomId))
|
|
915
|
+
return false;
|
|
916
|
+
const event = (type, key = "") => state.find((item) => item.type === type && item.state_key === key);
|
|
917
|
+
const create = event(EventType.RoomCreate);
|
|
918
|
+
const purpose = event(UNSTABLE_MSC3088_PURPOSE.name, UNSTABLE_MSC3089_TREE_SUBTYPE.name);
|
|
919
|
+
if (create?.content?.[RoomCreateTypeField] !== RoomType.Space ||
|
|
920
|
+
purpose?.content?.[UNSTABLE_MSC3088_ENABLED.name] !== true)
|
|
921
|
+
return false;
|
|
922
|
+
const userId = this.client.getUserId();
|
|
923
|
+
if (!userId || create?.sender !== userId ||
|
|
924
|
+
event(EventType.RoomMember, userId)?.content?.membership !== "join") {
|
|
925
|
+
return false;
|
|
926
|
+
}
|
|
927
|
+
const rawPower = event(EventType.RoomPowerLevels)?.content;
|
|
928
|
+
const power = parseMatrixPowerLevels(rawPower);
|
|
929
|
+
const ownerLevel = power.events?.[EventType.RoomPowerLevels] ?? 100;
|
|
930
|
+
if ((power.users?.[userId] ?? power.users_default ?? 0) < ownerLevel)
|
|
931
|
+
return false;
|
|
932
|
+
const events = { ...power.events };
|
|
933
|
+
let changed = (power.state_default ?? 50) < 100;
|
|
934
|
+
for (const type of [EventType.RoomJoinRules, UNSTABLE_MSC3088_PURPOSE.name]) {
|
|
935
|
+
if (events[type] !== undefined && events[type] < 100) {
|
|
936
|
+
events[type] = 100;
|
|
937
|
+
changed = true;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
if (changed) {
|
|
941
|
+
await withMatrixMutationAbort(() => this.client.sendStateEvent(roomId, EventType.RoomPowerLevels, {
|
|
942
|
+
...rawPower,
|
|
943
|
+
state_default: Math.max(power.state_default ?? 50, 100),
|
|
944
|
+
events,
|
|
945
|
+
}, ""), matrixRequestSignal(options));
|
|
946
|
+
}
|
|
947
|
+
const joinRules = event(EventType.RoomJoinRules)?.content;
|
|
948
|
+
if (joinRules?.join_rule !== "invite") {
|
|
949
|
+
await withMatrixMutationAbort(() => this.client.sendStateEvent(roomId, EventType.RoomJoinRules, {
|
|
950
|
+
join_rule: JoinRule.Invite,
|
|
951
|
+
}, ""), matrixRequestSignal(options));
|
|
952
|
+
changed = true;
|
|
953
|
+
}
|
|
954
|
+
return changed;
|
|
955
|
+
}
|
|
691
956
|
/**
|
|
692
957
|
* Recommended entry point: builds the MatrixClient with a persistent crypto
|
|
693
958
|
* store and the secret-storage callback wiring `keys.*` needs, starts the
|
|
@@ -747,10 +1012,11 @@ export class TeleCryptIOStorage {
|
|
|
747
1012
|
return TeleCryptIOStorage.bootstrap(client, opts);
|
|
748
1013
|
}
|
|
749
1014
|
/** Shared post-construction bootstrap for `create()`/`createFromOidc()`:
|
|
750
|
-
* persistent crypto store, first sync, wrap in a `TeleCryptIOStorage`. */
|
|
1015
|
+
* persistent crypto store, optional first sync, wrap in a `TeleCryptIOStorage`. */
|
|
751
1016
|
static async bootstrap(client, opts) {
|
|
752
1017
|
const progress = opts.onProgress ?? (() => { });
|
|
753
1018
|
const persistent = opts.persistentCryptoStore ?? true;
|
|
1019
|
+
let storage;
|
|
754
1020
|
try {
|
|
755
1021
|
progress("Loading encryption engine (WASM)…");
|
|
756
1022
|
await TeleCryptIOStorage.withTimeout(client.initRustCrypto({
|
|
@@ -758,27 +1024,35 @@ export class TeleCryptIOStorage {
|
|
|
758
1024
|
cryptoDatabasePrefix: opts.cryptoDatabasePrefix ?? `telecrypt-io-storage::${opts.userId}::${opts.deviceId}`,
|
|
759
1025
|
}), opts.initTimeoutMs ?? 60000, "crypto init", opts.signal);
|
|
760
1026
|
progress("Encryption ready — opening secure store…");
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1027
|
+
storage = new TeleCryptIOStorage(client, opts, opts);
|
|
1028
|
+
if (opts.startClient === false) {
|
|
1029
|
+
// Rust Crypto starts checking server-side backup state during init. A
|
|
1030
|
+
// short-lived caller (the CLI login transaction) may close this
|
|
1031
|
+
// client before that background check finishes, so drain it before
|
|
1032
|
+
// returning the sync-deferred client.
|
|
1033
|
+
const crypto = client.getCrypto();
|
|
1034
|
+
if (crypto) {
|
|
1035
|
+
await TeleCryptIOStorage.withTimeout(crypto.checkKeyBackupAndEnable(), opts.syncTimeoutMs ?? 15000, "crypto backup check", opts.signal);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
else {
|
|
1039
|
+
await storage.startSync(opts.signal);
|
|
1040
|
+
}
|
|
1041
|
+
return storage;
|
|
770
1042
|
}
|
|
771
1043
|
catch (error) {
|
|
772
1044
|
// stopClient is idempotent in matrix-js-sdk and is also required after
|
|
773
1045
|
// init/timeout failures: crypto startup can have installed listeners or
|
|
774
1046
|
// a sync task before the awaited operation rejects.
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1047
|
+
if (!storage) {
|
|
1048
|
+
try {
|
|
1049
|
+
client.stopClient();
|
|
1050
|
+
}
|
|
1051
|
+
catch (cleanupError) {
|
|
1052
|
+
throw new AggregateError([error, cleanupError], "storage bootstrap and client cleanup failed", {
|
|
1053
|
+
cause: error,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
782
1056
|
}
|
|
783
1057
|
throw error;
|
|
784
1058
|
}
|
|
@@ -992,12 +1266,7 @@ export class TeleCryptIOStorage {
|
|
|
992
1266
|
if (signal?.aborted)
|
|
993
1267
|
throw new StorageError("operation cancelled");
|
|
994
1268
|
try {
|
|
995
|
-
await withRecoveryCryptoDeadline(() => crypto.bootstrapCrossSigning({
|
|
996
|
-
// No existing verified device to interactively re-authenticate against
|
|
997
|
-
// for this account, so there is nothing to feed into `makeRequest`;
|
|
998
|
-
// matches the working pattern already proven in keys.test.ts.
|
|
999
|
-
authUploadDeviceSigningKeys: async () => undefined,
|
|
1000
|
-
}), signal, "cross-signing bootstrap");
|
|
1269
|
+
await withRecoveryCryptoDeadline(() => crypto.bootstrapCrossSigning({}), signal, "cross-signing bootstrap");
|
|
1001
1270
|
}
|
|
1002
1271
|
catch (error) {
|
|
1003
1272
|
// Cross-signing setup can commit server-side state before a transport
|
|
@@ -1261,7 +1530,7 @@ export class TeleCryptIOStorage {
|
|
|
1261
1530
|
kick: 100,
|
|
1262
1531
|
ban: 100,
|
|
1263
1532
|
redact: 100,
|
|
1264
|
-
state_default:
|
|
1533
|
+
state_default: 100,
|
|
1265
1534
|
events_default: 0,
|
|
1266
1535
|
users_default: 0,
|
|
1267
1536
|
events: {
|
|
@@ -1358,7 +1627,7 @@ export class TeleCryptIOStorage {
|
|
|
1358
1627
|
const branch = originalGetFile(fileId);
|
|
1359
1628
|
return branch ? this.decorateFileBranch(tree.id, branch) : null;
|
|
1360
1629
|
};
|
|
1361
|
-
tree.createFile = (name, encryptedContents, info, additionalContent) => this.createEncryptedFile(tree.id, name, encryptedContents, info, additionalContent);
|
|
1630
|
+
tree.createFile = (name, encryptedContents, info, additionalContent, signal) => this.createEncryptedFile(tree.id, name, encryptedContents, info, additionalContent, signal);
|
|
1362
1631
|
return tree;
|
|
1363
1632
|
}
|
|
1364
1633
|
decorateFileBranch(treeId, branch) {
|
|
@@ -1514,8 +1783,14 @@ export class TeleCryptIOStorage {
|
|
|
1514
1783
|
if (isTreeDeleted(this.client, room.roomId))
|
|
1515
1784
|
continue;
|
|
1516
1785
|
const tree = this.client.unstableGetFileTreeSpace(room.roomId);
|
|
1517
|
-
if (tree)
|
|
1786
|
+
if (tree) {
|
|
1787
|
+
// Existing owner sessions repair the persisted permissions on load;
|
|
1788
|
+
// readers never attempt this owner-only mutation.
|
|
1789
|
+
if (this.getStateEvent(room.roomId, EventType.RoomCreate, "")?.getSender() === this.client.getUserId()) {
|
|
1790
|
+
await this.refreshRoomState(room.roomId, { signal });
|
|
1791
|
+
}
|
|
1518
1792
|
trees.push(this.decorateTreeSpace(tree));
|
|
1793
|
+
}
|
|
1519
1794
|
}
|
|
1520
1795
|
return trees;
|
|
1521
1796
|
}
|
|
@@ -1527,6 +1802,9 @@ export class TeleCryptIOStorage {
|
|
|
1527
1802
|
const currentParent = this.getTree(parent.id);
|
|
1528
1803
|
if (!currentParent)
|
|
1529
1804
|
throw new Error("createSubtree: parent is unavailable");
|
|
1805
|
+
const readers = (await this.listMembers(currentParent, { signal: effectiveSignal }))
|
|
1806
|
+
.filter((member) => member.role === "viewer" &&
|
|
1807
|
+
(member.membership === "join" || member.membership === "invite"));
|
|
1530
1808
|
const tree = await this.createTreeSpace(name, "createSubtree", effectiveSignal);
|
|
1531
1809
|
let parentLinkAttempted = false;
|
|
1532
1810
|
let parentLinkEventId;
|
|
@@ -1553,7 +1831,6 @@ export class TeleCryptIOStorage {
|
|
|
1553
1831
|
if (this.client.getRoom(tree.id)) {
|
|
1554
1832
|
await this.refreshRoomState(tree.id, { signal: effectiveSignal });
|
|
1555
1833
|
}
|
|
1556
|
-
return tree;
|
|
1557
1834
|
}
|
|
1558
1835
|
catch (error) {
|
|
1559
1836
|
// Compensation is independent of the caller's cancelled deadline.
|
|
@@ -1645,6 +1922,22 @@ export class TeleCryptIOStorage {
|
|
|
1645
1922
|
}
|
|
1646
1923
|
throw error;
|
|
1647
1924
|
}
|
|
1925
|
+
// The folder and both links now exist. A failed invitation must leave
|
|
1926
|
+
// that known folder available for reconciliation instead of deleting a
|
|
1927
|
+
// room to which another reader may already have received access.
|
|
1928
|
+
try {
|
|
1929
|
+
for (const reader of readers) {
|
|
1930
|
+
if (effectiveSignal?.aborted)
|
|
1931
|
+
throw new StorageError("operation cancelled");
|
|
1932
|
+
const verifyHistorySharing = await prepareReaderHistorySharing(this.client, reader.userId);
|
|
1933
|
+
await withMatrixMutationAbort(() => tree.invite(reader.userId), effectiveSignal);
|
|
1934
|
+
await verifyHistorySharing();
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
catch (error) {
|
|
1938
|
+
throw new MutationPartialError("create folder", [tree.id], "the folder was created, but reader access is incomplete; retry sharing the parent vault with its readers", { cause: error });
|
|
1939
|
+
}
|
|
1940
|
+
return tree;
|
|
1648
1941
|
}, signal);
|
|
1649
1942
|
}
|
|
1650
1943
|
getTree(roomId) {
|
|
@@ -1733,19 +2026,19 @@ export class TeleCryptIOStorage {
|
|
|
1733
2026
|
throw new StorageError("operation cancelled");
|
|
1734
2027
|
let response;
|
|
1735
2028
|
try {
|
|
1736
|
-
|
|
2029
|
+
// Let the in-flight request settle so the uploader can retain returned
|
|
2030
|
+
// media/event IDs on cancellation, then stop before the next write.
|
|
2031
|
+
response = await tree.createFile(name, new Uint8Array(encrypted.data), encrypted.info, { info: { mimetype, size: data.byteLength } }, signal);
|
|
1737
2032
|
}
|
|
1738
2033
|
catch (error) {
|
|
1739
|
-
//
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
if (error instanceof MutationOutcomeUnknownError)
|
|
2034
|
+
// Preserve acknowledged upload stages after cancellation as well.
|
|
2035
|
+
if (error instanceof MutationOutcomeUnknownError || error instanceof MutationPartialError)
|
|
2036
|
+
throw error;
|
|
2037
|
+
if (error instanceof StorageError || error instanceof MatrixError)
|
|
1744
2038
|
throw error;
|
|
2039
|
+
// An unclassified transport failure can still follow a committed event.
|
|
1745
2040
|
throw new MutationOutcomeUnknownError("file upload", { cause: error });
|
|
1746
2041
|
}
|
|
1747
|
-
if (signal?.aborted)
|
|
1748
|
-
throw new StorageError("operation cancelled");
|
|
1749
2042
|
try {
|
|
1750
2043
|
return validateMatrixEventId(response?.event_id, "file upload response event ID");
|
|
1751
2044
|
}
|
|
@@ -1786,7 +2079,7 @@ export class TeleCryptIOStorage {
|
|
|
1786
2079
|
const mimetype = eventMetadata.mimetype ?? "application/octet-stream";
|
|
1787
2080
|
const mxcUrl = info.url;
|
|
1788
2081
|
const clientAny = this.client;
|
|
1789
|
-
|
|
2082
|
+
let accessToken = clientAny.getAccessToken();
|
|
1790
2083
|
validateMatrixToken(accessToken);
|
|
1791
2084
|
let trustedOrigin;
|
|
1792
2085
|
try {
|
|
@@ -1820,6 +2113,7 @@ export class TeleCryptIOStorage {
|
|
|
1820
2113
|
else
|
|
1821
2114
|
signal?.addEventListener("abort", abortExternal, { once: true });
|
|
1822
2115
|
let ciphertext;
|
|
2116
|
+
let retriedAuthentication = false;
|
|
1823
2117
|
try {
|
|
1824
2118
|
for (;;) {
|
|
1825
2119
|
const res = await raceWithAbort(fetch(currentUrl, {
|
|
@@ -1862,13 +2156,22 @@ export class TeleCryptIOStorage {
|
|
|
1862
2156
|
if (!res.ok) {
|
|
1863
2157
|
const statusFailure = new Error(`media download failed: ${res.status}`);
|
|
1864
2158
|
let detail = "";
|
|
2159
|
+
let unknownToken = false;
|
|
1865
2160
|
try {
|
|
1866
2161
|
// Error responses are diagnostics, not product media. Consume the
|
|
1867
2162
|
// complete body so no provider detail is discarded, then apply
|
|
1868
2163
|
// the SDK's shared secret/control-character sanitizer before it
|
|
1869
2164
|
// crosses the SDK boundary.
|
|
1870
2165
|
const body = await readResponseBody(res, controller.signal);
|
|
1871
|
-
|
|
2166
|
+
const responseText = new TextDecoder().decode(body.bytes);
|
|
2167
|
+
detail = sanitizeDiagnosticText(responseText);
|
|
2168
|
+
if (res.status === 401) {
|
|
2169
|
+
try {
|
|
2170
|
+
const errorBody = JSON.parse(responseText);
|
|
2171
|
+
unknownToken = isRecord(errorBody) && errorBody.errcode === "M_UNKNOWN_TOKEN";
|
|
2172
|
+
}
|
|
2173
|
+
catch { /* Non-Matrix errors retain the complete diagnostic below. */ }
|
|
2174
|
+
}
|
|
1872
2175
|
}
|
|
1873
2176
|
catch (bodyError) {
|
|
1874
2177
|
const partial = bodyError instanceof ResponseBodyReadError
|
|
@@ -1879,6 +2182,20 @@ export class TeleCryptIOStorage {
|
|
|
1879
2182
|
: bodyError;
|
|
1880
2183
|
throw new AggregateError([statusFailure, readFailure], "media download failed while reading the response body", { cause: statusFailure });
|
|
1881
2184
|
}
|
|
2185
|
+
if (unknownToken && !retriedAuthentication) {
|
|
2186
|
+
retriedAuthentication = true;
|
|
2187
|
+
// Matrix's authenticated transport owns token refresh, including
|
|
2188
|
+
// concurrent refreshes and persistence. Its media request path
|
|
2189
|
+
// buffers the whole body and follows redirects, so use a small
|
|
2190
|
+
// authenticated read to renew the session, then retry our bounded
|
|
2191
|
+
// streaming download once with the current token.
|
|
2192
|
+
await raceWithAbort(this.client.whoami(), controller.signal, () => signal?.aborted
|
|
2193
|
+
? new StorageError("operation cancelled")
|
|
2194
|
+
: new Error("media download timed out"));
|
|
2195
|
+
accessToken = clientAny.getAccessToken();
|
|
2196
|
+
validateMatrixToken(accessToken);
|
|
2197
|
+
continue;
|
|
2198
|
+
}
|
|
1882
2199
|
throw new Error(`${statusFailure.message}${detail === "" ? "" : `: ${detail}`}`);
|
|
1883
2200
|
}
|
|
1884
2201
|
if (!res.body) {
|