@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 +28 -3
- package/dist/TeleCryptIOStorage.d.ts +17 -2
- package/dist/TeleCryptIOStorage.js +70 -24
- package/dist/core/operations.js +86 -31
- 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
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
package/dist/core/operations.js
CHANGED
|
@@ -303,7 +303,8 @@ function isActiveRelationEvent(event) {
|
|
|
303
303
|
!Array.isArray(content) &&
|
|
304
304
|
Object.keys(content).length > 0);
|
|
305
305
|
}
|
|
306
|
-
function activeTreeDirectories(
|
|
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
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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(() =>
|
|
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 (
|
|
804
|
+
if (currentMembership === "join")
|
|
760
805
|
continue;
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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.
|
|
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
|
|
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
|
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/key-safe.js
ADDED
|
@@ -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.
|
|
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.
|
|
42
|
+
"matrix-js-sdk": "42.4.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/node": "24.13.3",
|