@telecrypt-io/storage 0.1.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/LICENSE +109 -0
- package/README.md +53 -0
- package/dist/TeleCryptIOStorage.d.ts +185 -0
- package/dist/TeleCryptIOStorage.js +323 -0
- package/dist/cli/cryptoSnapshot.d.ts +37 -0
- package/dist/cli/cryptoSnapshot.js +187 -0
- package/dist/cli/errors.d.ts +1 -0
- package/dist/cli/errors.js +3 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +415 -0
- package/dist/cli/output.d.ts +16 -0
- package/dist/cli/output.js +24 -0
- package/dist/cli/poll.d.ts +1 -0
- package/dist/cli/poll.js +3 -0
- package/dist/cli/profile.d.ts +20 -0
- package/dist/cli/profile.js +46 -0
- package/dist/cli/storage.d.ts +36 -0
- package/dist/cli/storage.js +99 -0
- package/dist/core/errors.d.ts +8 -0
- package/dist/core/errors.js +11 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +5 -0
- package/dist/core/operations.d.ts +35 -0
- package/dist/core/operations.js +120 -0
- package/dist/core/poll.d.ts +14 -0
- package/dist/core/poll.js +24 -0
- package/dist/core/types.d.ts +50 -0
- package/dist/core/types.js +10 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
Parameters
|
|
4
|
+
|
|
5
|
+
Licensor: TeleCrypt.io
|
|
6
|
+
Licensed Work: TeleCrypt.io Storage
|
|
7
|
+
The Licensed Work is (c) 2026 TeleCrypt.io
|
|
8
|
+
Additional Use Grant: You may use, copy, modify, and create derivative
|
|
9
|
+
works of the Licensed Work for any non-commercial
|
|
10
|
+
purpose, including personal, educational, research,
|
|
11
|
+
and experimentation use. You may not use the
|
|
12
|
+
Licensed Work, or any derivative work thereof, to
|
|
13
|
+
provide a commercial product or service to third
|
|
14
|
+
parties, or for any purpose intended for or
|
|
15
|
+
directed toward commercial advantage or monetary
|
|
16
|
+
compensation.
|
|
17
|
+
|
|
18
|
+
Change Date: 2030-07-20
|
|
19
|
+
|
|
20
|
+
Change License: Apache License, Version 2.0
|
|
21
|
+
|
|
22
|
+
For information about alternative licensing arrangements for the Licensed
|
|
23
|
+
Work, please contact TeleCrypt.io.
|
|
24
|
+
|
|
25
|
+
Notice
|
|
26
|
+
|
|
27
|
+
The Business Source License (this document, or the "License") is not an
|
|
28
|
+
Open Source license. However, the Licensed Work will eventually be made
|
|
29
|
+
available under an Open Source License, as stated in this License.
|
|
30
|
+
|
|
31
|
+
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
|
32
|
+
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
|
33
|
+
|
|
34
|
+
-----------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
Business Source License 1.1
|
|
37
|
+
|
|
38
|
+
Terms
|
|
39
|
+
|
|
40
|
+
The Licensor hereby grants you the right to copy, modify, create derivative
|
|
41
|
+
works, redistribute, and make non-production use of the Licensed Work. The
|
|
42
|
+
Licensor may make an Additional Use Grant, above, permitting limited
|
|
43
|
+
production use.
|
|
44
|
+
|
|
45
|
+
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
46
|
+
available distribution of a specific version of the Licensed Work under this
|
|
47
|
+
License, whichever comes first, the Licensor hereby grants you rights under
|
|
48
|
+
the terms of the Change License, and the rights granted in the paragraph
|
|
49
|
+
above terminate.
|
|
50
|
+
|
|
51
|
+
If your use of the Licensed Work does not comply with the requirements
|
|
52
|
+
currently in effect as described in this License, you must purchase a
|
|
53
|
+
commercial license from the Licensor, its affiliated entities, or authorized
|
|
54
|
+
resellers, or you must refrain from using the Licensed Work.
|
|
55
|
+
|
|
56
|
+
All copies of the original and modified Licensed Work, and derivative works
|
|
57
|
+
of the Licensed Work, are subject to this License. This License applies
|
|
58
|
+
separately for each version of the Licensed Work and the Change Date may vary
|
|
59
|
+
for each version of the Licensed Work released by Licensor.
|
|
60
|
+
|
|
61
|
+
You must conspicuously display this License on each original or modified
|
|
62
|
+
copy of the Licensed Work. If you receive the Licensed Work in original or
|
|
63
|
+
modified form from a third party, the terms and conditions set forth in this
|
|
64
|
+
License apply to your use of that work.
|
|
65
|
+
|
|
66
|
+
Any use of the Licensed Work in violation of this License will automatically
|
|
67
|
+
terminate your rights under this License for the current and all other
|
|
68
|
+
versions of the Licensed Work.
|
|
69
|
+
|
|
70
|
+
This License does not grant you any right in any trademark or logo of
|
|
71
|
+
Licensor or its affiliates (provided that you may use a trademark or logo of
|
|
72
|
+
Licensor as expressly required by this License).
|
|
73
|
+
|
|
74
|
+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
|
75
|
+
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
|
76
|
+
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
|
77
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
|
78
|
+
TITLE.
|
|
79
|
+
|
|
80
|
+
MariaDB hereby grants you permission to use this License's text to license
|
|
81
|
+
your works, and to refer to it using the trademark "Business Source License",
|
|
82
|
+
as long as you comply with the Covenants of Licensor below.
|
|
83
|
+
|
|
84
|
+
Covenants of Licensor
|
|
85
|
+
|
|
86
|
+
In consideration of the right to use this License's text and the "Business
|
|
87
|
+
Source License" name and trademark, Licensor covenants to MariaDB, and to all
|
|
88
|
+
other recipients of the licensed work to be provided by Licensor:
|
|
89
|
+
|
|
90
|
+
1. To specify as the Change License the GPL Version 2.0 or any later version,
|
|
91
|
+
or a license that is compatible with GPL Version 2.0 or a later version,
|
|
92
|
+
where "compatible" means that software provided under the Change License can
|
|
93
|
+
be included in a program with software provided under GPL Version 2.0 or a
|
|
94
|
+
later version. Licensor may specify additional Change Licenses without
|
|
95
|
+
limitation.
|
|
96
|
+
|
|
97
|
+
2. To either: (a) specify an additional grant of rights to use that does not
|
|
98
|
+
impose any additional restriction on the right granted in this License, as
|
|
99
|
+
the Additional Use Grant, or (b) insert the text "None" to specify a Change
|
|
100
|
+
Date of no more than four years from the date the Licensed Work is
|
|
101
|
+
published.
|
|
102
|
+
|
|
103
|
+
3. To not modify this License in any other way.
|
|
104
|
+
|
|
105
|
+
Notice
|
|
106
|
+
|
|
107
|
+
The Business Source License (this document, or the "License") is not an Open
|
|
108
|
+
Source license. However, the Licensed Work will eventually be made available
|
|
109
|
+
under an Open Source License, as stated in this License.
|
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# TeleCrypt.io Storage
|
|
2
|
+
|
|
3
|
+
End-to-end encrypted file storage and sharing, built on Matrix.
|
|
4
|
+
|
|
5
|
+
Files are encrypted on the client before upload. The server stores only opaque ciphertext and
|
|
6
|
+
never holds the decryption keys.
|
|
7
|
+
|
|
8
|
+
**Status:** early development. Not yet usable.
|
|
9
|
+
|
|
10
|
+
## How it works
|
|
11
|
+
|
|
12
|
+
Built on [MSC3089](https://github.com/matrix-org/matrix-spec-proposals/pull/3089), which models
|
|
13
|
+
a file tree using Matrix primitives:
|
|
14
|
+
|
|
15
|
+
| File-system concept | Matrix concept |
|
|
16
|
+
|---|---|
|
|
17
|
+
| Folder | A Space (room marked as a file tree) |
|
|
18
|
+
| Subfolder | A child Space |
|
|
19
|
+
| File | An event pointing at encrypted uploaded content |
|
|
20
|
+
| Version | A newer event superseding the old |
|
|
21
|
+
| Sharing | Room invitation |
|
|
22
|
+
| Permissions | Power levels |
|
|
23
|
+
|
|
24
|
+
Encryption uses the same scheme as Matrix attachments (AES-CTR with a per-file key, keys
|
|
25
|
+
distributed via the room's Megolm session). Requires no server-side changes — it runs against
|
|
26
|
+
stock Synapse.
|
|
27
|
+
|
|
28
|
+
## Development
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install
|
|
32
|
+
npm run synapse:up # disposable local Synapse for tests
|
|
33
|
+
npm test
|
|
34
|
+
npm run synapse:down
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Tests run against a real local Synapse in Docker, never against a production server.
|
|
38
|
+
|
|
39
|
+
See [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) for the phased build plan.
|
|
40
|
+
|
|
41
|
+
## Licence
|
|
42
|
+
|
|
43
|
+
[Business Source License 1.1](./LICENSE). Non-commercial use is permitted; converts to
|
|
44
|
+
Apache License 2.0 on 2030-07-20.
|
|
45
|
+
|
|
46
|
+
For commercial licensing, contact TeleCrypt.io.
|
|
47
|
+
|
|
48
|
+
## Third-party code
|
|
49
|
+
|
|
50
|
+
- [`matrix-js-sdk`](https://github.com/matrix-org/matrix-js-sdk) — Apache-2.0 — dependency
|
|
51
|
+
- [`matrix-files-sdk`](https://github.com/matrix-org/matrix-files-sdk) — Apache-2.0 — reference
|
|
52
|
+
- [`files-sdk-demo`](https://github.com/vector-im/files-sdk-demo) — AGPL-3.0 — **not used**;
|
|
53
|
+
incompatible with this project's licence
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { MatrixClient } from "matrix-js-sdk";
|
|
2
|
+
import type { CryptoCallbacks } from "matrix-js-sdk/lib/crypto-api/index.js";
|
|
3
|
+
export interface TreeSpace {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly room: {
|
|
6
|
+
name: string;
|
|
7
|
+
};
|
|
8
|
+
readonly isTopLevel: boolean;
|
|
9
|
+
setName(name: string): Promise<void>;
|
|
10
|
+
createDirectory(name: string): Promise<TreeSpace>;
|
|
11
|
+
getDirectories(): TreeSpace[];
|
|
12
|
+
getDirectory(roomId: string): TreeSpace | undefined;
|
|
13
|
+
invite(userId: string, andSubspaces?: boolean): Promise<void>;
|
|
14
|
+
delete(): Promise<void>;
|
|
15
|
+
getOrder(): number;
|
|
16
|
+
setOrder(index: number): Promise<void>;
|
|
17
|
+
getPermissions(userId: string): string;
|
|
18
|
+
setPermissions(userId: string, role: string): Promise<void>;
|
|
19
|
+
getFile(fileEventId: string): FileBranch | null;
|
|
20
|
+
listFiles(): FileBranch[];
|
|
21
|
+
listAllFiles(): FileBranch[];
|
|
22
|
+
createFile(name: string, encryptedContents: Buffer | ArrayBuffer | Uint8Array, info: Record<string, unknown>, additionalContent?: Record<string, unknown>): Promise<{
|
|
23
|
+
event_id: string;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
export interface FileBranch {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly version: number;
|
|
29
|
+
readonly isActive: boolean;
|
|
30
|
+
getName(): string;
|
|
31
|
+
setName(name: string): Promise<void>;
|
|
32
|
+
delete(): Promise<void>;
|
|
33
|
+
getFileInfo(): Promise<{
|
|
34
|
+
info: Record<string, unknown>;
|
|
35
|
+
httpUrl: string;
|
|
36
|
+
}>;
|
|
37
|
+
getFileEvent(): Promise<{
|
|
38
|
+
getContent: () => Record<string, unknown>;
|
|
39
|
+
}>;
|
|
40
|
+
getVersionHistory(): Promise<FileBranch[]>;
|
|
41
|
+
createNewVersion(name: string, encryptedContents: Buffer | ArrayBuffer | Uint8Array, info: Record<string, unknown>, additionalContent?: Record<string, unknown>): Promise<{
|
|
42
|
+
event_id: string;
|
|
43
|
+
}>;
|
|
44
|
+
}
|
|
45
|
+
export interface CreateTeleCryptIOStorageOptions {
|
|
46
|
+
/** Matrix homeserver base URL, e.g. "https://matrix.example.com". */
|
|
47
|
+
baseUrl: string;
|
|
48
|
+
userId: string;
|
|
49
|
+
accessToken: string;
|
|
50
|
+
deviceId: string;
|
|
51
|
+
/**
|
|
52
|
+
* Use a persistent crypto store (IndexedDB) so keys survive a restart on this
|
|
53
|
+
* device. This is the default (`true`) — it is what makes both same-device
|
|
54
|
+
* restart recovery and (via `keys.setupRecovery`/`restoreFromRecoveryKey`)
|
|
55
|
+
* new-device recovery possible. Set explicitly to `false` to opt into an
|
|
56
|
+
* in-memory, amnesiac store; this must be a deliberate choice, e.g. for a
|
|
57
|
+
* short-lived process that should never persist secrets to disk.
|
|
58
|
+
*/
|
|
59
|
+
persistentCryptoStore?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Overrides the IndexedDB name prefix used for the crypto store. Defaults to
|
|
62
|
+
* a value scoped to (userId, deviceId) so that multiple devices/users never
|
|
63
|
+
* collide when they happen to share a single IndexedDB origin (e.g. in
|
|
64
|
+
* Node/tests, where `fake-indexeddb` is process-global).
|
|
65
|
+
*/
|
|
66
|
+
cryptoDatabasePrefix?: string;
|
|
67
|
+
/** initialSyncLimit passed to startClient(); default 10. */
|
|
68
|
+
initialSyncLimit?: number;
|
|
69
|
+
/** How long to wait for the first sync before giving up; default 15000ms. */
|
|
70
|
+
syncTimeoutMs?: number;
|
|
71
|
+
/**
|
|
72
|
+
* Optional platform-supplied crypto callbacks (e.g. to source the secret
|
|
73
|
+
* storage key from an OS keychain instead of prompting). `keys.setupRecovery`
|
|
74
|
+
* and `keys.restoreFromRecoveryKey` temporarily override
|
|
75
|
+
* `getSecretStorageKey`/`cacheSecretStorageKey` on this object for the
|
|
76
|
+
* duration of the call, then restore whatever was here before.
|
|
77
|
+
*/
|
|
78
|
+
cryptoCallbacks?: CryptoCallbacks;
|
|
79
|
+
}
|
|
80
|
+
export declare class TeleCryptIOStorage {
|
|
81
|
+
private client;
|
|
82
|
+
constructor(client: MatrixClient);
|
|
83
|
+
/** The underlying matrix-js-sdk client (e.g. to stop it, or for advanced/interop use). */
|
|
84
|
+
getClient(): MatrixClient;
|
|
85
|
+
/**
|
|
86
|
+
* Recommended entry point: builds the MatrixClient with a persistent crypto
|
|
87
|
+
* store and the secret-storage callback wiring `keys.*` needs, starts the
|
|
88
|
+
* client, waits for the first sync, and returns a ready TeleCryptIOStorage.
|
|
89
|
+
*
|
|
90
|
+
* The plain constructor remains available for advanced callers who need to
|
|
91
|
+
* build/configure the MatrixClient themselves; in that case `keys.*` only
|
|
92
|
+
* works if the caller wires equivalent cryptoCallbacks onto the client at
|
|
93
|
+
* construction time (see `cryptoCallbacks` above for why: matrix-js-sdk
|
|
94
|
+
* captures a single callbacks object at MatrixClient construction, so it
|
|
95
|
+
* must exist before `initRustCrypto` runs, not be added afterwards).
|
|
96
|
+
*/
|
|
97
|
+
static create(opts: CreateTeleCryptIOStorageOptions): Promise<TeleCryptIOStorage>;
|
|
98
|
+
private requireCrypto;
|
|
99
|
+
/**
|
|
100
|
+
* Temporarily wires `getSecretStorageKey`/`cacheSecretStorageKey` on the
|
|
101
|
+
* client's cryptoCallbacks to hand back `privateKey`, runs `fn`, then
|
|
102
|
+
* restores whatever callbacks were there before — regardless of success or
|
|
103
|
+
* failure. Requires that the client was constructed with a cryptoCallbacks
|
|
104
|
+
* object in the first place (see `create()`'s doc comment).
|
|
105
|
+
*/
|
|
106
|
+
private withSecretStorageKey;
|
|
107
|
+
/**
|
|
108
|
+
* Bootstraps cross-signing and secret storage with a brand-new server-side
|
|
109
|
+
* key backup, and returns the Recovery Key for the caller to show the user.
|
|
110
|
+
* This is what makes `restoreFromRecoveryKey` on another device possible.
|
|
111
|
+
*/
|
|
112
|
+
private keysSetupRecovery;
|
|
113
|
+
/** Is there an active key backup and a ready secret storage right now? */
|
|
114
|
+
private keysIsRecoverySetup;
|
|
115
|
+
/**
|
|
116
|
+
* On a new device: unlocks secret storage with the Recovery Key, loads the
|
|
117
|
+
* key-backup decryption key out of secret storage, and restores the key
|
|
118
|
+
* backup so previously-uploaded files become decryptable again.
|
|
119
|
+
*
|
|
120
|
+
* Throws a clear error (never silently "succeeds" with zero keys) if the
|
|
121
|
+
* recovery key is malformed or does not unlock this account's secret
|
|
122
|
+
* storage / key backup.
|
|
123
|
+
*/
|
|
124
|
+
private keysRestoreFromRecoveryKey;
|
|
125
|
+
/** Key-management API: setup, status, and restore for server-side Secure Backup. */
|
|
126
|
+
get keys(): {
|
|
127
|
+
setupRecovery: () => Promise<{
|
|
128
|
+
recoveryKey: string;
|
|
129
|
+
}>;
|
|
130
|
+
isRecoverySetup: () => Promise<boolean>;
|
|
131
|
+
restoreFromRecoveryKey: (recoveryKey: string) => Promise<{
|
|
132
|
+
imported: number;
|
|
133
|
+
total: number;
|
|
134
|
+
}>;
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Creates a new top-level shared folder (tree space).
|
|
138
|
+
*
|
|
139
|
+
* Works around a genuine race in matrix-js-sdk's own
|
|
140
|
+
* `unstableCreateFileTree()`: it creates the room via a plain
|
|
141
|
+
* `createRoom()` HTTP call, then immediately wraps the result by looking
|
|
142
|
+
* the room up in the *local* client store (`new MSC3089TreeSpace(this,
|
|
143
|
+
* roomId)` calls `client.getRoom(roomId)` and throws `Error("Unknown
|
|
144
|
+
* room")` if it's not there yet). The local store is only populated by
|
|
145
|
+
* the client's background sync processing, which is asynchronous and can
|
|
146
|
+
* easily not have caught up in the few milliseconds between the HTTP
|
|
147
|
+
* response and that lookup — especially right after a client has just
|
|
148
|
+
* started. The room really was created server-side by that point (that's
|
|
149
|
+
* how we get "Unknown room" at all); this method waits for the client's
|
|
150
|
+
* own ongoing sync loop to surface it, then wraps it directly via
|
|
151
|
+
* `unstableGetFileTreeSpace`, instead of surfacing a spurious failure for
|
|
152
|
+
* a folder that in fact exists.
|
|
153
|
+
*/
|
|
154
|
+
createTree(name: string): Promise<TreeSpace>;
|
|
155
|
+
listTrees(): Promise<TreeSpace[]>;
|
|
156
|
+
getTree(roomId: string): TreeSpace | null;
|
|
157
|
+
/**
|
|
158
|
+
* Lists the participants of a shared folder (tree space) and their role,
|
|
159
|
+
* derived from room membership (join/invite state) + power levels.
|
|
160
|
+
* Includes invited-but-not-yet-joined members as well as joined ones;
|
|
161
|
+
* excludes anyone who has left/been kicked/banned.
|
|
162
|
+
*
|
|
163
|
+
* Deliberately reads directly from the server's authoritative REST state
|
|
164
|
+
* endpoints (`GET .../members`, `GET .../state/m.room.power_levels/`)
|
|
165
|
+
* rather than from the client's locally synced `tree.room`/`currentState`.
|
|
166
|
+
* On a freshly-started client (as every CLI command is — see
|
|
167
|
+
* docs/CLI_SPEC.md and src/cli/cryptoSnapshot.ts), room membership and
|
|
168
|
+
* power-level state can take several more `/sync` round trips to fully
|
|
169
|
+
* converge locally after a change, even though the room itself is already
|
|
170
|
+
* visible — reading the same data straight from the server sidesteps that
|
|
171
|
+
* sync-convergence lag entirely. The role thresholds mirror
|
|
172
|
+
* `MSC3089TreeSpace.getPermissions()` exactly (userLevel >= adminLevel ->
|
|
173
|
+
* owner, >= editLevel -> editor, else viewer).
|
|
174
|
+
*/
|
|
175
|
+
listMembers(tree: TreeSpace): Promise<{
|
|
176
|
+
userId: string;
|
|
177
|
+
role: string;
|
|
178
|
+
membership: string;
|
|
179
|
+
}[]>;
|
|
180
|
+
uploadFile(tree: TreeSpace, name: string, data: ArrayBuffer, mimetype: string): Promise<string>;
|
|
181
|
+
downloadFile(branch: FileBranch): Promise<{
|
|
182
|
+
data: ArrayBuffer;
|
|
183
|
+
mimetype: string;
|
|
184
|
+
}>;
|
|
185
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { ClientEvent, createClient, SyncState } from "matrix-js-sdk";
|
|
2
|
+
import { encryptAttachment, decryptAttachment } from "matrix-encrypt-attachment";
|
|
3
|
+
import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
|
|
4
|
+
export class TeleCryptIOStorage {
|
|
5
|
+
client;
|
|
6
|
+
constructor(client) {
|
|
7
|
+
this.client = client;
|
|
8
|
+
}
|
|
9
|
+
/** The underlying matrix-js-sdk client (e.g. to stop it, or for advanced/interop use). */
|
|
10
|
+
getClient() {
|
|
11
|
+
return this.client;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Recommended entry point: builds the MatrixClient with a persistent crypto
|
|
15
|
+
* store and the secret-storage callback wiring `keys.*` needs, starts the
|
|
16
|
+
* client, waits for the first sync, and returns a ready TeleCryptIOStorage.
|
|
17
|
+
*
|
|
18
|
+
* The plain constructor remains available for advanced callers who need to
|
|
19
|
+
* build/configure the MatrixClient themselves; in that case `keys.*` only
|
|
20
|
+
* works if the caller wires equivalent cryptoCallbacks onto the client at
|
|
21
|
+
* construction time (see `cryptoCallbacks` above for why: matrix-js-sdk
|
|
22
|
+
* captures a single callbacks object at MatrixClient construction, so it
|
|
23
|
+
* must exist before `initRustCrypto` runs, not be added afterwards).
|
|
24
|
+
*/
|
|
25
|
+
static async create(opts) {
|
|
26
|
+
const client = createClient({
|
|
27
|
+
baseUrl: opts.baseUrl,
|
|
28
|
+
userId: opts.userId,
|
|
29
|
+
accessToken: opts.accessToken,
|
|
30
|
+
deviceId: opts.deviceId,
|
|
31
|
+
cryptoCallbacks: opts.cryptoCallbacks ?? {},
|
|
32
|
+
});
|
|
33
|
+
const persistent = opts.persistentCryptoStore ?? true;
|
|
34
|
+
await client.initRustCrypto({
|
|
35
|
+
useIndexedDB: persistent,
|
|
36
|
+
cryptoDatabasePrefix: opts.cryptoDatabasePrefix ?? `telecrypt-io-storage::${opts.userId}::${opts.deviceId}`,
|
|
37
|
+
});
|
|
38
|
+
await client.startClient({ initialSyncLimit: opts.initialSyncLimit ?? 10 });
|
|
39
|
+
await new Promise((resolve, reject) => {
|
|
40
|
+
const timeoutMs = opts.syncTimeoutMs ?? 15000;
|
|
41
|
+
const timeout = setTimeout(() => reject(new Error("sync timeout")), timeoutMs);
|
|
42
|
+
client.once(ClientEvent.Sync, (state) => {
|
|
43
|
+
clearTimeout(timeout);
|
|
44
|
+
if (state === SyncState.Prepared || state === SyncState.Syncing) {
|
|
45
|
+
resolve();
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
reject(new Error(`unexpected sync state: ${state}`));
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
return new TeleCryptIOStorage(client);
|
|
53
|
+
}
|
|
54
|
+
requireCrypto() {
|
|
55
|
+
const crypto = this.client.getCrypto();
|
|
56
|
+
if (!crypto) {
|
|
57
|
+
throw new Error("TeleCryptIOStorage: this client's crypto was never initialised (call client.initRustCrypto() first, or use TeleCryptIOStorage.create())");
|
|
58
|
+
}
|
|
59
|
+
return crypto;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Temporarily wires `getSecretStorageKey`/`cacheSecretStorageKey` on the
|
|
63
|
+
* client's cryptoCallbacks to hand back `privateKey`, runs `fn`, then
|
|
64
|
+
* restores whatever callbacks were there before — regardless of success or
|
|
65
|
+
* failure. Requires that the client was constructed with a cryptoCallbacks
|
|
66
|
+
* object in the first place (see `create()`'s doc comment).
|
|
67
|
+
*/
|
|
68
|
+
async withSecretStorageKey(privateKey, fn) {
|
|
69
|
+
const callbacks = this.client.cryptoCallbacks;
|
|
70
|
+
const prevGetKey = callbacks.getSecretStorageKey;
|
|
71
|
+
const prevCache = callbacks.cacheSecretStorageKey;
|
|
72
|
+
callbacks.getSecretStorageKey = async ({ keys }) => {
|
|
73
|
+
const keyId = Object.keys(keys)[0];
|
|
74
|
+
return [keyId, privateKey];
|
|
75
|
+
};
|
|
76
|
+
callbacks.cacheSecretStorageKey = () => { };
|
|
77
|
+
try {
|
|
78
|
+
return await fn();
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
callbacks.getSecretStorageKey = prevGetKey;
|
|
82
|
+
callbacks.cacheSecretStorageKey = prevCache;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Bootstraps cross-signing and secret storage with a brand-new server-side
|
|
87
|
+
* key backup, and returns the Recovery Key for the caller to show the user.
|
|
88
|
+
* This is what makes `restoreFromRecoveryKey` on another device possible.
|
|
89
|
+
*/
|
|
90
|
+
async keysSetupRecovery() {
|
|
91
|
+
const crypto = this.requireCrypto();
|
|
92
|
+
await crypto.bootstrapCrossSigning({
|
|
93
|
+
// No existing verified device to interactively re-authenticate against
|
|
94
|
+
// for this account, so there is nothing to feed into `makeRequest`;
|
|
95
|
+
// matches the working pattern already proven in keys.test.ts.
|
|
96
|
+
authUploadDeviceSigningKeys: async () => undefined,
|
|
97
|
+
});
|
|
98
|
+
const generated = await crypto.createRecoveryKeyFromPassphrase();
|
|
99
|
+
if (!generated.encodedPrivateKey) {
|
|
100
|
+
throw new Error("setupRecovery: matrix-js-sdk did not return an encoded recovery key");
|
|
101
|
+
}
|
|
102
|
+
await this.withSecretStorageKey(generated.privateKey, async () => {
|
|
103
|
+
await crypto.bootstrapSecretStorage({
|
|
104
|
+
setupNewSecretStorage: true,
|
|
105
|
+
setupNewKeyBackup: true,
|
|
106
|
+
createSecretStorageKey: async () => generated,
|
|
107
|
+
});
|
|
108
|
+
await crypto.checkKeyBackupAndEnable();
|
|
109
|
+
});
|
|
110
|
+
return { recoveryKey: generated.encodedPrivateKey };
|
|
111
|
+
}
|
|
112
|
+
/** Is there an active key backup and a ready secret storage right now? */
|
|
113
|
+
async keysIsRecoverySetup() {
|
|
114
|
+
const crypto = this.client.getCrypto();
|
|
115
|
+
if (!crypto)
|
|
116
|
+
return false;
|
|
117
|
+
const [storageStatus, backupVersion] = await Promise.all([
|
|
118
|
+
crypto.getSecretStorageStatus(),
|
|
119
|
+
crypto.getActiveSessionBackupVersion(),
|
|
120
|
+
]);
|
|
121
|
+
return storageStatus.ready && backupVersion !== null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* On a new device: unlocks secret storage with the Recovery Key, loads the
|
|
125
|
+
* key-backup decryption key out of secret storage, and restores the key
|
|
126
|
+
* backup so previously-uploaded files become decryptable again.
|
|
127
|
+
*
|
|
128
|
+
* Throws a clear error (never silently "succeeds" with zero keys) if the
|
|
129
|
+
* recovery key is malformed or does not unlock this account's secret
|
|
130
|
+
* storage / key backup.
|
|
131
|
+
*/
|
|
132
|
+
async keysRestoreFromRecoveryKey(recoveryKey) {
|
|
133
|
+
const crypto = this.requireCrypto();
|
|
134
|
+
let privateKey;
|
|
135
|
+
try {
|
|
136
|
+
privateKey = decodeRecoveryKey(recoveryKey);
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
throw new Error(`restoreFromRecoveryKey: not a valid recovery key (${err.message})`);
|
|
140
|
+
}
|
|
141
|
+
return this.withSecretStorageKey(privateKey, async () => {
|
|
142
|
+
try {
|
|
143
|
+
await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
throw new Error(`restoreFromRecoveryKey: recovery key did not unlock secret storage / key backup (${err.message})`);
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const result = await crypto.restoreKeyBackup();
|
|
150
|
+
return { imported: result.imported, total: result.total };
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
throw new Error(`restoreFromRecoveryKey: failed to restore key backup (${err.message})`);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/** Key-management API: setup, status, and restore for server-side Secure Backup. */
|
|
158
|
+
get keys() {
|
|
159
|
+
return {
|
|
160
|
+
setupRecovery: () => this.keysSetupRecovery(),
|
|
161
|
+
isRecoverySetup: () => this.keysIsRecoverySetup(),
|
|
162
|
+
restoreFromRecoveryKey: (recoveryKey) => this.keysRestoreFromRecoveryKey(recoveryKey),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Creates a new top-level shared folder (tree space).
|
|
167
|
+
*
|
|
168
|
+
* Works around a genuine race in matrix-js-sdk's own
|
|
169
|
+
* `unstableCreateFileTree()`: it creates the room via a plain
|
|
170
|
+
* `createRoom()` HTTP call, then immediately wraps the result by looking
|
|
171
|
+
* the room up in the *local* client store (`new MSC3089TreeSpace(this,
|
|
172
|
+
* roomId)` calls `client.getRoom(roomId)` and throws `Error("Unknown
|
|
173
|
+
* room")` if it's not there yet). The local store is only populated by
|
|
174
|
+
* the client's background sync processing, which is asynchronous and can
|
|
175
|
+
* easily not have caught up in the few milliseconds between the HTTP
|
|
176
|
+
* response and that lookup — especially right after a client has just
|
|
177
|
+
* started. The room really was created server-side by that point (that's
|
|
178
|
+
* how we get "Unknown room" at all); this method waits for the client's
|
|
179
|
+
* own ongoing sync loop to surface it, then wraps it directly via
|
|
180
|
+
* `unstableGetFileTreeSpace`, instead of surfacing a spurious failure for
|
|
181
|
+
* a folder that in fact exists.
|
|
182
|
+
*/
|
|
183
|
+
async createTree(name) {
|
|
184
|
+
const knownRoomIdsBefore = new Set(this.client.getRooms().map((r) => r.roomId));
|
|
185
|
+
try {
|
|
186
|
+
// The `await` here is load-bearing: without it, the promise's
|
|
187
|
+
// eventual rejection would happen after this try/catch has already
|
|
188
|
+
// returned, bypassing the recovery logic below entirely.
|
|
189
|
+
return (await this.client.unstableCreateFileTree(name));
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
if (!(err instanceof Error) || err.message !== "Unknown room")
|
|
193
|
+
throw err;
|
|
194
|
+
const newRoomId = await new Promise((resolve, reject) => {
|
|
195
|
+
const deadline = Date.now() + 15000;
|
|
196
|
+
const poll = () => {
|
|
197
|
+
const found = this.client.getRooms().find((r) => !knownRoomIdsBefore.has(r.roomId));
|
|
198
|
+
if (found) {
|
|
199
|
+
resolve(found.roomId);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (Date.now() >= deadline) {
|
|
203
|
+
reject(new Error("createTree: room was created server-side but never appeared in this client's local sync state"));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
setTimeout(poll, 200);
|
|
207
|
+
};
|
|
208
|
+
poll();
|
|
209
|
+
});
|
|
210
|
+
const tree = this.client.unstableGetFileTreeSpace(newRoomId);
|
|
211
|
+
if (!tree) {
|
|
212
|
+
throw new Error(`createTree: room ${newRoomId} appeared but is not a valid file tree space`);
|
|
213
|
+
}
|
|
214
|
+
return tree;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async listTrees() {
|
|
218
|
+
const rooms = this.client.getRooms();
|
|
219
|
+
const trees = [];
|
|
220
|
+
for (const room of rooms) {
|
|
221
|
+
const tree = this.client.unstableGetFileTreeSpace(room.roomId);
|
|
222
|
+
if (tree) {
|
|
223
|
+
trees.push(tree);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return trees;
|
|
227
|
+
}
|
|
228
|
+
getTree(roomId) {
|
|
229
|
+
return this.client.unstableGetFileTreeSpace(roomId);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Lists the participants of a shared folder (tree space) and their role,
|
|
233
|
+
* derived from room membership (join/invite state) + power levels.
|
|
234
|
+
* Includes invited-but-not-yet-joined members as well as joined ones;
|
|
235
|
+
* excludes anyone who has left/been kicked/banned.
|
|
236
|
+
*
|
|
237
|
+
* Deliberately reads directly from the server's authoritative REST state
|
|
238
|
+
* endpoints (`GET .../members`, `GET .../state/m.room.power_levels/`)
|
|
239
|
+
* rather than from the client's locally synced `tree.room`/`currentState`.
|
|
240
|
+
* On a freshly-started client (as every CLI command is — see
|
|
241
|
+
* docs/CLI_SPEC.md and src/cli/cryptoSnapshot.ts), room membership and
|
|
242
|
+
* power-level state can take several more `/sync` round trips to fully
|
|
243
|
+
* converge locally after a change, even though the room itself is already
|
|
244
|
+
* visible — reading the same data straight from the server sidesteps that
|
|
245
|
+
* sync-convergence lag entirely. The role thresholds mirror
|
|
246
|
+
* `MSC3089TreeSpace.getPermissions()` exactly (userLevel >= adminLevel ->
|
|
247
|
+
* owner, >= editLevel -> editor, else viewer).
|
|
248
|
+
*/
|
|
249
|
+
async listMembers(tree) {
|
|
250
|
+
const clientAny = this.client;
|
|
251
|
+
const baseUrl = clientAny.getHomeserverUrl();
|
|
252
|
+
const token = clientAny.getAccessToken();
|
|
253
|
+
const authHeaders = { Authorization: `Bearer ${token}` };
|
|
254
|
+
const roomPath = `${baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(tree.id)}`;
|
|
255
|
+
const [membersRes, powerLevelsRes] = await Promise.all([
|
|
256
|
+
fetch(`${roomPath}/members`, { headers: authHeaders }),
|
|
257
|
+
fetch(`${roomPath}/state/m.room.power_levels/`, { headers: authHeaders }),
|
|
258
|
+
]);
|
|
259
|
+
if (!membersRes.ok) {
|
|
260
|
+
throw new Error(`listMembers: failed to fetch room members (${membersRes.status})`);
|
|
261
|
+
}
|
|
262
|
+
const membersBody = (await membersRes.json());
|
|
263
|
+
const pls = powerLevelsRes.ok
|
|
264
|
+
? (await powerLevelsRes.json())
|
|
265
|
+
: {};
|
|
266
|
+
const viewLevel = pls.users_default ?? 0;
|
|
267
|
+
const editLevel = pls.events_default ?? 50;
|
|
268
|
+
const adminLevel = pls.events?.["m.room.power_levels"] ?? 100;
|
|
269
|
+
const roleFor = (userId) => {
|
|
270
|
+
const userLevel = pls.users?.[userId] ?? viewLevel;
|
|
271
|
+
if (userLevel >= adminLevel)
|
|
272
|
+
return "owner";
|
|
273
|
+
if (userLevel >= editLevel)
|
|
274
|
+
return "editor";
|
|
275
|
+
return "viewer";
|
|
276
|
+
};
|
|
277
|
+
return membersBody.chunk
|
|
278
|
+
.filter((e) => e.content.membership === "join" || e.content.membership === "invite")
|
|
279
|
+
.map((e) => ({
|
|
280
|
+
userId: e.state_key,
|
|
281
|
+
membership: e.content.membership,
|
|
282
|
+
role: roleFor(e.state_key),
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
async uploadFile(tree, name, data, mimetype) {
|
|
286
|
+
const encrypted = await encryptAttachment(data);
|
|
287
|
+
const { event_id } = await tree.createFile(name, Buffer.from(encrypted.data), encrypted.info, { info: { mimetype, size: data.byteLength } });
|
|
288
|
+
return event_id;
|
|
289
|
+
}
|
|
290
|
+
async downloadFile(branch) {
|
|
291
|
+
const { info } = await branch.getFileInfo();
|
|
292
|
+
// If the underlying room event couldn't be decrypted (e.g. this device
|
|
293
|
+
// never received/recovered the megolm session for it), matrix-js-sdk
|
|
294
|
+
// hands back a placeholder with no usable `info` rather than throwing —
|
|
295
|
+
// so without this check, callers would hit an opaque
|
|
296
|
+
// "Cannot read properties of undefined" a few lines down instead of a
|
|
297
|
+
// clear, actionable error.
|
|
298
|
+
if (!info || typeof info.url !== "string") {
|
|
299
|
+
throw new Error("downloadFile: could not read file info from the event — it is likely undecryptable on this device (missing megolm session; try restoring from a Recovery Key)");
|
|
300
|
+
}
|
|
301
|
+
const mxcUrl = info.url;
|
|
302
|
+
const clientAny = this.client;
|
|
303
|
+
const downloadUrl = clientAny.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, false, true, true);
|
|
304
|
+
if (!downloadUrl)
|
|
305
|
+
throw new Error("failed to build media URL");
|
|
306
|
+
const res = await fetch(downloadUrl, {
|
|
307
|
+
headers: {
|
|
308
|
+
Authorization: `Bearer ${clientAny.getAccessToken()}`,
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
if (!res.ok) {
|
|
312
|
+
throw new Error(`media download failed: ${res.status}`);
|
|
313
|
+
}
|
|
314
|
+
const ciphertext = await res.arrayBuffer();
|
|
315
|
+
const data = await decryptAttachment(ciphertext, info);
|
|
316
|
+
const eventContent = (await branch.getFileEvent()).getContent();
|
|
317
|
+
const infoBlock = eventContent["info"];
|
|
318
|
+
const mimetype = typeof infoBlock?.["mimetype"] === "string"
|
|
319
|
+
? infoBlock["mimetype"]
|
|
320
|
+
: "application/octet-stream";
|
|
321
|
+
return { data, mimetype };
|
|
322
|
+
}
|
|
323
|
+
}
|