@telecrypt-io/storage 0.8.0 → 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 CHANGED
@@ -46,6 +46,13 @@ by default. Browser persistence uses IndexedDB and the Matrix Rust crypto WASM r
46
46
  example assumes a browser environment. OIDC discovery, login, refresh, and the corresponding
47
47
  `createFromOidc` inputs are exported from `@telecrypt-io/storage/core`.
48
48
 
49
+ Applications that require the Decryption Key Safe before downloading room history can create the
50
+ client with `startClient: false`. Check its status and restore room keys first; `restore()` starts
51
+ sync after importing them so Matrix can publish this login's device keys and complete account
52
+ signing. For an already-ready safe, call `await storage.startSync()` after the status check. Keep
53
+ Storage operations locked until the Safe reports ready. The default is to start sync during
54
+ creation.
55
+
49
56
  The example creates retained Matrix rooms and encrypted media. Delete its file and then its empty
50
57
  vault when finished; cleanup is part of the operation, not an automatic consequence of stopping
51
58
  the client.
@@ -76,9 +83,27 @@ and a per-file key distributed through the room's Megolm session). The server st
76
83
  media and Matrix events, but encryption does not hide all room, event, or other metadata from the
77
84
  server or users who can read the room.
78
85
 
79
- Recovery is explicit. Use `core.setupRecovery(storage)` to create a recovery key and keep that key
80
- securely; use `core.restoreRecovery(storage, recoveryKey)` on a new device. Losing the recovery key
81
- can prevent encrypted keys from being restored.
86
+ The Decryption Key Safe is mandatory before the public core storage operations. Inspect
87
+ `storage.keySafe.getStatus(signal?)`. For `setup-required`, call `setup(signal?)`, offer the returned
88
+ `recoveryKey` for copying/saving, explain that losing both this key and local decryption keys can
89
+ make files permanently unreadable, and require an explicit saved-key acknowledgement before
90
+ `confirmSaved(signal?)`. `confirmation-required` returns the same pending key after interruption.
91
+ For `restore-required`, call `restore(recoveryKey, signal?)` with the existing key; this restores
92
+ room keys and signs the current login using the existing account keys. Normal use requires `ready`.
93
+ Setup and restore never replace an existing safe or account signing identity. Resetting the account
94
+ password cannot recover encrypted files.
95
+
96
+ Pending setup state is kept beside the login's IndexedDB crypto data. A platform that snapshots
97
+ IndexedDB to disk, such as the CLI, must supply `onKeySafeStateChanged` at creation and durably flush
98
+ its snapshot before resolving that callback. Key Safe methods accept an optional `AbortSignal`.
99
+ The safe, signing keys, and saved-key acknowledgement are shared SDK behavior rather than separate
100
+ Web/CLI implementations.
101
+
102
+ Sharing uses Matrix's native encrypted-history bundle exchange. Both users must have completed
103
+ their account signing setup. An invitation alone does not establish successful history sharing:
104
+ the SDK checks native recipient-device eligibility afterward and reports partial completion if
105
+ keys could not be shared. An upload restriction can reject the encrypted history bundle upload;
106
+ the error is preserved, including when retrying an existing invitation.
82
107
 
83
108
  Deletion has strict ordering. Delete files before their folders or vaults because folders and
84
109
  vaults must be empty. File deletion removes the encrypted media object before redacting its Matrix
@@ -2,6 +2,7 @@ import { MatrixClient, MatrixEvent } from "matrix-js-sdk";
2
2
  import type { CryptoCallbacks } from "matrix-js-sdk/lib/crypto-api/index.js";
3
3
  import type { TokenRefreshFunction } from "matrix-js-sdk/lib/http-api/index.js";
4
4
  import type { RecoveryStatus } from "./core/types.js";
5
+ import { DecryptionKeySafe, type KeySafePersistenceOptions } from "./key-safe.js";
5
6
  export interface TreeSpace {
6
7
  readonly id: string;
7
8
  readonly room: {
@@ -64,8 +65,12 @@ export interface CreateTeleCryptIOStorageOptions {
64
65
  * Node/tests, where `fake-indexeddb` is process-global).
65
66
  */
66
67
  cryptoDatabasePrefix?: string;
68
+ /** Persist local Key Safe state before server-side setup continues (CLI snapshots). */
69
+ onKeySafeStateChanged?: () => Promise<void>;
67
70
  /** initialSyncLimit passed to startClient(); default 10. */
68
71
  initialSyncLimit?: number;
72
+ /** Start Matrix sync after crypto initializes; default true. */
73
+ startClient?: boolean;
69
74
  /** How long to wait for the first sync before giving up; default 15000ms. */
70
75
  syncTimeoutMs?: number;
71
76
  /** How long to wait for rust-crypto WASM + IndexedDB init; default 60000ms. */
@@ -107,7 +112,10 @@ export interface CreateFromOidcOptions {
107
112
  tokenRefreshFunction?: TokenRefreshFunction;
108
113
  persistentCryptoStore?: boolean;
109
114
  cryptoDatabasePrefix?: string;
115
+ /** Persist local Key Safe state before server-side setup continues (CLI snapshots). */
116
+ onKeySafeStateChanged?: () => Promise<void>;
110
117
  initialSyncLimit?: number;
118
+ startClient?: boolean;
111
119
  syncTimeoutMs?: number;
112
120
  initTimeoutMs?: number;
113
121
  onProgress?: (message: string) => void;
@@ -144,13 +152,20 @@ export declare function readFileEventMetadata(content: unknown): FileEventMetada
144
152
  export declare function withTreeMutation<T>(client: MatrixClient, operation: (signal?: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T>;
145
153
  export declare class TeleCryptIOStorage {
146
154
  private client;
155
+ private readonly syncOptions;
147
156
  private readonly decoratedTreeSpaces;
148
157
  private readonly decoratedFileBranches;
149
158
  private readonly treeNames;
150
159
  private readonly fileNames;
151
- constructor(client: MatrixClient);
160
+ readonly keySafe: DecryptionKeySafe;
161
+ private syncStarted;
162
+ private syncInFlight;
163
+ constructor(client: MatrixClient, options?: KeySafePersistenceOptions, syncOptions?: Pick<CreateTeleCryptIOStorageOptions, "initialSyncLimit" | "syncTimeoutMs" | "onProgress">);
152
164
  /** The underlying matrix-js-sdk client (e.g. to stop it, or for advanced/interop use). */
153
165
  getClient(): MatrixClient;
166
+ /** Start Matrix room sync after crypto setup, for example after the current
167
+ * login has checked or restored its Decryption Key Safe. */
168
+ startSync(signal?: AbortSignal): Promise<void>;
154
169
  private fileNameKey;
155
170
  private getStateEvent;
156
171
  private getTreeOwnerId;
@@ -201,7 +216,7 @@ export declare class TeleCryptIOStorage {
201
216
  */
202
217
  static createFromOidc(opts: CreateFromOidcOptions): Promise<TeleCryptIOStorage>;
203
218
  /** Shared post-construction bootstrap for `create()`/`createFromOidc()`:
204
- * persistent crypto store, first sync, wrap in a `TeleCryptIOStorage`. */
219
+ * persistent crypto store, optional first sync, wrap in a `TeleCryptIOStorage`. */
205
220
  private static bootstrap;
206
221
  private static throwIfAborted;
207
222
  private static withTimeout;
@@ -9,6 +9,7 @@ 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";
12
13
  const STORAGE_METADATA_EVENT_TYPE = "io.telecrypt.storage.metadata";
13
14
  const STORAGE_METADATA_MSGTYPE = "io.telecrypt.storage.metadata";
14
15
  const GENERIC_TREE_NAME = "Encrypted storage";
@@ -406,12 +407,18 @@ export async function withTreeMutation(client, operation, signal) {
406
407
  }
407
408
  export class TeleCryptIOStorage {
408
409
  client;
410
+ syncOptions;
409
411
  decoratedTreeSpaces = new WeakSet();
410
412
  decoratedFileBranches = new WeakMap();
411
413
  treeNames = new Map();
412
414
  fileNames = new Map();
413
- constructor(client) {
415
+ keySafe;
416
+ syncStarted = false;
417
+ syncInFlight;
418
+ constructor(client, options = {}, syncOptions = {}) {
414
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));
415
422
  // Advanced callers may construct a MatrixClient themselves. The SDK does
416
423
  // not mutate matrix-js-sdk internals: configure that client with the
417
424
  // supported createClient({ fetchFn, localTimeoutMs }) options, or use
@@ -421,6 +428,41 @@ export class TeleCryptIOStorage {
421
428
  getClient() {
422
429
  return this.client;
423
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
+ }
424
466
  fileNameKey(treeId, fileId) {
425
467
  return `${treeId}\u0000${fileId}`;
426
468
  }
@@ -747,10 +789,11 @@ export class TeleCryptIOStorage {
747
789
  return TeleCryptIOStorage.bootstrap(client, opts);
748
790
  }
749
791
  /** Shared post-construction bootstrap for `create()`/`createFromOidc()`:
750
- * persistent crypto store, first sync, wrap in a `TeleCryptIOStorage`. */
792
+ * persistent crypto store, optional first sync, wrap in a `TeleCryptIOStorage`. */
751
793
  static async bootstrap(client, opts) {
752
794
  const progress = opts.onProgress ?? (() => { });
753
795
  const persistent = opts.persistentCryptoStore ?? true;
796
+ let storage;
754
797
  try {
755
798
  progress("Loading encryption engine (WASM)…");
756
799
  await TeleCryptIOStorage.withTimeout(client.initRustCrypto({
@@ -758,27 +801,35 @@ export class TeleCryptIOStorage {
758
801
  cryptoDatabasePrefix: opts.cryptoDatabasePrefix ?? `telecrypt-io-storage::${opts.userId}::${opts.deviceId}`,
759
802
  }), opts.initTimeoutMs ?? 60000, "crypto init", opts.signal);
760
803
  progress("Encryption ready — opening secure store…");
761
- progress("Starting Matrix client…");
762
- // Mark this before awaiting: startClient can start its sync loop and then
763
- // reject while reporting an error from the initial request.
764
- await TeleCryptIOStorage.withTimeout(client.startClient({ initialSyncLimit: opts.initialSyncLimit ?? 10 }), opts.syncTimeoutMs ?? 15000, "client start", opts.signal);
765
- progress("Waiting for first sync with homeserver…");
766
- await TeleCryptIOStorage.waitForFirstSync(client, opts.syncTimeoutMs ?? 15000, opts.signal);
767
- TeleCryptIOStorage.throwIfAborted(opts.signal);
768
- progress("Sync complete.");
769
- return new TeleCryptIOStorage(client);
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;
770
819
  }
771
820
  catch (error) {
772
821
  // stopClient is idempotent in matrix-js-sdk and is also required after
773
822
  // init/timeout failures: crypto startup can have installed listeners or
774
823
  // a sync task before the awaited operation rejects.
775
- try {
776
- client.stopClient();
777
- }
778
- catch (cleanupError) {
779
- throw new AggregateError([error, cleanupError], "storage bootstrap and client cleanup failed", {
780
- cause: error,
781
- });
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
+ }
782
833
  }
783
834
  throw error;
784
835
  }
@@ -992,12 +1043,7 @@ export class TeleCryptIOStorage {
992
1043
  if (signal?.aborted)
993
1044
  throw new StorageError("operation cancelled");
994
1045
  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");
1046
+ await withRecoveryCryptoDeadline(() => crypto.bootstrapCrossSigning({}), signal, "cross-signing bootstrap");
1001
1047
  }
1002
1048
  catch (error) {
1003
1049
  // Cross-signing setup can commit server-side state before a transport
@@ -303,7 +303,8 @@ function isActiveRelationEvent(event) {
303
303
  !Array.isArray(content) &&
304
304
  Object.keys(content).length > 0);
305
305
  }
306
- function activeTreeDirectories(client, tree) {
306
+ async function activeTreeDirectories(storage, tree, signal) {
307
+ const client = storage.getClient();
307
308
  const childEvents = readRelationEvents(client, tree.id, EventType.SpaceChild);
308
309
  if (childEvents === null)
309
310
  throw new StorageError("storage folder state is unavailable");
@@ -316,13 +317,26 @@ function activeTreeDirectories(client, tree) {
316
317
  throw new StorageError("storage folder state is inconsistent");
317
318
  activeChildIds.add(childId);
318
319
  }
319
- const knownChildren = new Map(tree.getDirectories().map((child) => [child.id, child]));
320
- return [...activeChildIds].map((childId) => {
321
- const child = knownChildren.get(childId);
322
- if (!child)
323
- throw new StorageError("storage folder state is inconsistent");
324
- return child;
325
- });
320
+ const directories = await Promise.all([...activeChildIds].map(async (childId) => {
321
+ if (isMarkedTreeDeleted(storage, childId))
322
+ return null;
323
+ try {
324
+ // The parent room's relationship state can arrive before Matrix SDK's
325
+ // MSC3089 TreeSpace projection includes that child. Resolve the exact
326
+ // room named by the active relation instead of treating that brief
327
+ // projection lag as corrupt folder state.
328
+ return await waitForCondition(() => storage.getTree(childId), { timeoutMs: 15000, signal });
329
+ }
330
+ catch (error) {
331
+ if (signal?.aborted)
332
+ throw new StorageError("operation cancelled");
333
+ if (error instanceof ConditionTimeoutError) {
334
+ throw new StorageError("storage folder state is inconsistent");
335
+ }
336
+ throw new StorageError("storage folder state lookup failed", { cause: error });
337
+ }
338
+ }));
339
+ return directories.filter((directory) => directory !== null);
326
340
  }
327
341
  async function unlinkExternalParents(storage, rootId, externalParents, signal) {
328
342
  const client = storage.getClient();
@@ -479,9 +493,6 @@ async function deleteRoomDeterministically(storage, roomId, signal) {
479
493
  function removeRoomFromLocalStore(client, roomId) {
480
494
  client.store?.removeRoom(roomId);
481
495
  }
482
- function isActiveMembership(membership) {
483
- return membership === "join" || membership === "invite";
484
- }
485
496
  function isRevocableMembership(membership) {
486
497
  return membership === "join" || membership === "invite" || membership === "knock";
487
498
  }
@@ -497,6 +508,7 @@ function toArrayBuffer(bytes) {
497
508
  return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
498
509
  }
499
510
  export async function createVault(storage, name, options) {
511
+ await storage.keySafe.requireReady({ signal: options?.signal });
500
512
  return withOperationDeadline(options, async (signal) => {
501
513
  let tree;
502
514
  try {
@@ -516,6 +528,7 @@ export async function createVault(storage, name, options) {
516
528
  }
517
529
  /** Top-level vaults only — excludes subdirectories of an existing tree. */
518
530
  export async function listVaults(storage, options) {
531
+ await storage.keySafe.requireReady({ signal: options?.signal });
519
532
  return withOperationDeadline(options, async (signal) => {
520
533
  const trees = await storage.listTrees(signal);
521
534
  const topLevel = trees.filter((tree) => tree.isTopLevel);
@@ -534,6 +547,7 @@ export function getMyVaultRole(storage, vaultId) {
534
547
  return tree.getPermissions(userId);
535
548
  }
536
549
  export async function joinVault(storage, vaultId, options) {
550
+ await storage.keySafe.requireReady({ signal: options?.signal });
537
551
  return withOperationDeadline(options, async (signal) => {
538
552
  let membership;
539
553
  try {
@@ -555,8 +569,27 @@ export async function joinVault(storage, vaultId, options) {
555
569
  }
556
570
  if (membership === "join")
557
571
  return { vaultId, joined: true };
572
+ const client = storage.getClient();
573
+ const userId = client.getUserId();
574
+ if (userId && client.getRoom(vaultId)?.getMember(userId)?.membership !== "invite") {
575
+ // MatrixClient.joinRoom reads the inviter from its local invite-room
576
+ // state. A to-device history bundle can arrive first in the initial
577
+ // sync, before that stripped invite state has reached the room store;
578
+ // joining without it skips Matrix JS SDK's pending-bundle acceptance.
579
+ try {
580
+ await waitForCondition(() => client.getRoom(vaultId)?.getMember(userId)?.membership === "invite" ? true : null, { timeoutMs: 15000, signal });
581
+ }
582
+ catch (err) {
583
+ if (signal.aborted)
584
+ throw new StorageError("operation cancelled");
585
+ if (err instanceof ConditionTimeoutError) {
586
+ throw new StorageError("the room invitation has not reached this login yet; retry joining", { cause: err });
587
+ }
588
+ throw err;
589
+ }
590
+ }
558
591
  try {
559
- await withRateLimitRetry(() => withMatrixMutationAbort(() => storage.getClient().joinRoom(vaultId), signal), signal);
592
+ await withRateLimitRetry(() => withMatrixMutationAbort(() => client.joinRoom(vaultId), signal), signal);
560
593
  }
561
594
  catch (err) {
562
595
  if (err instanceof MutationOutcomeUnknownError)
@@ -592,6 +625,7 @@ function isReviewedStorageTreeRoom(room) {
592
625
  }
593
626
  /** Rooms where this account is invited and the room looks like a file tree. */
594
627
  export async function listPendingInvites(storage, options) {
628
+ await storage.keySafe.requireReady({ signal: options?.signal });
595
629
  return withOperationDeadline(options, async (signal) => {
596
630
  const client = storage.getClient();
597
631
  const rooms = client.getRooms();
@@ -628,6 +662,7 @@ export async function listPendingInvites(storage, options) {
628
662
  }
629
663
  /** Decline a vault invite (same as leaving before join). */
630
664
  export async function declineInvite(storage, vaultId, options) {
665
+ await storage.keySafe.requireReady({ signal: options?.signal });
631
666
  return withOperationDeadline(options, async (signal) => {
632
667
  const client = storage.getClient();
633
668
  try {
@@ -721,6 +756,7 @@ export async function declineInvite(storage, vaultId, options) {
721
756
  }
722
757
  /** Invites `userId` to the vault as a reader. */
723
758
  export async function shareVault(storage, vaultId, userId, role, options) {
759
+ await storage.keySafe.requireReady({ signal: options?.signal });
724
760
  if (role !== "viewer") {
725
761
  throw new StorageError("storage sharing supports viewers only");
726
762
  }
@@ -743,6 +779,15 @@ export async function shareVault(storage, vaultId, userId, role, options) {
743
779
  if (currentMembers.some((member) => member.userId === userId && member.role === "owner")) {
744
780
  throw new StorageError("share will not demote an existing owner");
745
781
  }
782
+ const client = storage.getClient();
783
+ const crypto = client.getCrypto();
784
+ if (!crypto)
785
+ throw new StorageError("sharing requires initialized encryption");
786
+ const recipient = await client.downloadKeysForUsers([userId]);
787
+ const devices = Object.keys(recipient.device_keys?.[userId] ?? {});
788
+ if (!recipient.master_keys?.[userId] || !recipient.self_signing_keys?.[userId] || devices.length === 0) {
789
+ throw new StorageError("The reader must set up or unlock their Decryption Key Safe before sharing");
790
+ }
746
791
  for (const [index, space] of spaces.entries()) {
747
792
  ensureOperationActive(operation.signal);
748
793
  const members = index === 0
@@ -756,28 +801,21 @@ export async function shareVault(storage, vaultId, userId, role, options) {
756
801
  : await storage.getRoomMembership(space.id, userId, {
757
802
  signal: operation.signal,
758
803
  });
759
- if (isActiveMembership(currentMembership ?? ""))
804
+ if (currentMembership === "join")
760
805
  continue;
761
- try {
762
- await withRateLimitRetry(() => withMatrixMutationAbort(() => space.invite(userId), operation.signal), operation.signal);
763
- completedRoomIds.add(space.id);
764
- }
765
- catch (error) {
766
- if (!(error instanceof MatrixError) || error.errcode !== "M_FORBIDDEN")
767
- throw error;
768
- // Synapse may race another invite/join and answer M_FORBIDDEN. Only
769
- // suppress that typed condition after re-reading authoritative
770
- // membership; never infer it from provider-controlled error text.
771
- const membership = await storage.getRoomMembership(space.id, userId, {
772
- signal: operation.signal,
773
- });
774
- if (!isActiveMembership(membership ?? ""))
775
- throw new StorageError("share failed");
806
+ // Native invite shares the encrypted history before inviting. Retry
807
+ // pending invitations too: an earlier attempt may have sent only an
808
+ // invitation. In particular, never swallow a media-upload 403 merely
809
+ // because an invitation already exists.
810
+ await withRateLimitRetry(() => withMatrixMutationAbort(() => space.invite(userId), operation.signal), operation.signal);
811
+ completedRoomIds.add(space.id);
812
+ const statuses = await Promise.all(devices.map((id) => crypto.getDeviceVerificationStatus(userId, id)));
813
+ if (!statuses.some((status) => status?.signedByOwner)) {
814
+ throw new StorageError("The invitation was sent, but history keys could not be shared; the reader must unlock their Decryption Key Safe, then retry sharing");
776
815
  }
777
816
  }
778
817
  // Storage rooms have one immutable owner power level and readers use
779
- // the room's default user level. Inviting is the complete sharing
780
- // operation; changing per-user power levels would create editors and
818
+ // the room's default user level. Changing per-user power levels would create editors and
781
819
  // let metadata authorship drift away from the owner.
782
820
  }
783
821
  catch (error) {
@@ -790,6 +828,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
790
828
  (error.message === "share failed" ||
791
829
  error.message.includes("owner") ||
792
830
  error.message.includes("current user") ||
831
+ error.message.includes("Decryption Key Safe") ||
793
832
  error.message === "operation cancelled")) {
794
833
  throw error;
795
834
  }
@@ -804,6 +843,7 @@ export async function shareVault(storage, vaultId, userId, role, options) {
804
843
  }
805
844
  }
806
845
  export async function unshareVault(storage, vaultId, userId, options) {
846
+ await storage.keySafe.requireReady({ signal: options?.signal });
807
847
  const operation = createOperationDeadline(options);
808
848
  const completedRoomIds = new Set();
809
849
  try {
@@ -878,6 +918,7 @@ export async function unshareVault(storage, vaultId, userId, options) {
878
918
  }
879
919
  }
880
920
  export async function listMembers(storage, vaultId, options) {
921
+ await storage.keySafe.requireReady({ signal: options?.signal });
881
922
  return withOperationDeadline(options, async (signal) => {
882
923
  const tree = await resolveTree(storage, vaultId, signal);
883
924
  try {
@@ -891,6 +932,7 @@ export async function listMembers(storage, vaultId, options) {
891
932
  });
892
933
  }
893
934
  export async function listFiles(storage, treeId, options) {
935
+ await storage.keySafe.requireReady({ signal: options?.signal });
894
936
  return withOperationDeadline(options, async (signal) => {
895
937
  const tree = await resolveTree(storage, treeId, signal);
896
938
  try {
@@ -913,11 +955,12 @@ export async function listFiles(storage, treeId, options) {
913
955
  }
914
956
  /** Lists the direct child folders of a vault or folder. */
915
957
  export async function listSubfolders(storage, parentId, options) {
958
+ await storage.keySafe.requireReady({ signal: options?.signal });
916
959
  return withOperationDeadline(options, async (signal) => {
917
960
  const tree = await resolveTree(storage, parentId, signal);
918
961
  await storage.refreshRoomState(parentId, { signal });
919
962
  ensureOperationActive(signal);
920
- const directories = activeTreeDirectories(storage.getClient(), tree)
963
+ const directories = (await activeTreeDirectories(storage, tree, signal))
921
964
  .filter((directory) => !isMarkedTreeDeleted(storage, directory.id));
922
965
  return Promise.all(directories.map(async (directory) => ({
923
966
  id: directory.id,
@@ -926,6 +969,7 @@ export async function listSubfolders(storage, parentId, options) {
926
969
  });
927
970
  }
928
971
  export async function createSubfolder(storage, parentId, name, options) {
972
+ await storage.keySafe.requireReady({ signal: options?.signal });
929
973
  validateName(name, "name");
930
974
  return withOperationDeadline(options, async (signal) => {
931
975
  const tree = await resolveTree(storage, parentId, signal);
@@ -971,10 +1015,12 @@ async function renameTree(storage, treeId, name, options) {
971
1015
  }
972
1016
  /** Renames a top-level Vault. */
973
1017
  export async function renameVault(storage, vaultId, name, options) {
1018
+ await storage.keySafe.requireReady({ signal: options?.signal });
974
1019
  return renameTree(storage, vaultId, name, options);
975
1020
  }
976
1021
  /** Renames a nested folder. */
977
1022
  export async function renameFolder(storage, folderId, name, options) {
1023
+ await storage.keySafe.requireReady({ signal: options?.signal });
978
1024
  return renameTree(storage, folderId, name, options);
979
1025
  }
980
1026
  async function deleteTree(storage, treeId, options) {
@@ -1038,13 +1084,16 @@ async function deleteTree(storage, treeId, options) {
1038
1084
  }
1039
1085
  /** Deletes a top-level Vault. */
1040
1086
  export async function deleteVault(storage, vaultId, options) {
1087
+ await storage.keySafe.requireReady({ signal: options?.signal });
1041
1088
  return deleteTree(storage, vaultId, options);
1042
1089
  }
1043
1090
  /** Deletes a nested folder. */
1044
1091
  export async function deleteFolder(storage, folderId, options) {
1092
+ await storage.keySafe.requireReady({ signal: options?.signal });
1045
1093
  return deleteTree(storage, folderId, options);
1046
1094
  }
1047
1095
  export async function renameFile(storage, treeId, fileId, name, options) {
1096
+ await storage.keySafe.requireReady({ signal: options?.signal });
1048
1097
  validateName(name, "file name");
1049
1098
  return withOperationDeadline(options, async (signal) => {
1050
1099
  const tree = await resolveTree(storage, treeId, signal);
@@ -1094,6 +1143,7 @@ async function deleteFileMedia(storage, mediaIds, signal) {
1094
1143
  }
1095
1144
  }
1096
1145
  export async function deleteFile(storage, treeId, fileId, options) {
1146
+ await storage.keySafe.requireReady({ signal: options?.signal });
1097
1147
  return withOperationDeadline(options, async (signal) => {
1098
1148
  const tree = await resolveTree(storage, treeId, signal);
1099
1149
  if (isMarkedFileDeleted(storage, tree.id, fileId))
@@ -1184,6 +1234,7 @@ export async function deleteFile(storage, treeId, fileId, options) {
1184
1234
  }, "mutation");
1185
1235
  }
1186
1236
  export async function uploadFile(storage, treeId, name, bytes, mimetype, options) {
1237
+ await storage.keySafe.requireReady({ signal: options?.signal });
1187
1238
  return withOperationDeadline(options, async (signal) => {
1188
1239
  const tree = await resolveTree(storage, treeId, signal);
1189
1240
  const fileId = await withRateLimitRetry(() => storage.uploadFile(tree, name, toArrayBuffer(bytes), mimetype, signal), signal).catch((error) => {
@@ -1219,6 +1270,7 @@ export async function uploadFile(storage, treeId, name, bytes, mimetype, options
1219
1270
  }, "mutation");
1220
1271
  }
1221
1272
  export async function downloadFile(storage, treeId, fileId, options) {
1273
+ await storage.keySafe.requireReady({ signal: options?.signal });
1222
1274
  return withOperationDeadline(options, async (signal) => {
1223
1275
  const tree = await resolveTree(storage, treeId, signal);
1224
1276
  const branch = await resolveFile(storage, tree, fileId, signal);
@@ -1253,6 +1305,7 @@ function tsToIso(ts) {
1253
1305
  return new Date(ts).toISOString();
1254
1306
  }
1255
1307
  export async function getFileDetails(storage, treeId, fileId, options) {
1308
+ await storage.keySafe.requireReady({ signal: options?.signal });
1256
1309
  return withOperationDeadline(options, async (signal) => {
1257
1310
  const tree = await resolveTree(storage, treeId, signal);
1258
1311
  await resolveFile(storage, tree, fileId, signal);
@@ -1308,9 +1361,11 @@ async function getTreeDetails(storage, treeId, signal) {
1308
1361
  }
1309
1362
  /** Returns details for a top-level Vault. */
1310
1363
  export async function getVaultDetails(storage, vaultId, options) {
1364
+ await storage.keySafe.requireReady({ signal: options?.signal });
1311
1365
  return withOperationDeadline(options, (signal) => getTreeDetails(storage, vaultId, signal));
1312
1366
  }
1313
1367
  /** Returns details for a nested folder. */
1314
1368
  export async function getFolderDetails(storage, folderId, options) {
1369
+ await storage.keySafe.requireReady({ signal: options?.signal });
1315
1370
  return withOperationDeadline(options, (signal) => getTreeDetails(storage, folderId, signal));
1316
1371
  }
@@ -87,3 +87,4 @@ export interface FolderDetails {
87
87
  createdAt: string | null;
88
88
  memberCount: number | null;
89
89
  }
90
+ export type { KeySafeStatus, KeySafeOptions } from "../key-safe.js";
@@ -0,0 +1,62 @@
1
+ import { type MatrixClient } from "matrix-js-sdk";
2
+ export interface KeySafeOptions {
3
+ signal?: AbortSignal;
4
+ }
5
+ export interface KeySafeStatus {
6
+ state: "setup-required" | "confirmation-required" | "restore-required" | "ready";
7
+ recoveryKey?: string;
8
+ }
9
+ export interface KeySafePersistenceOptions {
10
+ persistentCryptoStore?: boolean;
11
+ cryptoDatabasePrefix?: string;
12
+ /** Flush platform crypto persistence before continuing a setup mutation. */
13
+ onKeySafeStateChanged?: () => Promise<void>;
14
+ }
15
+ type WithKey = <T>(key: Uint8Array<ArrayBuffer>, fn: () => Promise<T>, signal?: AbortSignal) => Promise<T>;
16
+ /** The local saved-key acknowledgement and Matrix's existing encryption setup. */
17
+ export declare class DecryptionKeySafe {
18
+ private client;
19
+ private withKey;
20
+ private options;
21
+ private startSync?;
22
+ private memory;
23
+ private tail;
24
+ private secretRecordsRefreshed;
25
+ private refreshedKeyDescriptorId;
26
+ constructor(client: MatrixClient, withKey: WithKey, options?: KeySafePersistenceOptions, startSync?: ((signal?: AbortSignal) => Promise<void>) | undefined);
27
+ private active;
28
+ private crypto;
29
+ private local;
30
+ private save;
31
+ private serial;
32
+ private refreshAccountData;
33
+ /**
34
+ * MatrixClient.getAccountDataFromServer reads its local store after initial
35
+ * sync. A resumed sync can complete before account-data events reach that
36
+ * store, so refresh the existing Safe records from the authenticated server
37
+ * API before native secret-storage code reads them. Seed those exact records
38
+ * through the public Store API used by Matrix sync itself.
39
+ */
40
+ private refreshSecretStorageAccountData;
41
+ private topology;
42
+ private serverBackup;
43
+ private signingReady;
44
+ getStatus(signal?: AbortSignal): Promise<KeySafeStatus>;
45
+ requireReady(options?: KeySafeOptions): Promise<void>;
46
+ /** Re-publish the same native signed records after an interrupted upload. */
47
+ private publishSigning;
48
+ private checkSigningCanResume;
49
+ private configure;
50
+ setup(signal?: AbortSignal): Promise<{
51
+ recoveryKey: string;
52
+ }>;
53
+ confirmSaved(signal?: AbortSignal): Promise<{
54
+ state: "ready";
55
+ }>;
56
+ restore(recoveryKey: string, signal?: AbortSignal): Promise<{
57
+ imported: number;
58
+ total: number;
59
+ state: "ready";
60
+ }>;
61
+ }
62
+ export {};
@@ -0,0 +1,349 @@
1
+ import { MatrixError, MatrixEvent } from "matrix-js-sdk";
2
+ import { Method } from "matrix-js-sdk/lib/http-api/method.js";
3
+ import { ClientPrefix } from "matrix-js-sdk/lib/http-api/prefix.js";
4
+ import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
5
+ import { encodeUri } from "matrix-js-sdk/lib/utils.js";
6
+ import { StorageError } from "./core/errors.js";
7
+ /** The local saved-key acknowledgement and Matrix's existing encryption setup. */
8
+ export class DecryptionKeySafe {
9
+ client;
10
+ withKey;
11
+ options;
12
+ startSync;
13
+ memory = {};
14
+ tail = Promise.resolve();
15
+ secretRecordsRefreshed = false;
16
+ refreshedKeyDescriptorId = null;
17
+ constructor(client, withKey, options = {}, startSync) {
18
+ this.client = client;
19
+ this.withKey = withKey;
20
+ this.options = options;
21
+ this.startSync = startSync;
22
+ }
23
+ active(signal) {
24
+ if (signal?.aborted)
25
+ throw new StorageError("operation cancelled");
26
+ }
27
+ crypto() {
28
+ const crypto = this.client.getCrypto();
29
+ if (!crypto)
30
+ throw new StorageError("encryption is not initialized");
31
+ return crypto;
32
+ }
33
+ async local(write) {
34
+ if (this.options.persistentCryptoStore === false) {
35
+ if (write)
36
+ this.memory = write;
37
+ return this.memory;
38
+ }
39
+ const prefix = this.options.cryptoDatabasePrefix ?? `telecrypt-io-storage::${this.client.getUserId()}::${this.client.getDeviceId()}`;
40
+ const database = await new Promise((resolve, reject) => {
41
+ const request = indexedDB.open(`${prefix}::key-safe`, 1);
42
+ request.onupgradeneeded = () => request.result.createObjectStore("state");
43
+ request.onsuccess = () => resolve(request.result);
44
+ request.onerror = () => reject(request.error);
45
+ });
46
+ try {
47
+ return await new Promise((resolve, reject) => {
48
+ const transaction = database.transaction("state", write ? "readwrite" : "readonly");
49
+ const store = transaction.objectStore("state");
50
+ const request = write ? store.put(write, "setup") : store.get("setup");
51
+ transaction.oncomplete = () => resolve(write ?? request.result ?? {});
52
+ transaction.onerror = () => reject(transaction.error);
53
+ transaction.onabort = () => reject(transaction.error ?? new Error("key safe persistence aborted"));
54
+ });
55
+ }
56
+ finally {
57
+ database.close();
58
+ }
59
+ }
60
+ async save(value) {
61
+ const previous = await this.local();
62
+ await this.local(value);
63
+ try {
64
+ await this.options.onKeySafeStateChanged?.();
65
+ }
66
+ catch (error) {
67
+ await this.local(previous);
68
+ throw error;
69
+ }
70
+ }
71
+ serial(operation) {
72
+ const result = this.tail.then(operation);
73
+ this.tail = result.catch(() => undefined);
74
+ return result;
75
+ }
76
+ async refreshAccountData(type) {
77
+ const user = this.client.getUserId();
78
+ if (!user)
79
+ throw new StorageError("Matrix login is not initialized");
80
+ const path = encodeUri("/user/$userId/account_data/$type", { $userId: user, $type: type });
81
+ let content;
82
+ try {
83
+ content = await this.client.http.authedRequest(Method.Get, path, undefined, undefined, { prefix: ClientPrefix.V3 });
84
+ }
85
+ catch (error) {
86
+ if (!(error instanceof MatrixError) || error.errcode !== "M_NOT_FOUND")
87
+ throw error;
88
+ content = null;
89
+ }
90
+ // An absent account-data event is not itself an event to cache. In
91
+ // particular, MatrixEvent normalizes null content to {}, which would make
92
+ // SecretStorage treat an absent secret as malformed content.
93
+ if (content !== null) {
94
+ this.client.store.storeAccountDataEvents([new MatrixEvent({ type, sender: user, content })]);
95
+ }
96
+ return content;
97
+ }
98
+ /**
99
+ * MatrixClient.getAccountDataFromServer reads its local store after initial
100
+ * sync. A resumed sync can complete before account-data events reach that
101
+ * store, so refresh the existing Safe records from the authenticated server
102
+ * API before native secret-storage code reads them. Seed those exact records
103
+ * through the public Store API used by Matrix sync itself.
104
+ */
105
+ async refreshSecretStorageAccountData(force = false) {
106
+ const defaultKey = await this.refreshAccountData("m.secret_storage.default_key");
107
+ const keyId = typeof defaultKey?.key === "string" ? defaultKey.key : null;
108
+ if (keyId && (force || this.refreshedKeyDescriptorId !== keyId)) {
109
+ await this.refreshAccountData(`m.secret_storage.key.${keyId}`);
110
+ this.refreshedKeyDescriptorId = keyId;
111
+ }
112
+ if (force || !this.secretRecordsRefreshed) {
113
+ await Promise.all([
114
+ this.refreshAccountData("m.cross_signing.master"),
115
+ this.refreshAccountData("m.cross_signing.self_signing"),
116
+ this.refreshAccountData("m.cross_signing.user_signing"),
117
+ this.refreshAccountData("m.megolm_backup.v1"),
118
+ ]);
119
+ this.secretRecordsRefreshed = true;
120
+ }
121
+ return keyId;
122
+ }
123
+ async topology(forceAccountDataRefresh = false) {
124
+ const [defaultKeyId, backup] = await Promise.all([
125
+ this.refreshSecretStorageAccountData(forceAccountDataRefresh), this.serverBackup(),
126
+ ]);
127
+ const key = await this.client.secretStorage.getKey();
128
+ return { key, defaultKeyId, backup };
129
+ }
130
+ async serverBackup() {
131
+ // CryptoApi.getKeyBackupInfo caches absence and maps request failures to
132
+ // null. Neither can authorize creating a new server backup on retry.
133
+ try {
134
+ return await this.client.http.authedRequest(Method.Get, "/room_keys/version", undefined, undefined, { prefix: ClientPrefix.V3 });
135
+ }
136
+ catch (error) {
137
+ if (error instanceof MatrixError && error.errcode === "M_NOT_FOUND")
138
+ return null;
139
+ throw error;
140
+ }
141
+ }
142
+ async signingReady() {
143
+ const crypto = this.crypto();
144
+ const user = this.client.getUserId();
145
+ await crypto.userHasCrossSigningKeys(user, true);
146
+ const [account, device, ready] = await Promise.all([
147
+ crypto.getUserVerificationStatus(user),
148
+ crypto.getDeviceVerificationStatus(user, this.client.getDeviceId()),
149
+ crypto.isCrossSigningReady(),
150
+ ]);
151
+ return ready && account.isVerified() && device?.signedByOwner === true;
152
+ }
153
+ getStatus(signal) {
154
+ const options = { signal };
155
+ return this.serial(async () => {
156
+ this.active(options.signal);
157
+ const local = await this.local();
158
+ if (local.recoveryKey)
159
+ return { state: "confirmation-required", recoveryKey: local.recoveryKey };
160
+ const { key, defaultKeyId, backup } = await this.topology();
161
+ if (!key && !defaultKeyId && !backup)
162
+ return { state: "setup-required" };
163
+ if (local.confirmed && key?.[0] === local.keyId && backup?.version === local.backupVersion && await this.signingReady()) {
164
+ await this.crypto().checkKeyBackupAndEnable();
165
+ if (await this.crypto().getActiveSessionBackupVersion() === backup?.version) {
166
+ this.active(options.signal);
167
+ return { state: "ready" };
168
+ }
169
+ }
170
+ this.active(options.signal);
171
+ return { state: "restore-required" };
172
+ });
173
+ }
174
+ async requireReady(options = {}) {
175
+ const status = await this.getStatus(options.signal);
176
+ if (status.state !== "ready")
177
+ throw new StorageError("Set up or unlock the Decryption Key Safe before using storage");
178
+ }
179
+ /** Re-publish the same native signed records after an interrupted upload. */
180
+ async publishSigning(signal) {
181
+ const crypto = this.crypto();
182
+ const user = this.client.getUserId();
183
+ const cached = await crypto.getUserCrossSigningKeys(user);
184
+ const server = await this.client.downloadKeysForUsers([user]);
185
+ if (!cached?.master_key || !cached.self_signing_key || !cached.user_signing_key) {
186
+ throw new StorageError("account signing setup is incomplete; unlock the existing Decryption Key Safe");
187
+ }
188
+ for (const [remote, local] of [
189
+ [server.master_keys?.[user], cached.master_key],
190
+ [server.self_signing_keys?.[user], cached.self_signing_key],
191
+ [server.user_signing_keys?.[user], cached.user_signing_key],
192
+ ]) {
193
+ if (remote && JSON.stringify(remote.keys) !== JSON.stringify(local.keys)) {
194
+ throw new StorageError("account signing keys changed; unlock the existing Decryption Key Safe");
195
+ }
196
+ }
197
+ this.active(signal);
198
+ await this.client.http.authedRequest(Method.Post, "/keys/device_signing/upload", undefined, cached, { prefix: ClientPrefix.V3 });
199
+ this.active(signal);
200
+ await crypto.crossSignDevice(this.client.getDeviceId());
201
+ await crypto.userHasCrossSigningKeys(user, true);
202
+ }
203
+ async checkSigningCanResume() {
204
+ const user = this.client.getUserId();
205
+ const [cached, server, status] = await Promise.all([
206
+ this.crypto().getUserCrossSigningKeys(user),
207
+ this.client.downloadKeysForUsers([user]),
208
+ this.crypto().getCrossSigningStatus(),
209
+ ]);
210
+ // A new login may defer room sync until the Safe is restored. Ensure the
211
+ // Rust crypto store has the account's public signing keys before importing
212
+ // their private copies from secret storage; the HTTP download above alone
213
+ // does not populate that native identity cache.
214
+ await this.crypto().userHasCrossSigningKeys(user, true);
215
+ const remote = server.master_keys?.[user];
216
+ const hasPrivateKeys = Object.values(status.privateKeysCachedLocally).every(Boolean);
217
+ if (remote && !status.privateKeysInSecretStorage && !hasPrivateKeys) {
218
+ throw new StorageError("Use the existing account signing keys; automatic replacement is not allowed");
219
+ }
220
+ if (remote && hasPrivateKeys && cached?.master_key && JSON.stringify(remote.keys) !== JSON.stringify(cached.master_key.keys)) {
221
+ throw new StorageError("Account signing keys changed; this login must unlock the existing Decryption Key Safe");
222
+ }
223
+ }
224
+ async configure(encoded, signal) {
225
+ const crypto = this.crypto();
226
+ const privateKey = decodeRecoveryKey(encoded);
227
+ const { key, defaultKeyId, backup } = await this.topology(true);
228
+ if (!key && defaultKeyId)
229
+ throw new StorageError("The existing Decryption Key Safe is incomplete; it will not be replaced");
230
+ if (key && !(await this.client.secretStorage.checkKey(privateKey, key[1]))) {
231
+ throw new StorageError("This key does not unlock the existing Decryption Key Safe");
232
+ }
233
+ await this.withKey(privateKey, async () => {
234
+ await this.checkSigningCanResume();
235
+ this.active(signal);
236
+ // Establish the encrypted account-data store first. Native cross-signing
237
+ // then saves its private keys there BEFORE publishing public keys. This
238
+ // keeps a CLI crash between native requests recoverable with the pending
239
+ // saved key, even before its next IndexedDB snapshot reaches disk.
240
+ if (!key) {
241
+ await crypto.bootstrapSecretStorage({
242
+ createSecretStorageKey: async () => ({ privateKey, encodedPrivateKey: encoded, keyInfo: {} }),
243
+ setupNewKeyBackup: false,
244
+ });
245
+ }
246
+ this.active(signal);
247
+ // Omitting authUploadDeviceSigningKeys sends the real initial upload.
248
+ await crypto.bootstrapCrossSigning({});
249
+ await this.publishSigning(signal);
250
+ this.active(signal);
251
+ await crypto.bootstrapSecretStorage({
252
+ createSecretStorageKey: async () => ({ privateKey, encodedPrivateKey: encoded, keyInfo: {} }),
253
+ setupNewKeyBackup: backup === null,
254
+ });
255
+ this.active(signal);
256
+ await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
257
+ await crypto.checkKeyBackupAndEnable();
258
+ if (!(await this.signingReady()) || !(await crypto.getActiveSessionBackupVersion())) {
259
+ throw new StorageError("Decryption Key Safe setup has not finished; retry with the same saved key");
260
+ }
261
+ }, signal);
262
+ }
263
+ setup(signal) {
264
+ const options = { signal };
265
+ return this.serial(async () => {
266
+ this.active(options.signal);
267
+ let local = await this.local();
268
+ if (!local.recoveryKey) {
269
+ const { key, defaultKeyId, backup } = await this.topology(true);
270
+ if (key || defaultKeyId || backup)
271
+ throw new StorageError("A Decryption Key Safe already exists; enter its saved key to unlock it");
272
+ const generated = await this.crypto().createRecoveryKeyFromPassphrase();
273
+ if (!generated.encodedPrivateKey)
274
+ throw new StorageError("Could not create the Decryption Key Safe key");
275
+ local = { recoveryKey: generated.encodedPrivateKey };
276
+ await this.save(local);
277
+ }
278
+ this.active(options.signal);
279
+ await this.configure(local.recoveryKey, options.signal);
280
+ return { recoveryKey: local.recoveryKey };
281
+ });
282
+ }
283
+ confirmSaved(signal) {
284
+ const options = { signal };
285
+ return this.serial(async () => {
286
+ this.active(options.signal);
287
+ const local = await this.local();
288
+ if (!local.recoveryKey)
289
+ throw new StorageError("Set up the Decryption Key Safe and save its key before confirming");
290
+ await this.configure(local.recoveryKey, options.signal);
291
+ const { key, defaultKeyId, backup } = await this.topology(true);
292
+ if (!key && defaultKeyId)
293
+ throw new StorageError("The existing Decryption Key Safe is incomplete; it will not be replaced");
294
+ if (!key || !backup?.version)
295
+ throw new StorageError("Decryption Key Safe setup is incomplete");
296
+ this.active(options.signal);
297
+ await this.save({ confirmed: true, keyId: key[0], backupVersion: backup.version });
298
+ return { state: "ready" };
299
+ });
300
+ }
301
+ restore(recoveryKey, signal) {
302
+ const options = { signal };
303
+ return this.serial(async () => {
304
+ this.active(options.signal);
305
+ let privateKey;
306
+ try {
307
+ privateKey = decodeRecoveryKey(recoveryKey);
308
+ }
309
+ catch (cause) {
310
+ throw new StorageError("Invalid Decryption Key Safe key", { cause });
311
+ }
312
+ const { key, defaultKeyId, backup } = await this.topology(true);
313
+ if (!key && defaultKeyId)
314
+ throw new StorageError("The existing Decryption Key Safe is incomplete; it will not be replaced");
315
+ if (!key || !(await this.client.secretStorage.checkKey(privateKey, key[1]))) {
316
+ throw new StorageError("This key does not unlock the existing Decryption Key Safe");
317
+ }
318
+ // An interrupted first setup may have saved secret storage before it
319
+ // created the backup. Complete those missing steps using this same key.
320
+ if (!backup)
321
+ await this.configure(recoveryKey, options.signal);
322
+ return this.withKey(privateKey, async () => {
323
+ const status = await this.crypto().getCrossSigningStatus();
324
+ if (!status.privateKeysInSecretStorage && !Object.values(status.privateKeysCachedLocally).every(Boolean)) {
325
+ throw new StorageError("Account signing keys are missing from the Decryption Key Safe; they will not be replaced");
326
+ }
327
+ await this.checkSigningCanResume();
328
+ this.active(options.signal);
329
+ await this.crypto().bootstrapCrossSigning({});
330
+ await this.publishSigning(options.signal);
331
+ await this.crypto().loadSessionBackupPrivateKeyFromSecretStorage();
332
+ await this.crypto().checkKeyBackupAndEnable();
333
+ const result = await this.crypto().restoreKeyBackup();
334
+ // Restore room keys before starting history sync. That first sync also
335
+ // publishes this login's device keys so Matrix can complete its
336
+ // account-signing check.
337
+ await this.startSync?.(options.signal);
338
+ if (!(await this.signingReady()))
339
+ throw new StorageError("This login's account signing setup is incomplete");
340
+ this.active(options.signal);
341
+ const currentBackup = await this.serverBackup();
342
+ if (!currentBackup?.version)
343
+ throw new StorageError("Decryption Key Safe backup is unavailable");
344
+ await this.save({ confirmed: true, keyId: key[0], backupVersion: currentBackup.version });
345
+ return { imported: result.imported, total: result.total, state: "ready" };
346
+ }, options.signal);
347
+ });
348
+ }
349
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telecrypt-io/storage",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "license": "BUSL-1.1",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.19.0",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "matrix-encrypt-attachment": "1.0.3",
42
- "matrix-js-sdk": "42.2.0"
42
+ "matrix-js-sdk": "42.4.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "24.13.3",