@telecrypt-io/storage 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -3
- package/dist/TeleCryptIOStorage.d.ts +43 -3
- package/dist/TeleCryptIOStorage.js +365 -49
- package/dist/core/operations.js +234 -147
- package/dist/core/types.d.ts +1 -0
- package/dist/key-safe.d.ts +62 -0
- package/dist/key-safe.js +349 -0
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ClientEvent, createClient, EventType, MatrixEvent, MatrixError, Preset, RoomCreateTypeField, RoomType, SyncState, UNSTABLE_MSC3088_ENABLED, UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_TREE_SUBTYPE, } from "matrix-js-sdk";
|
|
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";
|
|
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";
|
|
@@ -9,6 +9,11 @@ import { cancelResponseBody, raceWithAbort, readMediaResponseBody, readResponseB
|
|
|
9
9
|
import { sanitizeDiagnosticText } from "./core/oidc.js";
|
|
10
10
|
import { validateName } from "./core/validation.js";
|
|
11
11
|
import { isTreeDeleted } from "./deletion-markers.js";
|
|
12
|
+
import { DecryptionKeySafe } from "./key-safe.js";
|
|
13
|
+
const STORAGE_METADATA_EVENT_TYPE = "io.telecrypt.storage.metadata";
|
|
14
|
+
const STORAGE_METADATA_MSGTYPE = "io.telecrypt.storage.metadata";
|
|
15
|
+
const GENERIC_TREE_NAME = "Encrypted storage";
|
|
16
|
+
const GENERIC_FILE_NAME = "Encrypted file";
|
|
12
17
|
const recoveryMutationQueues = new WeakMap();
|
|
13
18
|
const treeMutationQueues = new WeakMap();
|
|
14
19
|
const TREE_SYNC_TIMEOUT_MS = 15000;
|
|
@@ -235,12 +240,6 @@ export function readFileEventMetadata(content) {
|
|
|
235
240
|
function isRecord(value) {
|
|
236
241
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
237
242
|
}
|
|
238
|
-
async function isUndecryptableFilePlaceholder(branch) {
|
|
239
|
-
// matrix-js-sdk replaces failed encrypted event content with m.bad.encrypted;
|
|
240
|
-
// its public failure flag is the authoritative state and avoids guessing
|
|
241
|
-
// from that display content (or misclassifying malformed plaintext events).
|
|
242
|
-
return (await branch.getFileEvent()).isDecryptionFailure();
|
|
243
|
-
}
|
|
244
243
|
function validateSecretStorageStatus(value) {
|
|
245
244
|
if (!isRecord(value))
|
|
246
245
|
throw new RecoverySetupAmbiguousError();
|
|
@@ -408,9 +407,18 @@ export async function withTreeMutation(client, operation, signal) {
|
|
|
408
407
|
}
|
|
409
408
|
export class TeleCryptIOStorage {
|
|
410
409
|
client;
|
|
410
|
+
syncOptions;
|
|
411
411
|
decoratedTreeSpaces = new WeakSet();
|
|
412
|
-
|
|
412
|
+
decoratedFileBranches = new WeakMap();
|
|
413
|
+
treeNames = new Map();
|
|
414
|
+
fileNames = new Map();
|
|
415
|
+
keySafe;
|
|
416
|
+
syncStarted = false;
|
|
417
|
+
syncInFlight;
|
|
418
|
+
constructor(client, options = {}, syncOptions = {}) {
|
|
413
419
|
this.client = client;
|
|
420
|
+
this.syncOptions = syncOptions;
|
|
421
|
+
this.keySafe = new DecryptionKeySafe(client, (key, operation, signal) => this.withSecretStorageKey(key, operation, signal), options, (signal) => this.startSync(signal));
|
|
414
422
|
// Advanced callers may construct a MatrixClient themselves. The SDK does
|
|
415
423
|
// not mutate matrix-js-sdk internals: configure that client with the
|
|
416
424
|
// supported createClient({ fetchFn, localTimeoutMs }) options, or use
|
|
@@ -420,6 +428,264 @@ export class TeleCryptIOStorage {
|
|
|
420
428
|
getClient() {
|
|
421
429
|
return this.client;
|
|
422
430
|
}
|
|
431
|
+
/** Start Matrix room sync after crypto setup, for example after the current
|
|
432
|
+
* login has checked or restored its Decryption Key Safe. */
|
|
433
|
+
async startSync(signal) {
|
|
434
|
+
if (this.syncStarted)
|
|
435
|
+
return;
|
|
436
|
+
if (this.syncInFlight)
|
|
437
|
+
return this.syncInFlight;
|
|
438
|
+
const start = (async () => {
|
|
439
|
+
try {
|
|
440
|
+
TeleCryptIOStorage.throwIfAborted(signal);
|
|
441
|
+
const timeoutMs = this.syncOptions.syncTimeoutMs ?? 15000;
|
|
442
|
+
this.syncOptions.onProgress?.("Starting Matrix client…");
|
|
443
|
+
await TeleCryptIOStorage.withTimeout(this.client.startClient({ initialSyncLimit: this.syncOptions.initialSyncLimit ?? 10 }), timeoutMs, "client start", signal);
|
|
444
|
+
this.syncOptions.onProgress?.("Waiting for first sync with homeserver…");
|
|
445
|
+
await TeleCryptIOStorage.waitForFirstSync(this.client, timeoutMs, signal);
|
|
446
|
+
TeleCryptIOStorage.throwIfAborted(signal);
|
|
447
|
+
this.syncStarted = true;
|
|
448
|
+
this.syncOptions.onProgress?.("Sync complete.");
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
try {
|
|
452
|
+
this.client.stopClient();
|
|
453
|
+
}
|
|
454
|
+
catch (stopError) {
|
|
455
|
+
throw new AggregateError([error, stopError], "Matrix sync startup and cleanup failed");
|
|
456
|
+
}
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
this.syncInFlight = undefined;
|
|
461
|
+
}
|
|
462
|
+
})();
|
|
463
|
+
this.syncInFlight = start;
|
|
464
|
+
return start;
|
|
465
|
+
}
|
|
466
|
+
fileNameKey(treeId, fileId) {
|
|
467
|
+
return `${treeId}\u0000${fileId}`;
|
|
468
|
+
}
|
|
469
|
+
getStateEvent(roomId, eventType, stateKey) {
|
|
470
|
+
const state = this.client.getRoom(roomId)?.currentState;
|
|
471
|
+
const raw = state?.getStateEvents(eventType, stateKey);
|
|
472
|
+
if (Array.isArray(raw))
|
|
473
|
+
return raw[0] ?? null;
|
|
474
|
+
return raw ?? null;
|
|
475
|
+
}
|
|
476
|
+
getTreeOwnerId(roomId) {
|
|
477
|
+
const create = this.getStateEvent(roomId, EventType.RoomCreate, "");
|
|
478
|
+
const ownerId = create?.getSender();
|
|
479
|
+
if (!ownerId)
|
|
480
|
+
throw new StorageError("storage owner is unavailable");
|
|
481
|
+
return ownerId;
|
|
482
|
+
}
|
|
483
|
+
async sendEncryptedMetadataMessage(roomId, content) {
|
|
484
|
+
const response = await this.client.sendMessage(roomId, content);
|
|
485
|
+
return validateMatrixEventId(response?.event_id, "storage metadata response event ID");
|
|
486
|
+
}
|
|
487
|
+
async readEncryptedMetadataMessage(roomId, eventId, allowRedacted = false) {
|
|
488
|
+
const rawEvent = await this.client.fetchRoomEvent(roomId, eventId);
|
|
489
|
+
const event = this.client.getEventMapper()(rawEvent);
|
|
490
|
+
await this.client.decryptEventIfNeeded(event);
|
|
491
|
+
if (event.isDecryptionFailure())
|
|
492
|
+
throw new UndecryptableFileError();
|
|
493
|
+
if (allowRedacted &&
|
|
494
|
+
event.getId() === eventId &&
|
|
495
|
+
event.getRoomId() === roomId &&
|
|
496
|
+
event.getSender() === this.getTreeOwnerId(roomId) &&
|
|
497
|
+
event.isRedacted()) {
|
|
498
|
+
return event;
|
|
499
|
+
}
|
|
500
|
+
if (event.getId() !== eventId ||
|
|
501
|
+
event.getRoomId() !== roomId ||
|
|
502
|
+
event.getSender() !== this.getTreeOwnerId(roomId) ||
|
|
503
|
+
event.getWireType() !== EventType.RoomMessageEncrypted ||
|
|
504
|
+
event.getType() !== EventType.RoomMessage) {
|
|
505
|
+
throw new StorageError("encrypted storage metadata is invalid or unavailable");
|
|
506
|
+
}
|
|
507
|
+
return event;
|
|
508
|
+
}
|
|
509
|
+
async writeTreeName(roomId, name) {
|
|
510
|
+
validateName(name, "name");
|
|
511
|
+
const metadataEventId = await this.sendEncryptedMetadataMessage(roomId, {
|
|
512
|
+
msgtype: STORAGE_METADATA_MSGTYPE,
|
|
513
|
+
body: name,
|
|
514
|
+
});
|
|
515
|
+
await this.client.sendStateEvent(roomId, STORAGE_METADATA_EVENT_TYPE, { event_id: metadataEventId }, "");
|
|
516
|
+
this.treeNames.set(roomId, name);
|
|
517
|
+
}
|
|
518
|
+
/** Resolves an encrypted vault/folder name through its pointer state event. */
|
|
519
|
+
async getTreeName(roomId, options) {
|
|
520
|
+
if (options?.signal?.aborted)
|
|
521
|
+
throw new StorageError("operation cancelled");
|
|
522
|
+
await this.refreshRoomState(roomId, options);
|
|
523
|
+
const pointer = this.getStateEvent(roomId, STORAGE_METADATA_EVENT_TYPE, "");
|
|
524
|
+
const pointerContent = pointer?.getContent();
|
|
525
|
+
if (!pointer ||
|
|
526
|
+
pointer.getSender() !== this.getTreeOwnerId(roomId) ||
|
|
527
|
+
!isRecord(pointerContent) ||
|
|
528
|
+
typeof pointerContent.event_id !== "string") {
|
|
529
|
+
throw new StorageError("encrypted storage name is unavailable");
|
|
530
|
+
}
|
|
531
|
+
const metadataEventId = validateMatrixEventId(pointerContent.event_id, "storage metadata event ID");
|
|
532
|
+
let event;
|
|
533
|
+
try {
|
|
534
|
+
event = await this.readEncryptedMetadataMessage(roomId, metadataEventId);
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
// A new device can list storage before restoring room keys. Keep the
|
|
538
|
+
// room visible with a generic label; a later call retries decryption.
|
|
539
|
+
if (error instanceof UndecryptableFileError)
|
|
540
|
+
return GENERIC_TREE_NAME;
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
const content = event.getContent();
|
|
544
|
+
if (!isRecord(content) ||
|
|
545
|
+
content.msgtype !== STORAGE_METADATA_MSGTYPE ||
|
|
546
|
+
typeof content.body !== "string" ||
|
|
547
|
+
Object.hasOwn(content, "file_event_id")) {
|
|
548
|
+
throw new StorageError("encrypted storage name is invalid or unavailable");
|
|
549
|
+
}
|
|
550
|
+
validateName(content.body, "name");
|
|
551
|
+
this.treeNames.set(roomId, content.body);
|
|
552
|
+
return content.body;
|
|
553
|
+
}
|
|
554
|
+
async writeFileName(treeId, fileId, name) {
|
|
555
|
+
validateName(name, "file name");
|
|
556
|
+
validateMatrixEventId(fileId, "file event ID");
|
|
557
|
+
const metadataEventId = await this.sendEncryptedMetadataMessage(treeId, {
|
|
558
|
+
msgtype: STORAGE_METADATA_MSGTYPE,
|
|
559
|
+
body: name,
|
|
560
|
+
file_event_id: fileId,
|
|
561
|
+
});
|
|
562
|
+
await this.client.sendStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, { active: true, metadata_event_id: metadataEventId }, fileId);
|
|
563
|
+
this.fileNames.set(this.fileNameKey(treeId, fileId), name);
|
|
564
|
+
}
|
|
565
|
+
/** Resolves an encrypted file name from the MSC3089 index pointer. */
|
|
566
|
+
async getFileName(treeId, fileId, options) {
|
|
567
|
+
if (options?.signal?.aborted)
|
|
568
|
+
throw new StorageError("operation cancelled");
|
|
569
|
+
if (options?.refreshState !== false)
|
|
570
|
+
await this.refreshRoomState(treeId, options);
|
|
571
|
+
const tree = this.getTree(treeId);
|
|
572
|
+
const branch = tree?.getFile(fileId);
|
|
573
|
+
if (!branch || branch.isActive === false)
|
|
574
|
+
throw new StorageError("file not found");
|
|
575
|
+
const indexEvent = branch.indexEvent;
|
|
576
|
+
const indexContent = indexEvent?.getContent();
|
|
577
|
+
if (!indexEvent ||
|
|
578
|
+
indexEvent.getSender() !== this.getTreeOwnerId(treeId) ||
|
|
579
|
+
!isRecord(indexContent) ||
|
|
580
|
+
indexContent.active !== true ||
|
|
581
|
+
typeof indexContent.metadata_event_id !== "string") {
|
|
582
|
+
throw new StorageError("encrypted file name is unavailable");
|
|
583
|
+
}
|
|
584
|
+
const metadataEventId = validateMatrixEventId(indexContent.metadata_event_id, "file metadata event ID");
|
|
585
|
+
let event;
|
|
586
|
+
try {
|
|
587
|
+
event = await this.readEncryptedMetadataMessage(treeId, metadataEventId);
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
// File IDs remain listable on a fresh device while its room keys are
|
|
591
|
+
// being restored. The caller can resolve the private name on retry.
|
|
592
|
+
if (error instanceof UndecryptableFileError)
|
|
593
|
+
return GENERIC_FILE_NAME;
|
|
594
|
+
throw error;
|
|
595
|
+
}
|
|
596
|
+
const content = event.getContent();
|
|
597
|
+
if (!isRecord(content) || typeof content.body !== "string") {
|
|
598
|
+
throw new StorageError("encrypted file name is invalid or unavailable");
|
|
599
|
+
}
|
|
600
|
+
if (content.msgtype === MsgType.File) {
|
|
601
|
+
if (metadataEventId !== fileId || !isRecord(content.file)) {
|
|
602
|
+
throw new StorageError("encrypted file metadata does not match its listing");
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
else if (content.msgtype !== STORAGE_METADATA_MSGTYPE ||
|
|
606
|
+
content.file_event_id !== fileId) {
|
|
607
|
+
throw new StorageError("encrypted file metadata does not match its listing");
|
|
608
|
+
}
|
|
609
|
+
validateName(content.body, "file name");
|
|
610
|
+
this.fileNames.set(this.fileNameKey(treeId, fileId), content.body);
|
|
611
|
+
return content.body;
|
|
612
|
+
}
|
|
613
|
+
/** Returns the current file metadata pointer, including on inactive branches. */
|
|
614
|
+
async getFileMetadataEventId(treeId, fileId, options) {
|
|
615
|
+
if (options?.signal?.aborted)
|
|
616
|
+
throw new StorageError("operation cancelled");
|
|
617
|
+
await this.refreshRoomState(treeId, options);
|
|
618
|
+
const branch = this.getTree(treeId)?.getFile(fileId);
|
|
619
|
+
if (!branch)
|
|
620
|
+
throw new StorageError("file not found");
|
|
621
|
+
const indexEvent = branch.indexEvent;
|
|
622
|
+
const content = indexEvent?.getContent();
|
|
623
|
+
if (!indexEvent || indexEvent.getSender() !== this.getTreeOwnerId(treeId)) {
|
|
624
|
+
throw new StorageError("encrypted file listing is unavailable");
|
|
625
|
+
}
|
|
626
|
+
if (!isRecord(content) ||
|
|
627
|
+
typeof content.metadata_event_id !== "string") {
|
|
628
|
+
if (branch.isActive === false || indexEvent.isRedacted())
|
|
629
|
+
return null;
|
|
630
|
+
throw new StorageError("encrypted file name is unavailable");
|
|
631
|
+
}
|
|
632
|
+
return validateMatrixEventId(content.metadata_event_id, "file metadata event ID");
|
|
633
|
+
}
|
|
634
|
+
/** Fetches and validates a current encrypted rename event, allowing retries after redaction. */
|
|
635
|
+
async getFileRenameMetadataEvent(treeId, fileId, metadataEventId) {
|
|
636
|
+
const event = await this.readEncryptedMetadataMessage(treeId, metadataEventId, true);
|
|
637
|
+
if (event.isRedacted())
|
|
638
|
+
return event;
|
|
639
|
+
const content = event.getContent();
|
|
640
|
+
if (!isRecord(content) ||
|
|
641
|
+
content.msgtype !== STORAGE_METADATA_MSGTYPE ||
|
|
642
|
+
content.file_event_id !== fileId ||
|
|
643
|
+
typeof content.body !== "string") {
|
|
644
|
+
throw new StorageError("encrypted file metadata does not match its listing");
|
|
645
|
+
}
|
|
646
|
+
validateName(content.body, "file name");
|
|
647
|
+
return event;
|
|
648
|
+
}
|
|
649
|
+
/** Fetches the original encrypted file message directly by event ID. */
|
|
650
|
+
async getOriginalFileEvent(treeId, fileId, allowRedacted = false) {
|
|
651
|
+
const event = await this.readEncryptedMetadataMessage(treeId, fileId, allowRedacted);
|
|
652
|
+
if (event.isRedacted() && allowRedacted)
|
|
653
|
+
return event;
|
|
654
|
+
const content = event.getContent();
|
|
655
|
+
if (!isRecord(content) ||
|
|
656
|
+
content.msgtype !== MsgType.File ||
|
|
657
|
+
typeof content.body !== "string" ||
|
|
658
|
+
!isRecord(content.file) ||
|
|
659
|
+
typeof content.file.url !== "string") {
|
|
660
|
+
throw new StorageError("encrypted file message is invalid or unavailable");
|
|
661
|
+
}
|
|
662
|
+
return event;
|
|
663
|
+
}
|
|
664
|
+
async createEncryptedFile(treeId, name, encryptedContents, info, additionalContent) {
|
|
665
|
+
validateFileName(name);
|
|
666
|
+
const bytes = encryptedContents instanceof Uint8Array
|
|
667
|
+
? encryptedContents
|
|
668
|
+
: new Uint8Array(encryptedContents);
|
|
669
|
+
const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
670
|
+
const uploaded = await this.client.uploadContent(body, { includeFilename: false });
|
|
671
|
+
if (typeof uploaded.content_uri !== "string" || uploaded.content_uri.length === 0) {
|
|
672
|
+
throw new StorageError("encrypted media upload returned no content URI");
|
|
673
|
+
}
|
|
674
|
+
const fileInfo = { ...info, url: uploaded.content_uri };
|
|
675
|
+
const fileContent = {
|
|
676
|
+
...additionalContent,
|
|
677
|
+
msgtype: MsgType.File,
|
|
678
|
+
body: name,
|
|
679
|
+
url: uploaded.content_uri,
|
|
680
|
+
file: fileInfo,
|
|
681
|
+
[UNSTABLE_MSC3089_LEAF.name]: {},
|
|
682
|
+
};
|
|
683
|
+
const sent = await this.client.sendMessage(treeId, fileContent);
|
|
684
|
+
const fileEventId = validateMatrixEventId(sent?.event_id, "file upload response event ID");
|
|
685
|
+
await this.client.sendStateEvent(treeId, UNSTABLE_MSC3089_BRANCH.name, { active: true, metadata_event_id: fileEventId }, fileEventId);
|
|
686
|
+
this.fileNames.set(this.fileNameKey(treeId, fileEventId), name);
|
|
687
|
+
return { event_id: fileEventId };
|
|
688
|
+
}
|
|
423
689
|
/**
|
|
424
690
|
* Refreshes a room's current state from the homeserver. The background sync
|
|
425
691
|
* loop is intentionally asynchronous, so a short-lived CLI process can
|
|
@@ -523,10 +789,11 @@ export class TeleCryptIOStorage {
|
|
|
523
789
|
return TeleCryptIOStorage.bootstrap(client, opts);
|
|
524
790
|
}
|
|
525
791
|
/** Shared post-construction bootstrap for `create()`/`createFromOidc()`:
|
|
526
|
-
* persistent crypto store, first sync, wrap in a `TeleCryptIOStorage`. */
|
|
792
|
+
* persistent crypto store, optional first sync, wrap in a `TeleCryptIOStorage`. */
|
|
527
793
|
static async bootstrap(client, opts) {
|
|
528
794
|
const progress = opts.onProgress ?? (() => { });
|
|
529
795
|
const persistent = opts.persistentCryptoStore ?? true;
|
|
796
|
+
let storage;
|
|
530
797
|
try {
|
|
531
798
|
progress("Loading encryption engine (WASM)…");
|
|
532
799
|
await TeleCryptIOStorage.withTimeout(client.initRustCrypto({
|
|
@@ -534,27 +801,35 @@ export class TeleCryptIOStorage {
|
|
|
534
801
|
cryptoDatabasePrefix: opts.cryptoDatabasePrefix ?? `telecrypt-io-storage::${opts.userId}::${opts.deviceId}`,
|
|
535
802
|
}), opts.initTimeoutMs ?? 60000, "crypto init", opts.signal);
|
|
536
803
|
progress("Encryption ready — opening secure store…");
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
804
|
+
storage = new TeleCryptIOStorage(client, opts, opts);
|
|
805
|
+
if (opts.startClient === false) {
|
|
806
|
+
// Rust Crypto starts checking server-side backup state during init. A
|
|
807
|
+
// short-lived caller (the CLI login transaction) may close this
|
|
808
|
+
// client before that background check finishes, so drain it before
|
|
809
|
+
// returning the sync-deferred client.
|
|
810
|
+
const crypto = client.getCrypto();
|
|
811
|
+
if (crypto) {
|
|
812
|
+
await TeleCryptIOStorage.withTimeout(crypto.checkKeyBackupAndEnable(), opts.syncTimeoutMs ?? 15000, "crypto backup check", opts.signal);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
await storage.startSync(opts.signal);
|
|
817
|
+
}
|
|
818
|
+
return storage;
|
|
546
819
|
}
|
|
547
820
|
catch (error) {
|
|
548
821
|
// stopClient is idempotent in matrix-js-sdk and is also required after
|
|
549
822
|
// init/timeout failures: crypto startup can have installed listeners or
|
|
550
823
|
// a sync task before the awaited operation rejects.
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
824
|
+
if (!storage) {
|
|
825
|
+
try {
|
|
826
|
+
client.stopClient();
|
|
827
|
+
}
|
|
828
|
+
catch (cleanupError) {
|
|
829
|
+
throw new AggregateError([error, cleanupError], "storage bootstrap and client cleanup failed", {
|
|
830
|
+
cause: error,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
558
833
|
}
|
|
559
834
|
throw error;
|
|
560
835
|
}
|
|
@@ -768,12 +1043,7 @@ export class TeleCryptIOStorage {
|
|
|
768
1043
|
if (signal?.aborted)
|
|
769
1044
|
throw new StorageError("operation cancelled");
|
|
770
1045
|
try {
|
|
771
|
-
await withRecoveryCryptoDeadline(() => crypto.bootstrapCrossSigning({
|
|
772
|
-
// No existing verified device to interactively re-authenticate against
|
|
773
|
-
// for this account, so there is nothing to feed into `makeRequest`;
|
|
774
|
-
// matches the working pattern already proven in keys.test.ts.
|
|
775
|
-
authUploadDeviceSigningKeys: async () => undefined,
|
|
776
|
-
}), signal, "cross-signing bootstrap");
|
|
1046
|
+
await withRecoveryCryptoDeadline(() => crypto.bootstrapCrossSigning({}), signal, "cross-signing bootstrap");
|
|
777
1047
|
}
|
|
778
1048
|
catch (error) {
|
|
779
1049
|
// Cross-signing setup can commit server-side state before a transport
|
|
@@ -1030,7 +1300,7 @@ export class TeleCryptIOStorage {
|
|
|
1030
1300
|
if (!userId)
|
|
1031
1301
|
throw new Error("createTree: Matrix client has no user ID");
|
|
1032
1302
|
return {
|
|
1033
|
-
name,
|
|
1303
|
+
name: GENERIC_TREE_NAME,
|
|
1034
1304
|
preset: Preset.PrivateChat,
|
|
1035
1305
|
power_level_content_override: {
|
|
1036
1306
|
invite: 100,
|
|
@@ -1052,6 +1322,7 @@ export class TeleCryptIOStorage {
|
|
|
1052
1322
|
[EventType.SpaceChild]: 100,
|
|
1053
1323
|
[EventType.SpaceParent]: 100,
|
|
1054
1324
|
[UNSTABLE_MSC3089_BRANCH.name]: 100,
|
|
1325
|
+
[STORAGE_METADATA_EVENT_TYPE]: 100,
|
|
1055
1326
|
},
|
|
1056
1327
|
users: { [userId]: 100 },
|
|
1057
1328
|
},
|
|
@@ -1119,10 +1390,46 @@ export class TeleCryptIOStorage {
|
|
|
1119
1390
|
return tree;
|
|
1120
1391
|
this.decoratedTreeSpaces.add(tree);
|
|
1121
1392
|
const originalGetDirectories = tree.getDirectories.bind(tree);
|
|
1393
|
+
const originalListFiles = typeof tree.listFiles === "function"
|
|
1394
|
+
? tree.listFiles.bind(tree)
|
|
1395
|
+
: () => [];
|
|
1396
|
+
const originalGetFile = typeof tree.getFile === "function"
|
|
1397
|
+
? tree.getFile.bind(tree)
|
|
1398
|
+
: () => null;
|
|
1122
1399
|
tree.getDirectories = () => originalGetDirectories().map((child) => this.decorateTreeSpace(child));
|
|
1123
1400
|
tree.createDirectory = (name) => this.createSubtree(tree, name);
|
|
1401
|
+
tree.setName = (name) => this.writeTreeName(tree.id, name);
|
|
1402
|
+
tree.listFiles = () => originalListFiles().map((branch) => this.decorateFileBranch(tree.id, branch));
|
|
1403
|
+
tree.getFile = (fileId) => {
|
|
1404
|
+
const branch = originalGetFile(fileId);
|
|
1405
|
+
return branch ? this.decorateFileBranch(tree.id, branch) : null;
|
|
1406
|
+
};
|
|
1407
|
+
tree.createFile = (name, encryptedContents, info, additionalContent) => this.createEncryptedFile(tree.id, name, encryptedContents, info, additionalContent);
|
|
1124
1408
|
return tree;
|
|
1125
1409
|
}
|
|
1410
|
+
decorateFileBranch(treeId, branch) {
|
|
1411
|
+
const existing = this.decoratedFileBranches.get(branch);
|
|
1412
|
+
if (existing)
|
|
1413
|
+
return existing;
|
|
1414
|
+
const decorated = Object.create(branch);
|
|
1415
|
+
const nameKey = this.fileNameKey(treeId, branch.id);
|
|
1416
|
+
Object.defineProperties(decorated, {
|
|
1417
|
+
getName: {
|
|
1418
|
+
value: () => this.fileNames.get(nameKey) ?? GENERIC_FILE_NAME,
|
|
1419
|
+
},
|
|
1420
|
+
setName: {
|
|
1421
|
+
value: (name) => this.writeFileName(treeId, branch.id, name),
|
|
1422
|
+
},
|
|
1423
|
+
delete: {
|
|
1424
|
+
value: async () => {
|
|
1425
|
+
const { deleteFile } = await import("./core/operations.js");
|
|
1426
|
+
await deleteFile(this, treeId, branch.id);
|
|
1427
|
+
},
|
|
1428
|
+
},
|
|
1429
|
+
});
|
|
1430
|
+
this.decoratedFileBranches.set(branch, decorated);
|
|
1431
|
+
return decorated;
|
|
1432
|
+
}
|
|
1126
1433
|
/** Cleans up only a room created by this operation and reports incomplete cleanup. */
|
|
1127
1434
|
async cleanupCreatedRoom(roomId) {
|
|
1128
1435
|
// Cleanup is compensating work. It must not inherit the caller's already
|
|
@@ -1203,9 +1510,26 @@ export class TeleCryptIOStorage {
|
|
|
1203
1510
|
throw new RoomCreationAmbiguousError(operation, error);
|
|
1204
1511
|
}
|
|
1205
1512
|
try {
|
|
1206
|
-
|
|
1513
|
+
const tree = await this.waitForTreeSpace(roomId, operation, signal);
|
|
1514
|
+
try {
|
|
1515
|
+
await this.writeTreeName(roomId, name);
|
|
1516
|
+
}
|
|
1517
|
+
catch (error) {
|
|
1518
|
+
try {
|
|
1519
|
+
await this.cleanupCreatedRoom(roomId);
|
|
1520
|
+
}
|
|
1521
|
+
catch (cleanupError) {
|
|
1522
|
+
throwWithCleanupDetail(error, roomId, cleanupError);
|
|
1523
|
+
}
|
|
1524
|
+
throw new StorageError(`${operation}: encrypted name initialization failed`, { cause: error });
|
|
1525
|
+
}
|
|
1526
|
+
return tree;
|
|
1207
1527
|
}
|
|
1208
1528
|
catch (error) {
|
|
1529
|
+
if (error instanceof StorageError &&
|
|
1530
|
+
error.message === `${operation}: encrypted name initialization failed`) {
|
|
1531
|
+
throw error;
|
|
1532
|
+
}
|
|
1209
1533
|
try {
|
|
1210
1534
|
await this.cleanupCreatedRoom(roomId);
|
|
1211
1535
|
}
|
|
@@ -1409,7 +1733,9 @@ export class TeleCryptIOStorage {
|
|
|
1409
1733
|
const members = parseMatrixMembersResponse(membersBody);
|
|
1410
1734
|
const pls = parseMatrixPowerLevels(plsRaw);
|
|
1411
1735
|
const viewLevel = pls.users_default ?? 0;
|
|
1412
|
-
|
|
1736
|
+
// Match matrix-js-sdk's MSC3089TreeSpace.getPermissions() default. An
|
|
1737
|
+
// explicit zero still means regular users lack the editor threshold.
|
|
1738
|
+
const editLevel = pls.events_default || 50;
|
|
1413
1739
|
const adminLevel = pls.events?.["m.room.power_levels"] ?? 100;
|
|
1414
1740
|
const roleFor = (userId) => {
|
|
1415
1741
|
const userLevel = pls.users?.[userId] ?? viewLevel;
|
|
@@ -1479,27 +1805,17 @@ export class TeleCryptIOStorage {
|
|
|
1479
1805
|
if (signal?.aborted)
|
|
1480
1806
|
throw new StorageError("operation cancelled");
|
|
1481
1807
|
let info;
|
|
1808
|
+
let fileEvent;
|
|
1482
1809
|
try {
|
|
1483
|
-
|
|
1810
|
+
fileEvent = await this.getOriginalFileEvent(branch.roomId, branch.id);
|
|
1811
|
+
const content = fileEvent.getContent();
|
|
1812
|
+
info = isRecord(content) && isRecord(content.file) ? content.file : undefined;
|
|
1484
1813
|
}
|
|
1485
1814
|
catch (error) {
|
|
1486
1815
|
if (signal?.aborted)
|
|
1487
1816
|
throw new StorageError("operation cancelled");
|
|
1488
1817
|
if (error instanceof StorageError || error instanceof MatrixError)
|
|
1489
1818
|
throw error;
|
|
1490
|
-
// matrix-js-sdk's file-info accessor reads an attachment absent from
|
|
1491
|
-
// failed-decryption events. Use its explicit decryption status, not a
|
|
1492
|
-
// guessed content shape; preserve other SDK/transport failures.
|
|
1493
|
-
let isPlaceholder = false;
|
|
1494
|
-
try {
|
|
1495
|
-
isPlaceholder = await isUndecryptableFilePlaceholder(branch);
|
|
1496
|
-
}
|
|
1497
|
-
catch (placeholderError) {
|
|
1498
|
-
throw new AggregateError([error, placeholderError], "file metadata lookup and placeholder inspection both failed", { cause: error });
|
|
1499
|
-
}
|
|
1500
|
-
if (isPlaceholder) {
|
|
1501
|
-
throw new UndecryptableFileError();
|
|
1502
|
-
}
|
|
1503
1819
|
throw error;
|
|
1504
1820
|
}
|
|
1505
1821
|
// Missing metadata alone does not establish a decryption failure.
|
|
@@ -1509,7 +1825,7 @@ export class TeleCryptIOStorage {
|
|
|
1509
1825
|
// `info` is the encrypted attachment descriptor. Its shape is owned by
|
|
1510
1826
|
// matrix-encrypt-attachment and does not carry the plaintext size. The
|
|
1511
1827
|
// event's `info` block is the sole source of user-facing file metadata.
|
|
1512
|
-
const eventMetadata = readFileEventMetadata(
|
|
1828
|
+
const eventMetadata = readFileEventMetadata(fileEvent.getContent());
|
|
1513
1829
|
if (signal?.aborted)
|
|
1514
1830
|
throw new StorageError("operation cancelled");
|
|
1515
1831
|
const declaredSize = eventMetadata.size;
|