@telecrypt-io/storage 0.3.0 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,16 +5,17 @@
5
5
  End-to-end encrypted file storage and sharing, built on Matrix.
6
6
 
7
7
  Files are encrypted on the client before upload. The server stores only opaque ciphertext and
8
- never holds the decryption keys. Shared folders let multiple people add and read files, and a
8
+ never holds the decryption keys. Shared vaults let multiple people add and read files, and a
9
9
  Recovery Key restores your files on a new device — even if you lose the original.
10
10
 
11
- This repository publishes the library package from protected exact-version tags with NPM Trusted
12
- Publishing/OIDC provenance. The command-line client is maintained in its own repository.
11
+ This repository publishes the library package from annotated exact-version tags whose resulting
12
+ GitHub Release is immutable, with NPM Trusted Publishing/OIDC provenance. The command-line client
13
+ is maintained in its own repository.
13
14
 
14
15
  ## Install
15
16
 
16
17
  ```bash
17
- npm install @telecrypt-io/storage@0.3.0
18
+ npm install @telecrypt-io/storage@0.5.7
18
19
  ```
19
20
 
20
21
  This gives you the `TeleCryptIOStorage` library and its browser-safe `core` API.
@@ -30,40 +31,86 @@ import * as core from "@telecrypt-io/storage/core";
30
31
  const storage = await TeleCryptIOStorage.create({
31
32
  baseUrl, userId, accessToken, deviceId,
32
33
  });
33
- const folder = await core.createFolder(storage, "Photos");
34
- await core.uploadFile(storage, folder.id, "cat.jpg", bytes, "image/jpeg");
34
+ const vault = await core.createVault(storage, "Photos");
35
+ await core.uploadFile(storage, vault.id, "cat.jpg", bytes, "image/jpeg");
35
36
  ```
36
37
 
38
+ The recommended constructors configure the Matrix client with the SDK's bounded, manual-redirect
39
+ transport and finite request deadline. The public `new TeleCryptIOStorage(client)` constructor is
40
+ an advanced escape hatch: it does not rewrite matrix-js-sdk internals. Callers using it must build
41
+ the client through matrix-js-sdk's supported `createClient` options (`fetchFn` and
42
+ `localTimeoutMs`) and remain responsible for the safety of other Matrix SDK requests. SDK methods
43
+ that need the authenticated transport fail closed when it is unavailable; they do not silently
44
+ install a private transport configuration.
45
+
46
+ Same-name vault/folder creation is serialized within one `TeleCryptIOStorage` instance, but
47
+ each call creates a distinct room. Display names are labels, not identities, and Matrix
48
+ provides no server-side room-creation idempotency key.
49
+
50
+ ## OIDC API
51
+
52
+ The SDK owns the browser authorization-code PKCE context in `sessionStorage` and provides device-code,
53
+ discovery, dynamic registration, identity confirmation, and refresh helpers. OAuth metadata, token
54
+ scopes, Matrix user/device identities, redirects, and response bodies are validated and bounded before
55
+ they are returned. Network-bound helpers accept `AbortSignal` where cancellation is meaningful; token
56
+ refresh uses a public client and persists the resulting token pair through the caller's callback.
57
+
37
58
  The command-line client is sourced by
38
59
  [`TeleCrypt-io/storage-cli`](https://github.com/TeleCrypt-io/storage-cli). The static web
39
60
  application is sourced by
40
61
  [`TeleCrypt-io/storage.telecrypt.io`](https://github.com/TeleCrypt-io/storage.telecrypt.io).
41
62
 
63
+ ### 0.5 OIDC migration
64
+
65
+ Version 0.5 targets Matrix JS SDK 42 and the stable Matrix OAuth metadata endpoint. Existing
66
+ integrations must use the current `createFromOidc` options and pass the confirmed Matrix user ID,
67
+ device ID, and access token returned by the login flow. Replace old Matrix SDK OIDC imports and
68
+ private HTTP discovery with `discoverOidcIssuer`, `registerClient`, the device-code or PKCE flow
69
+ helpers, and `whoAmI` from `core/oidc`; the discovery URL is the stable
70
+ `/_matrix/client/v1/auth_metadata` endpoint. Refresh integration uses
71
+ `buildTokenRefreshFunction` as the current `tokenRefreshFunction` callback and must persist the
72
+ returned token pair before reporting success. The SDK no longer carries pre-0.5 compatibility
73
+ paths, so no adapter-side shim is required or supported. Discovery accepts providers that omit
74
+ `revocation_endpoint`; revocation is only an opportunistic cleanup path after refresh persistence
75
+ fails. Device-code sessions retain their requested device ID in-process; callers that reconstruct a
76
+ session must pass its expected device ID to `waitForDeviceCodeLogin`. `buildTokenRefreshFunction`
77
+ always requires the expected device ID because a reconstructed refresh callback has no safe implicit
78
+ binding. Returned OAuth scopes are rejected unless they match that intended Matrix device exactly.
79
+
42
80
  ## How it works
43
81
 
44
82
  Built on [MSC3089](https://github.com/matrix-org/matrix-spec-proposals/pull/3089), which models
45
83
  a file tree using Matrix primitives:
46
84
 
47
- | File-system concept | Matrix concept |
85
+ | Storage concept | Matrix concept |
48
86
  |---|---|
49
- | Folder | A Space (room marked as a file tree) |
50
- | Subfolder | A child Space |
87
+ | Vault | A Space (room marked as a file tree) |
88
+ | Folder | A child Space |
51
89
  | File | An event pointing at encrypted uploaded content |
52
90
  | Version | A newer event superseding the old |
53
91
  | Sharing | Room invitation |
54
92
  | Permissions | Power levels |
55
93
 
94
+ ### Deletion
95
+
96
+ Vaults and folders are deleted only when they are empty. Delete each file first; deleting a file
97
+ also removes its complete Matrix version chain and reports a typed partial result if cleanup stops
98
+ after some versions. Delete empty child folders before deleting their parent. A nonempty delete
99
+ fails with `NonEmptyTreeError`, so a shared or nested tree is never removed implicitly.
100
+
56
101
  Encryption uses the same scheme as Matrix attachments (AES-CTR with a per-file key, keys
57
- distributed via the room's Megolm session). Requires no server-side changes — it runs against
58
- stock Synapse.
102
+ distributed via the room's Megolm session). File deletion uses TeleCrypt's authenticated Synapse
103
+ storage extension to remove the local media and thumbnail objects before Matrix redaction; stock
104
+ Synapse without that extension cannot provide the SDK deletion contract.
59
105
 
60
106
  ## Development
61
107
 
62
108
  ```bash
63
- npm ci
109
+ npm ci --ignore-scripts --no-fund --no-audit
64
110
  npm run synapse:up # disposable local Synapse/MAS fixture
65
111
  npm test
66
112
  npm run synapse:down
113
+ # For a complete reset, use ./throwaway_synapse/down.sh --wipe.
67
114
  ```
68
115
 
69
116
  Tests run against a real local Synapse/MAS fixture in Podman, never against a production server.
@@ -80,6 +127,4 @@ For commercial licensing, contact TeleCrypt.io.
80
127
  ## Third-party code
81
128
 
82
129
  - [`matrix-js-sdk`](https://github.com/matrix-org/matrix-js-sdk) — Apache-2.0 — dependency
83
- - [`matrix-files-sdk`](https://github.com/matrix-org/matrix-files-sdk) — Apache-2.0 — reference
84
- - [`files-sdk-demo`](https://github.com/vector-im/files-sdk-demo) — AGPL-3.0 — **not used**;
85
- incompatible with this project's licence
130
+ - [`matrix-encrypt-attachment`](https://github.com/matrix-org/matrix-encrypt-attachment) — Apache-2.0 — dependency
@@ -1,6 +1,7 @@
1
1
  import { MatrixClient } 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
+ import type { RecoveryStatus } from "./core/types.js";
4
5
  export interface TreeSpace {
5
6
  readonly id: string;
6
7
  readonly room: {
@@ -20,7 +21,7 @@ export interface TreeSpace {
20
21
  getFile(fileEventId: string): FileBranch | null;
21
22
  listFiles(): FileBranch[];
22
23
  listAllFiles(): FileBranch[];
23
- createFile(name: string, encryptedContents: Buffer | ArrayBuffer | Uint8Array, info: Record<string, unknown>, additionalContent?: Record<string, unknown>): Promise<{
24
+ createFile(name: string, encryptedContents: ArrayBuffer | Uint8Array, info: Record<string, unknown>, additionalContent?: Record<string, unknown>): Promise<{
24
25
  event_id: string;
25
26
  }>;
26
27
  }
@@ -39,13 +40,15 @@ export interface FileBranch {
39
40
  getContent: () => Record<string, unknown>;
40
41
  }>;
41
42
  getVersionHistory(): Promise<FileBranch[]>;
42
- createNewVersion(name: string, encryptedContents: Buffer | ArrayBuffer | Uint8Array, info: Record<string, unknown>, additionalContent?: Record<string, unknown>): Promise<{
43
+ createNewVersion(name: string, encryptedContents: ArrayBuffer | Uint8Array, info: Record<string, unknown>, additionalContent?: Record<string, unknown>): Promise<{
43
44
  event_id: string;
44
45
  }>;
45
46
  }
46
47
  export interface CreateTeleCryptIOStorageOptions {
47
48
  /** Matrix homeserver base URL, e.g. "https://matrix.example.com". */
48
49
  baseUrl: string;
50
+ /** Independently trusted Matrix server name for this deployment. */
51
+ serverName: string;
49
52
  userId: string;
50
53
  accessToken: string;
51
54
  deviceId: string;
@@ -73,6 +76,8 @@ export interface CreateTeleCryptIOStorageOptions {
73
76
  initTimeoutMs?: number;
74
77
  /** Optional progress reporter for UI/CLI status lines during bootstrap. */
75
78
  onProgress?: (message: string) => void;
79
+ /** Cancels bootstrap and stops the owned MatrixClient before rejecting. */
80
+ signal?: AbortSignal;
76
81
  /**
77
82
  * Optional platform-supplied crypto callbacks (e.g. to source the secret
78
83
  * storage key from an OS keychain instead of prompting). `keys.setupRecovery`
@@ -91,6 +96,8 @@ export interface CreateTeleCryptIOStorageOptions {
91
96
  */
92
97
  export interface CreateFromOidcOptions {
93
98
  baseUrl: string;
99
+ /** Independently trusted Matrix server name for this deployment. */
100
+ serverName: string;
94
101
  userId: string;
95
102
  accessToken: string;
96
103
  deviceId: string;
@@ -99,10 +106,8 @@ export interface CreateFromOidcOptions {
99
106
  * refreshed mid-request. */
100
107
  refreshToken?: string;
101
108
  /** See `src/core/oidc.ts`'s `buildTokenRefreshFunction` — a plain
102
- * `(refreshToken) => Promise<AccessTokens>`, deliberately NOT
103
- * matrix-js-sdk's `OidcTokenRefresher` (see that file's doc comment for
104
- * why: it requires `window.sessionStorage`, which doesn't exist under
105
- * Node). */
109
+ * `(refreshToken) => Promise<AccessTokens>` that keeps token response
110
+ * validation and adapter-specific persistence in the shared core. */
106
111
  tokenRefreshFunction?: TokenRefreshFunction;
107
112
  persistentCryptoStore?: boolean;
108
113
  cryptoDatabasePrefix?: string;
@@ -110,10 +115,33 @@ export interface CreateFromOidcOptions {
110
115
  syncTimeoutMs?: number;
111
116
  initTimeoutMs?: number;
112
117
  onProgress?: (message: string) => void;
118
+ /** Cancels bootstrap and stops the owned MatrixClient before rejecting. */
119
+ signal?: AbortSignal;
113
120
  cryptoCallbacks?: CryptoCallbacks;
114
121
  }
122
+ /** Waits for one Matrix mutation to settle without aborting the shared client.
123
+ * If cancellation arrives after the mutation starts, the outcome is explicitly
124
+ * unknown: the caller must reconcile and may safely retry idempotent work. */
125
+ export declare function withMatrixMutationAbort<T>(_client: MatrixClient, operation: () => Promise<T>, signal?: AbortSignal, operationName?: string): Promise<T>;
126
+ export interface MatrixRequestOptions {
127
+ signal?: AbortSignal;
128
+ timeoutMs?: number;
129
+ /** Matrix SDK transport deadline, when making a direct authenticated request. */
130
+ localTimeoutMs?: number;
131
+ /** Internal Matrix SDK spelling used by `authedRequest`. */
132
+ abortSignal?: AbortSignal;
133
+ }
134
+ /**
135
+ * Serializes all tree mutations for one MatrixClient. A single queue is
136
+ * intentional: separate per-room locks can deadlock when a parent mutation
137
+ * needs to inspect or update a child while another operation does the inverse.
138
+ * Each invocation still creates its own room; Matrix has no server-side
139
+ * room-creation idempotency key for MSC3089.
140
+ */
141
+ export declare function withTreeMutation<T>(client: MatrixClient, operation: (signal?: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T>;
115
142
  export declare class TeleCryptIOStorage {
116
143
  private client;
144
+ private readonly decoratedTreeSpaces;
117
145
  constructor(client: MatrixClient);
118
146
  /** The underlying matrix-js-sdk client (e.g. to stop it, or for advanced/interop use). */
119
147
  getClient(): MatrixClient;
@@ -123,7 +151,7 @@ export declare class TeleCryptIOStorage {
123
151
  * otherwise list a stale state snapshot immediately after another process
124
152
  * changed a file name or branch.
125
153
  */
126
- refreshRoomState(roomId: string): Promise<void>;
154
+ refreshRoomState(roomId: string, options?: MatrixRequestOptions): Promise<void>;
127
155
  /**
128
156
  * Recommended entry point: builds the MatrixClient with a persistent crypto
129
157
  * store and the secret-storage callback wiring `keys.*` needs, starts the
@@ -135,21 +163,25 @@ export declare class TeleCryptIOStorage {
135
163
  * construction time (see `cryptoCallbacks` above for why: matrix-js-sdk
136
164
  * captures a single callbacks object at MatrixClient construction, so it
137
165
  * must exist before `initRustCrypto` runs, not be added afterwards).
138
- */
166
+ */
139
167
  static create(opts: CreateTeleCryptIOStorageOptions): Promise<TeleCryptIOStorage>;
140
168
  /**
141
169
  * As `create()`, but the resulting `MatrixClient` was authenticated via
142
170
  * OIDC/MAS (device-code or authorization-code+PKCE — see
143
171
  * `src/core/oidc.ts`) rather than `m.login.password`. If `refreshToken` +
144
- * `tokenRefresher` are both given, the client's `tokenRefreshFunction` is
172
+ * `tokenRefreshFunction` are both given, the client's `tokenRefreshFunction` is
145
173
  * wired so an expired access token is transparently refreshed mid-request
146
174
  * — same mechanism matrix-js-sdk uses for any refresh-token-capable login.
147
- */
175
+ */
148
176
  static createFromOidc(opts: CreateFromOidcOptions): Promise<TeleCryptIOStorage>;
149
177
  /** Shared post-construction bootstrap for `create()`/`createFromOidc()`:
150
178
  * persistent crypto store, first sync, wrap in a `TeleCryptIOStorage`. */
151
179
  private static bootstrap;
180
+ private static throwIfAborted;
152
181
  private static withTimeout;
182
+ /** Bounds a Matrix mutation without cancelling a shared client's other
183
+ * requests. A timeout means the server may have committed the mutation. */
184
+ private static withMutationTimeout;
153
185
  /** Wait until the client reaches Prepared/Syncing. Ignores transient
154
186
  * pre-sync states instead of rejecting on the first Sync event (which can
155
187
  * race `startClient`), and handles the case where sync already completed
@@ -164,14 +196,19 @@ export declare class TeleCryptIOStorage {
164
196
  * object in the first place (see `create()`'s doc comment).
165
197
  */
166
198
  private withSecretStorageKey;
199
+ private withRecoveryMutation;
167
200
  /**
168
201
  * Bootstraps cross-signing and secret storage with a brand-new server-side
169
202
  * key backup, and returns the Recovery Key for the caller to show the user.
170
203
  * This is what makes `restoreFromRecoveryKey` on another device possible.
171
204
  */
172
205
  private keysSetupRecovery;
206
+ private performKeysSetupRecovery;
173
207
  /** Is there an active key backup and a ready secret storage right now? */
174
208
  private keysIsRecoverySetup;
209
+ private performKeysIsRecoverySetup;
210
+ /** Returns one consistent recovery topology snapshot without exposing raw crypto callbacks. */
211
+ private keysGetRecoveryStatus;
175
212
  /**
176
213
  * On a new device: unlocks secret storage with the Recovery Key, loads the
177
214
  * key-backup decryption key out of secret storage, and restores the key
@@ -182,55 +219,51 @@ export declare class TeleCryptIOStorage {
182
219
  * storage / key backup.
183
220
  */
184
221
  private keysRestoreFromRecoveryKey;
222
+ private performKeysRestoreFromRecoveryKey;
185
223
  /** Key-management API: setup, status, and restore for server-side Secure Backup. */
186
224
  get keys(): {
187
- setupRecovery: () => Promise<{
225
+ setupRecovery: (signal?: AbortSignal) => Promise<{
188
226
  recoveryKey: string;
189
227
  }>;
190
- isRecoverySetup: () => Promise<boolean>;
191
- restoreFromRecoveryKey: (recoveryKey: string) => Promise<{
228
+ isRecoverySetup: (signal?: AbortSignal) => Promise<boolean>;
229
+ getStatus: (signal?: AbortSignal) => Promise<RecoveryStatus>;
230
+ restoreFromRecoveryKey: (recoveryKey: string, signal?: AbortSignal) => Promise<{
192
231
  imported: number;
193
232
  total: number;
194
233
  }>;
195
234
  };
196
235
  /**
197
- * Creates a new top-level shared folder (tree space).
236
+ * Creates a new top-level shared vault (tree space).
198
237
  *
199
- * Works around a genuine race in matrix-js-sdk's own
200
- * `unstableCreateFileTree()`: it creates the room via a plain
201
- * `createRoom()` HTTP call, then immediately wraps the result by looking
202
- * the room up in the *local* client store (`new MSC3089TreeSpace(this,
203
- * roomId)` calls `client.getRoom(roomId)` and throws `Error("Unknown
204
- * room")` if it's not there yet). The local store is only populated by
205
- * the client's background sync processing, which is asynchronous and can
206
- * easily not have caught up in the few milliseconds between the HTTP
207
- * response and that lookup — especially right after a client has just
208
- * started. The room really was created server-side by that point (that's
209
- * how we get "Unknown room" at all); this method waits for the client's
210
- * own ongoing sync loop to surface it, then wraps it directly via
211
- * `unstableGetFileTreeSpace`, instead of surfacing a spurious failure for
212
- * a folder that in fact exists.
238
+ * Uses Matrix's public `createRoom()` response as the authoritative room
239
+ * identity, then waits for that exact room to appear in local sync state.
213
240
  */
214
- createTree(name: string): Promise<TreeSpace>;
215
- listTrees(): Promise<TreeSpace[]>;
241
+ private treeRoomOptions;
242
+ private waitForTreeSpace;
216
243
  /**
217
- * Creates a subdirectory (nested tree space) under `parent` and links it
218
- * with the parent's space.child / the child's space.parent state events.
219
- *
220
- * Mirrors `createTree`'s workaround for the same matrix-js-sdk race:
221
- * `MSC3089TreeSpace.createDirectory()` calls `unstableCreateFileTree()`,
222
- * which creates the room via a plain `createRoom()` HTTP call and then
223
- * immediately wraps the result by looking the room up in the *local*
224
- * client store — throwing `Error("Unknown room")` when the background
225
- * sync loop hasn't surfaced the new room yet. Without the workaround the
226
- * subfolder room is created server-side but never linked (no space.child
227
- * event), leaving an orphaned room and a spurious failure for a folder
228
- * that in fact exists.
244
+ * MSC3089's built-in createDirectory only sends the two relationship events;
245
+ * it does not update either room's local state after the homeserver accepts
246
+ * them. Wrap returned spaces so child creation goes through createSubtree,
247
+ * which refreshes the exact parent/child state before reporting success.
248
+ */
249
+ private decorateTreeSpace;
250
+ /** Cleans up only a room created by this operation and reports incomplete cleanup. */
251
+ private cleanupCreatedRoom;
252
+ private removeRelationLink;
253
+ private removeChildLink;
254
+ private removeParentLink;
255
+ private createTreeSpace;
256
+ /**
257
+ * Creates one top-level shared vault per invocation. Same-name calls are
258
+ * serialized for this MatrixClient, but display names are not identities.
229
259
  */
230
- createSubtree(parent: TreeSpace, name: string): Promise<TreeSpace>;
260
+ createTree(name: string, signal?: AbortSignal): Promise<TreeSpace>;
261
+ listTrees(signal?: AbortSignal): Promise<TreeSpace[]>;
262
+ /** Creates and links a child tree space using the exact Matrix room ID. */
263
+ createSubtree(parent: TreeSpace, name: string, signal?: AbortSignal): Promise<TreeSpace>;
231
264
  getTree(roomId: string): TreeSpace | null;
232
265
  /**
233
- * Lists the participants of a shared folder (tree space) and their role,
266
+ * Lists the participants of a shared vault (tree space) and their role,
234
267
  * derived from room membership (join/invite state) + power levels.
235
268
  * Includes invited-but-not-yet-joined members as well as joined ones;
236
269
  * excludes anyone who has left/been kicked/banned.
@@ -246,13 +279,21 @@ export declare class TeleCryptIOStorage {
246
279
  * `MSC3089TreeSpace.getPermissions()` exactly (userLevel >= adminLevel ->
247
280
  * owner, >= editLevel -> editor, else viewer).
248
281
  */
249
- listMembers(tree: TreeSpace): Promise<{
282
+ listMembers(tree: TreeSpace, options?: MatrixRequestOptions): Promise<{
250
283
  userId: string;
251
284
  role: string;
252
285
  membership: string;
253
286
  }[]>;
254
- uploadFile(tree: TreeSpace, name: string, data: ArrayBuffer, mimetype: string): Promise<string>;
255
- downloadFile(branch: FileBranch): Promise<{
287
+ /**
288
+ * Returns the homeserver's complete joined-room inventory. Local sync room
289
+ * lists can be truncated by initialSyncLimit and are not an authoritative
290
+ * basis for deletion safety.
291
+ */
292
+ listJoinedRoomIds(options?: MatrixRequestOptions): Promise<string[]>;
293
+ /** Reads one room membership from the homeserver, not the lagging local sync store. */
294
+ getRoomMembership(roomId: string, userId?: string | null, options?: MatrixRequestOptions): Promise<string | null>;
295
+ uploadFile(tree: TreeSpace, name: string, data: ArrayBuffer, mimetype: string, signal?: AbortSignal): Promise<string>;
296
+ downloadFile(branch: FileBranch, signal?: AbortSignal): Promise<{
256
297
  data: ArrayBuffer;
257
298
  mimetype: string;
258
299
  }>;