@3sln/trove 0.0.5 → 0.0.8
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 +6 -0
- package/package.json +1 -1
- package/packages/core/src/collections/index.js +146 -1
- package/packages/core/src/encryption/envelope.js +444 -0
- package/packages/core/src/encryption/exposure.js +101 -0
- package/packages/core/src/encryption/keys.js +88 -0
- package/packages/core/src/encryption/policy.js +112 -0
- package/packages/core/src/encryption/rotation.js +313 -0
- package/packages/core/src/index.js +17 -1
- package/packages/core/src/links.js +85 -0
- package/packages/core/src/metadata/memory.js +3 -1
- package/packages/core/src/metadata/sqlite.js +27 -7
- package/packages/core/src/scan.js +35 -1
- package/packages/core/src/storage/cost.js +228 -0
- package/packages/core/src/storage/drivers.js +13 -4
- package/packages/core/src/storage/registry.js +37 -4
- package/packages/core/src/uploads.js +197 -15
- package/packages/core/src/vfs.js +148 -9
- package/packages/server/src/engine/providers/core.js +38 -3
- package/packages/server/src/index.js +8 -0
- package/packages/server/src/routes.js +29 -1
- package/packages/web/dist/assets/main-y778bpte.js +356 -0
- package/packages/web/dist/assets/{main-f0f2tfhp.js.map → main-y778bpte.js.map} +10 -8
- package/packages/web/dist/assets/styles-nfy8t3n1.css +1 -0
- package/packages/web/dist/index.html +9 -3
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +20 -4
- package/packages/web/src/bl/services.js +64 -4
- package/packages/web/src/platform/api.js +134 -11
- package/packages/web/src/styles.css +3 -0
- package/packages/web/src/ui/components/overlays.js +13 -0
- package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
- package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
package/README.md
CHANGED
|
@@ -163,6 +163,12 @@ TROVE_S3_ACCESS_KEY_ID=… # or AWS_ACCESS_KEY_ID
|
|
|
163
163
|
TROVE_S3_SECRET_ACCESS_KEY=… # or AWS_SECRET_ACCESS_KEY
|
|
164
164
|
TROVE_S3_PATH_STYLE=true # MinIO / custom endpoints
|
|
165
165
|
|
|
166
|
+
# Which store types a COLLECTION may be created on. Defaults to everything this
|
|
167
|
+
# runtime registered; naming a subset takes the rest off the collection form and
|
|
168
|
+
# refuses them. Worth setting on Workers, where `memory` is offered because it is
|
|
169
|
+
# portable but produces a collection that loses its uploads on isolate recycle.
|
|
170
|
+
TROVE_STORAGE_DRIVERS=s3 # subset of: memory | filesystem | s3
|
|
171
|
+
|
|
166
172
|
# Metadata (file tree + facets)
|
|
167
173
|
TROVE_METADATA=sqlite # memory | sqlite
|
|
168
174
|
TROVE_DB_PATH=./data/trove.db
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@3sln/trove",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Trove — a self-hostable, plugin-extensible Google Drive. Semantic search, pluggable storage (S3 / filesystem / NAS), and a VS Code-style contribution system with sandboxed plugins.",
|
|
6
6
|
"repository": {
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
// secrets (access keys); treat that store as sensitive.
|
|
14
14
|
|
|
15
15
|
import { TroveError } from '../errors.js';
|
|
16
|
+
import { normalizeEncryption, describeEncryption } from '../encryption/policy.js';
|
|
17
|
+
import { newCollectionKey, fromHex, toHex } from '../encryption/keys.js';
|
|
18
|
+
|
|
16
19
|
import { PrefixedStorage } from '../storage/prefixed.js';
|
|
17
20
|
import { newId } from '../util.js';
|
|
18
21
|
|
|
@@ -77,6 +80,120 @@ export class CollectionService {
|
|
|
77
80
|
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The encryption settings for a collection, generating a key the first time.
|
|
85
|
+
*
|
|
86
|
+
* The key is generated, not derived from anything a user types. A passphrase would buy
|
|
87
|
+
* nothing: the server knows the key regardless — it hands it to clients and decrypts for
|
|
88
|
+
* indexers — so there is no protection to gain from the user holding it, and every cost
|
|
89
|
+
* would still apply. A random 256-bit key cannot be forgotten, guessed, or shoulder-read,
|
|
90
|
+
* and needs no prompt in front of the collection.
|
|
91
|
+
*
|
|
92
|
+
* Generated ONCE. Re-enabling, or changing the rules, keeps the existing key: every
|
|
93
|
+
* stored object names the key it was sealed with, so quietly minting a new one would
|
|
94
|
+
* orphan all of them. Replacing a key is rotation, and rotation re-encrypts.
|
|
95
|
+
*
|
|
96
|
+
* @returns {Promise<{encryption: object, dataKey: Uint8Array|null}>}
|
|
97
|
+
*/
|
|
98
|
+
async #encryptionFor(patch, existing, ring) {
|
|
99
|
+
if (!patch || patch.enabled === false) return { encryption: null, keys: ring || null };
|
|
100
|
+
if (existing?.fingerprint && ring?.[existing.fingerprint]) {
|
|
101
|
+
// Keep the whole ring; only the rules can change here.
|
|
102
|
+
return { encryption: normalizeEncryption(patch, existing.fingerprint), keys: ring };
|
|
103
|
+
}
|
|
104
|
+
const { dataKey, config } = await newCollectionKey();
|
|
105
|
+
return {
|
|
106
|
+
encryption: normalizeEncryption(patch, config.fingerprint),
|
|
107
|
+
keys: { ...(ring || {}), [config.fingerprint]: toHex(dataKey) },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The key an object was sealed with, or the collection's current key.
|
|
113
|
+
*
|
|
114
|
+
* A collection holds a RING, not a key: rotation adds a new key and makes it current,
|
|
115
|
+
* then re-encrypts objects onto it in the background. Until that finishes both keys are
|
|
116
|
+
* live, and an object is opened with whichever one its envelope names — which is the
|
|
117
|
+
* whole reason every object carries a fingerprint. Retiring a key is only safe once
|
|
118
|
+
* nothing names it any more.
|
|
119
|
+
*
|
|
120
|
+
* Server-side only. Callers are the transfer plans, which hand the right key to a client
|
|
121
|
+
* that may read the collection, and indexing, which decrypts to read content. Never
|
|
122
|
+
* reachable through `describe`.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} collectionId
|
|
125
|
+
* @param {string} [fingerprint] which key; omitted means the current one
|
|
126
|
+
*/
|
|
127
|
+
async dataKeyFor(collectionId, fingerprint) {
|
|
128
|
+
const c = await this.get(collectionId);
|
|
129
|
+
const ring = c?.$keys;
|
|
130
|
+
if (!ring) return null;
|
|
131
|
+
const want = fingerprint || c.encryption?.fingerprint;
|
|
132
|
+
const hex = want && ring[want];
|
|
133
|
+
return hex ? fromHex(hex) : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Every key this collection can still open something with, current first.
|
|
138
|
+
*
|
|
139
|
+
* For rotation, and for anything that has to read objects it did not plan.
|
|
140
|
+
*/
|
|
141
|
+
async keyRingFor(collectionId) {
|
|
142
|
+
const c = await this.get(collectionId);
|
|
143
|
+
if (!c?.$keys) return [];
|
|
144
|
+
const current = c.encryption?.fingerprint;
|
|
145
|
+
return Object.entries(c.$keys)
|
|
146
|
+
.map(([fp, hex]) => ({ fingerprint: fp, dataKey: fromHex(hex), current: fp === current }))
|
|
147
|
+
.sort((a, b) => Number(b.current) - Number(a.current));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Begin a rotation: mint a key, make it current, keep the old ones.
|
|
152
|
+
*
|
|
153
|
+
* Only makes the new key current. Nothing is re-encrypted here — objects move onto it
|
|
154
|
+
* incrementally, and until every one has, the old keys must stay or their objects become
|
|
155
|
+
* unreadable. `retireKey` is what finishes the job.
|
|
156
|
+
*/
|
|
157
|
+
async beginRotation(collectionId, principal) {
|
|
158
|
+
const c = await this.assert(principal, collectionId, 'admin');
|
|
159
|
+
if (!c.encryption?.enabled) throw TroveError.invalid('This collection is not encrypted');
|
|
160
|
+
const { dataKey, config } = await newCollectionKey();
|
|
161
|
+
const next = {
|
|
162
|
+
...c,
|
|
163
|
+
encryption: { ...c.encryption, fingerprint: config.fingerprint },
|
|
164
|
+
$keys: { ...(c.$keys || {}), [config.fingerprint]: toHex(dataKey) },
|
|
165
|
+
};
|
|
166
|
+
await this.kv.set(NS, collectionId, next);
|
|
167
|
+
return { fingerprint: config.fingerprint, previous: c.encryption.fingerprint };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Drop a key from the ring, once nothing is sealed with it any more.
|
|
172
|
+
*
|
|
173
|
+
* Refuses to drop the current key: that would leave the collection encrypting with
|
|
174
|
+
* something it cannot open.
|
|
175
|
+
*/
|
|
176
|
+
async retireKey(collectionId, fingerprint, principal, { system = false } = {}) {
|
|
177
|
+
// `system` is for the rotation walker, which has no user behind it — it is finishing
|
|
178
|
+
// work an admin already authorized when they started the rotation. Named rather than
|
|
179
|
+
// done by passing a fake principal, so the bypass is visible at both ends.
|
|
180
|
+
const c = system ? await this.get(collectionId) : await this.assert(principal, collectionId, 'admin');
|
|
181
|
+
if (fingerprint === c.encryption?.fingerprint) {
|
|
182
|
+
throw TroveError.invalid('That is the collection\u2019s current key');
|
|
183
|
+
}
|
|
184
|
+
if (!c.$keys?.[fingerprint]) return { retired: false };
|
|
185
|
+
const keys = { ...c.$keys };
|
|
186
|
+
delete keys[fingerprint];
|
|
187
|
+
await this.kv.set(NS, collectionId, { ...c, $keys: keys });
|
|
188
|
+
return { retired: true };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** What a collection encrypts, for the code that has to decide per item. */
|
|
192
|
+
async encryptionFor(collectionId) {
|
|
193
|
+
const c = await this.get(collectionId);
|
|
194
|
+
return c?.encryption || null;
|
|
195
|
+
}
|
|
196
|
+
|
|
80
197
|
/**
|
|
81
198
|
* Every collection record, with no principal and no ACL filtering.
|
|
82
199
|
*
|
|
@@ -106,6 +223,11 @@ export class CollectionService {
|
|
|
106
223
|
id: c.id, name: c.name, description: c.description || '',
|
|
107
224
|
driver: c.store?.driver, system: !!c.system,
|
|
108
225
|
capabilities: caps, createdAt: c.createdAt,
|
|
226
|
+
// Safe to hand to anyone who can see the collection: the salt, the KDF parameters
|
|
227
|
+
// and the fingerprint are what turn a passphrase into the key, and are useless
|
|
228
|
+
// without the passphrase. Null when the collection is not encrypted, so a client
|
|
229
|
+
// never has to ask a second question to find out.
|
|
230
|
+
encryption: describeEncryption(c.encryption),
|
|
109
231
|
};
|
|
110
232
|
}
|
|
111
233
|
|
|
@@ -269,7 +391,7 @@ export class CollectionService {
|
|
|
269
391
|
return { record, created: true };
|
|
270
392
|
}
|
|
271
393
|
|
|
272
|
-
async create({ name, description, store, acl }, principal) {
|
|
394
|
+
async create({ name, description, store, acl, encryption }, principal) {
|
|
273
395
|
if (!this.canCreate(principal)) throw TroveError.forbidden('You cannot create collections');
|
|
274
396
|
if (!name?.trim()) throw TroveError.invalid('Collection name is required');
|
|
275
397
|
if (!store?.driver) throw TroveError.invalid('A backing store (driver + config) is required');
|
|
@@ -283,6 +405,14 @@ export class CollectionService {
|
|
|
283
405
|
const grants = acl?.grants ? [...acl.grants] : [];
|
|
284
406
|
grants.push({ type: 'user', subject: principal.id, capabilities: ['admin'] });
|
|
285
407
|
const record = { id, name: name.trim(), description: description || '', store, acl: { grants }, createdAt: Date.now(), createdBy: principal.id };
|
|
408
|
+
// Set at creation so the very first upload is covered. Enabling it later is allowed and
|
|
409
|
+
// only affects what arrives after — nothing retroactively encrypts what is already
|
|
410
|
+
// there, and pretending otherwise would be the more dangerous lie.
|
|
411
|
+
if (encryption !== undefined) {
|
|
412
|
+
const set = await this.#encryptionFor(encryption, null, null);
|
|
413
|
+
record.encryption = set.encryption;
|
|
414
|
+
if (set.keys) record.$keys = set.keys;
|
|
415
|
+
}
|
|
286
416
|
await this.kv.set(NS, id, record);
|
|
287
417
|
return this.describe(record, principal);
|
|
288
418
|
}
|
|
@@ -297,6 +427,21 @@ export class CollectionService {
|
|
|
297
427
|
next.store = patch.store;
|
|
298
428
|
this._storage.delete(id); // rebuild backend next use
|
|
299
429
|
}
|
|
430
|
+
// Turning encryption ON affects only what is uploaded from now on, and turning it OFF
|
|
431
|
+
// does not decrypt anything: every object records its own envelope, so what is already
|
|
432
|
+
// stored keeps working either way. Changing the FINGERPRINT is a different act — that
|
|
433
|
+
// is a key rotation, and rotating without re-encrypting would orphan every existing
|
|
434
|
+
// object. Refused here; the rotation job is what does it safely.
|
|
435
|
+
if (patch.encryption !== undefined) {
|
|
436
|
+
// Turning encryption ON affects only what is uploaded from now on, and turning it OFF
|
|
437
|
+
// decrypts nothing: every object records its own envelope, so what is already stored
|
|
438
|
+
// keeps working either way. The KEY is never replaced here — it is generated once and
|
|
439
|
+
// kept, because every stored object names the key it was sealed with. Replacing one
|
|
440
|
+
// is rotation, and rotation re-encrypts.
|
|
441
|
+
const set = await this.#encryptionFor(patch.encryption, c.encryption, c.$keys);
|
|
442
|
+
next.encryption = set.encryption;
|
|
443
|
+
if (set.keys) next.$keys = set.keys;
|
|
444
|
+
}
|
|
300
445
|
await this.kv.set(NS, id, next);
|
|
301
446
|
return this.describe(next, principal);
|
|
302
447
|
}
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// The wrapper around an encrypted object's bytes.
|
|
2
|
+
//
|
|
3
|
+
// An encrypted item is not a blob of ciphertext — it is a readable header followed by
|
|
4
|
+
// independently-encrypted chunks. Both halves of that are load-bearing.
|
|
5
|
+
//
|
|
6
|
+
// READABLE HEADER. Whoever holds the object must be able to learn what key it wants
|
|
7
|
+
// without having the key: which collection key encrypted it (the fingerprint), how it was
|
|
8
|
+
// encrypted, and how big it really is. That is what lets a sideloaded object — one copied
|
|
9
|
+
// into the bucket from somewhere else, or left behind by a half-finished key rotation —
|
|
10
|
+
// be matched to a key instead of being an unreadable mystery. It is also why the
|
|
11
|
+
// collection carries a fingerprint AND every object carries one: the collection's says
|
|
12
|
+
// which key to ask the user for, the object's says whether this particular object is
|
|
13
|
+
// actually encrypted with it.
|
|
14
|
+
//
|
|
15
|
+
// CHUNKS. Range requests are load-bearing in this drive already: the service worker slices
|
|
16
|
+
// ranges out of pinned files, the text viewer reads the first 512KB of a large file rather
|
|
17
|
+
// than pulling gigabytes to show a screenful, and media seeking is a range request per
|
|
18
|
+
// seek. A single AES-GCM blob has exactly one authentication tag over the whole message,
|
|
19
|
+
// so reading one byte means fetching and decrypting all of them. Fixed-size chunks turn a
|
|
20
|
+
// plaintext range into a chunk range, and only those chunks are fetched and decrypted.
|
|
21
|
+
//
|
|
22
|
+
// The cost is honest and small: 16 bytes of tag per chunk, and a header. At the default
|
|
23
|
+
// chunk size that is under 0.02% overhead.
|
|
24
|
+
|
|
25
|
+
import { TroveError } from '../errors.js';
|
|
26
|
+
|
|
27
|
+
/** "TRV1" — enough to recognise the format, and to refuse bytes that are not it. */
|
|
28
|
+
const MAGIC = new Uint8Array([0x54, 0x52, 0x56, 0x31]);
|
|
29
|
+
|
|
30
|
+
export const VERSION = 1;
|
|
31
|
+
/** AES-256-GCM. Recorded per object so the format can gain another without ambiguity. */
|
|
32
|
+
export const ALG_AES_256_GCM = 1;
|
|
33
|
+
|
|
34
|
+
/** AES-GCM's authentication tag. */
|
|
35
|
+
export const TAG_BYTES = 16;
|
|
36
|
+
/** Random per object; the per-chunk nonce is this followed by the chunk index. */
|
|
37
|
+
const NONCE_PREFIX_BYTES = 8;
|
|
38
|
+
const NONCE_BYTES = 12;
|
|
39
|
+
/** Truncated: 128 bits is far past collision risk for "which key is this". */
|
|
40
|
+
export const FINGERPRINT_BYTES = 16;
|
|
41
|
+
|
|
42
|
+
export const HEADER_BYTES = 44;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 1 MiB of plaintext per chunk.
|
|
46
|
+
*
|
|
47
|
+
* The trade is seek granularity against overhead and round trips. Too small and a large
|
|
48
|
+
* file becomes thousands of tags and a range read becomes many requests; too large and
|
|
49
|
+
* seeking to one second of audio drags megabytes. 1 MiB keeps overhead at 16 bytes per
|
|
50
|
+
* MiB — about 0.0015% — while keeping a seek to roughly one chunk.
|
|
51
|
+
*/
|
|
52
|
+
export const DEFAULT_CHUNK_SIZE = 1024 * 1024;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* How many bytes the ciphertext of a plaintext of this size occupies.
|
|
56
|
+
*
|
|
57
|
+
* Needed before a single byte is encrypted: the upload plan negotiates part boundaries and
|
|
58
|
+
* a per-file size limit against the size that will actually be STORED, and a plan computed
|
|
59
|
+
* against the plaintext size is wrong by a tag per chunk. On a multipart upload that is the
|
|
60
|
+
* difference between a final part that exists and one that does not.
|
|
61
|
+
*/
|
|
62
|
+
export function cipherSize(plaintextSize, chunkSize = DEFAULT_CHUNK_SIZE) {
|
|
63
|
+
assertChunkSize(chunkSize);
|
|
64
|
+
if (!(plaintextSize >= 0)) throw TroveError.invalid('plaintextSize must be a non-negative number');
|
|
65
|
+
// An empty file still gets a header, and still has one (empty) chunk — so that "is this
|
|
66
|
+
// encrypted" has the same answer for an empty file as for any other.
|
|
67
|
+
const chunks = plaintextSize === 0 ? 1 : Math.ceil(plaintextSize / chunkSize);
|
|
68
|
+
return HEADER_BYTES + plaintextSize + chunks * TAG_BYTES;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The inverse, for reporting the real size of something already stored. */
|
|
72
|
+
export function plaintextSizeOf(cipherTotal, chunkSize = DEFAULT_CHUNK_SIZE) {
|
|
73
|
+
assertChunkSize(chunkSize);
|
|
74
|
+
const body = cipherTotal - HEADER_BYTES;
|
|
75
|
+
if (body < TAG_BYTES) throw TroveError.invalid('Ciphertext is too short to be an envelope');
|
|
76
|
+
const full = Math.floor(body / (chunkSize + TAG_BYTES));
|
|
77
|
+
const rest = body - full * (chunkSize + TAG_BYTES);
|
|
78
|
+
// A trailing partial chunk still carries a full tag.
|
|
79
|
+
return full * chunkSize + Math.max(0, rest - TAG_BYTES);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function assertChunkSize(chunkSize) {
|
|
83
|
+
if (!Number.isInteger(chunkSize) || chunkSize <= 0 || chunkSize > 0xffffffff) {
|
|
84
|
+
throw TroveError.invalid(`Invalid chunk size ${chunkSize}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @typedef {object} EnvelopeHeader
|
|
90
|
+
* @property {number} version
|
|
91
|
+
* @property {number} algorithm
|
|
92
|
+
* @property {number} chunkSize plaintext bytes per chunk
|
|
93
|
+
* @property {number} plaintextSize the real size, which the ciphertext length does not give
|
|
94
|
+
* @property {Uint8Array} noncePrefix
|
|
95
|
+
* @property {Uint8Array} fingerprint which key this was encrypted with
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/** @param {EnvelopeHeader} h */
|
|
99
|
+
export function encodeHeader(h) {
|
|
100
|
+
const out = new Uint8Array(HEADER_BYTES);
|
|
101
|
+
const view = new DataView(out.buffer);
|
|
102
|
+
out.set(MAGIC, 0);
|
|
103
|
+
out[4] = h.version ?? VERSION;
|
|
104
|
+
out[5] = h.algorithm ?? ALG_AES_256_GCM;
|
|
105
|
+
// 6..7 reserved: written as zero and required to BE zero on read, so a future flag
|
|
106
|
+
// cannot be silently ignored by a reader that predates it.
|
|
107
|
+
view.setUint32(8, h.chunkSize, true);
|
|
108
|
+
// A double holds an exact integer to 2^53, which is 9 petabytes — past any file, and
|
|
109
|
+
// past what the rest of this codebase handles as a JS number anyway.
|
|
110
|
+
view.setBigUint64(12, BigInt(h.plaintextSize), true);
|
|
111
|
+
out.set(h.noncePrefix, 20);
|
|
112
|
+
out.set(h.fingerprint, 28);
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** @returns {EnvelopeHeader} */
|
|
117
|
+
export function decodeHeader(bytes) {
|
|
118
|
+
if (bytes.length < HEADER_BYTES) throw TroveError.invalid('Not an encrypted object: too short');
|
|
119
|
+
for (let i = 0; i < MAGIC.length; i++) {
|
|
120
|
+
if (bytes[i] !== MAGIC[i]) throw TroveError.invalid('Not an encrypted object');
|
|
121
|
+
}
|
|
122
|
+
const version = bytes[4];
|
|
123
|
+
if (version !== VERSION) {
|
|
124
|
+
// Named rather than "corrupt". A reader that meets a newer envelope should say the
|
|
125
|
+
// drive is newer than it is, not that the file is broken.
|
|
126
|
+
throw TroveError.invalid(`This object uses envelope version ${version}, and this client understands ${VERSION}`);
|
|
127
|
+
}
|
|
128
|
+
const algorithm = bytes[5];
|
|
129
|
+
if (algorithm !== ALG_AES_256_GCM) throw TroveError.invalid(`Unknown encryption algorithm ${algorithm}`);
|
|
130
|
+
if (bytes[6] !== 0 || bytes[7] !== 0) {
|
|
131
|
+
throw TroveError.invalid('This object sets envelope flags this client does not understand');
|
|
132
|
+
}
|
|
133
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
134
|
+
const chunkSize = view.getUint32(8, true);
|
|
135
|
+
assertChunkSize(chunkSize);
|
|
136
|
+
return {
|
|
137
|
+
version,
|
|
138
|
+
algorithm,
|
|
139
|
+
chunkSize,
|
|
140
|
+
plaintextSize: Number(view.getBigUint64(12, true)),
|
|
141
|
+
noncePrefix: bytes.slice(20, 20 + NONCE_PREFIX_BYTES),
|
|
142
|
+
fingerprint: bytes.slice(28, 28 + FINGERPRINT_BYTES),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Does this look like one of ours? Cheap, and does not need the key. */
|
|
147
|
+
export function isEnvelope(bytes) {
|
|
148
|
+
if (!bytes || bytes.length < MAGIC.length) return false;
|
|
149
|
+
for (let i = 0; i < MAGIC.length; i++) if (bytes[i] !== MAGIC[i]) return false;
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The nonce for one chunk: the object's random prefix, then the chunk index.
|
|
155
|
+
*
|
|
156
|
+
* Uniqueness per (key, nonce) is the one thing AES-GCM cannot survive losing — a repeat
|
|
157
|
+
* leaks the XOR of two plaintexts and, worse, the authentication key. The prefix is random
|
|
158
|
+
* per object so two objects never collide, and the counter makes chunks within an object
|
|
159
|
+
* distinct. This is why an object may never be re-encrypted in place under the same key
|
|
160
|
+
* with a fresh prefix omitted: rotation writes a NEW object.
|
|
161
|
+
*/
|
|
162
|
+
function nonceFor(prefix, chunkIndex) {
|
|
163
|
+
const nonce = new Uint8Array(NONCE_BYTES);
|
|
164
|
+
nonce.set(prefix, 0);
|
|
165
|
+
new DataView(nonce.buffer).setUint32(NONCE_PREFIX_BYTES, chunkIndex, true);
|
|
166
|
+
return nonce;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Which ciphertext bytes are needed to answer a plaintext range, and how much of the
|
|
171
|
+
* decrypted result to discard at each end.
|
|
172
|
+
*
|
|
173
|
+
* This is the whole reason for chunking. A viewer asking for the first 512KB of a 4GB file
|
|
174
|
+
* gets one chunk fetched instead of four gigabytes.
|
|
175
|
+
*
|
|
176
|
+
* @param {{start: number, end: number}} range inclusive plaintext byte range
|
|
177
|
+
* @param {EnvelopeHeader} header
|
|
178
|
+
*/
|
|
179
|
+
export function cipherRangeFor(range, header) {
|
|
180
|
+
const { chunkSize, plaintextSize } = header;
|
|
181
|
+
const start = Math.max(0, range.start);
|
|
182
|
+
const end = Math.min(range.end ?? plaintextSize - 1, plaintextSize - 1);
|
|
183
|
+
if (start > end) throw TroveError.invalid('Empty or reversed range');
|
|
184
|
+
const firstChunk = Math.floor(start / chunkSize);
|
|
185
|
+
const lastChunk = Math.floor(end / chunkSize);
|
|
186
|
+
const stride = chunkSize + TAG_BYTES;
|
|
187
|
+
return {
|
|
188
|
+
firstChunk,
|
|
189
|
+
lastChunk,
|
|
190
|
+
// Inclusive ciphertext byte range to fetch.
|
|
191
|
+
cipherStart: HEADER_BYTES + firstChunk * stride,
|
|
192
|
+
cipherEnd: Math.min(HEADER_BYTES + (lastChunk + 1) * stride, cipherSize(plaintextSize, chunkSize)) - 1,
|
|
193
|
+
// Once those chunks are decrypted and joined, the caller wants this slice of them.
|
|
194
|
+
trimStart: start - firstChunk * chunkSize,
|
|
195
|
+
trimEnd: end - firstChunk * chunkSize + 1,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function importKey(rawKey) {
|
|
200
|
+
if (!(rawKey instanceof Uint8Array) || rawKey.length !== 32) {
|
|
201
|
+
throw TroveError.invalid('An AES-256 key must be 32 bytes');
|
|
202
|
+
}
|
|
203
|
+
return crypto.subtle.importKey('raw', rawKey, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Encrypt a whole object.
|
|
208
|
+
*
|
|
209
|
+
* @param {Uint8Array} rawKey 32-byte data key
|
|
210
|
+
* @param {Uint8Array} plaintext
|
|
211
|
+
* @param {{fingerprint: Uint8Array, chunkSize?: number}} opts
|
|
212
|
+
* @returns {Promise<Uint8Array>} header + chunks
|
|
213
|
+
*/
|
|
214
|
+
export async function encrypt(rawKey, plaintext, { fingerprint, chunkSize = DEFAULT_CHUNK_SIZE } = {}) {
|
|
215
|
+
assertChunkSize(chunkSize);
|
|
216
|
+
if (!fingerprint || fingerprint.length !== FINGERPRINT_BYTES) {
|
|
217
|
+
throw TroveError.invalid(`A fingerprint must be ${FINGERPRINT_BYTES} bytes`);
|
|
218
|
+
}
|
|
219
|
+
const key = await importKey(rawKey);
|
|
220
|
+
const noncePrefix = crypto.getRandomValues(new Uint8Array(NONCE_PREFIX_BYTES));
|
|
221
|
+
const header = encodeHeader({
|
|
222
|
+
version: VERSION,
|
|
223
|
+
algorithm: ALG_AES_256_GCM,
|
|
224
|
+
chunkSize,
|
|
225
|
+
plaintextSize: plaintext.length,
|
|
226
|
+
noncePrefix,
|
|
227
|
+
fingerprint,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const out = new Uint8Array(cipherSize(plaintext.length, chunkSize));
|
|
231
|
+
out.set(header, 0);
|
|
232
|
+
let at = HEADER_BYTES;
|
|
233
|
+
const chunks = plaintext.length === 0 ? 1 : Math.ceil(plaintext.length / chunkSize);
|
|
234
|
+
for (let i = 0; i < chunks; i++) {
|
|
235
|
+
const slice = plaintext.subarray(i * chunkSize, Math.min((i + 1) * chunkSize, plaintext.length));
|
|
236
|
+
const sealed = new Uint8Array(await crypto.subtle.encrypt(
|
|
237
|
+
{ name: 'AES-GCM', iv: nonceFor(noncePrefix, i) }, key, slice,
|
|
238
|
+
));
|
|
239
|
+
out.set(sealed, at);
|
|
240
|
+
at += sealed.length;
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Decrypt a whole object.
|
|
247
|
+
*
|
|
248
|
+
* A failure here is deliberately not distinguished into "wrong key" versus "tampered":
|
|
249
|
+
* AES-GCM cannot tell them apart, and a message that guessed would be guessing.
|
|
250
|
+
*/
|
|
251
|
+
export async function decrypt(rawKey, envelope) {
|
|
252
|
+
const header = decodeHeader(envelope);
|
|
253
|
+
const key = await importKey(rawKey);
|
|
254
|
+
return decryptChunks(key, envelope.subarray(HEADER_BYTES), header, 0);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Decrypt a run of chunks that starts at `firstChunk` — the partner of `cipherRangeFor`,
|
|
259
|
+
* for the case where only part of the object was fetched.
|
|
260
|
+
*/
|
|
261
|
+
export async function decryptRange(rawKey, cipherPart, header, firstChunk) {
|
|
262
|
+
const key = await importKey(rawKey);
|
|
263
|
+
return decryptChunks(key, cipherPart, header, firstChunk);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function decryptChunks(key, body, header, firstChunk) {
|
|
267
|
+
const { chunkSize, noncePrefix } = header;
|
|
268
|
+
const stride = chunkSize + TAG_BYTES;
|
|
269
|
+
const pieces = [];
|
|
270
|
+
let total = 0;
|
|
271
|
+
for (let at = 0, i = firstChunk; at < body.length; at += stride, i++) {
|
|
272
|
+
const sealed = body.subarray(at, Math.min(at + stride, body.length));
|
|
273
|
+
if (sealed.length <= TAG_BYTES && sealed.length !== TAG_BYTES) {
|
|
274
|
+
throw TroveError.invalid('Truncated encrypted object');
|
|
275
|
+
}
|
|
276
|
+
let opened;
|
|
277
|
+
try {
|
|
278
|
+
opened = new Uint8Array(await crypto.subtle.decrypt(
|
|
279
|
+
{ name: 'AES-GCM', iv: nonceFor(noncePrefix, i) }, key, sealed,
|
|
280
|
+
));
|
|
281
|
+
} catch {
|
|
282
|
+
throw TroveError.invalid('Could not decrypt: wrong key, or the data has been altered');
|
|
283
|
+
}
|
|
284
|
+
pieces.push(opened);
|
|
285
|
+
total += opened.length;
|
|
286
|
+
}
|
|
287
|
+
const out = new Uint8Array(total);
|
|
288
|
+
let at = 0;
|
|
289
|
+
for (const p of pieces) { out.set(p, at); at += p.length; }
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Decrypt a ciphertext stream chunk by chunk, without holding the object in memory.
|
|
295
|
+
*
|
|
296
|
+
* The buffering version is fine for a text preview and wrong for a two-hour video: a
|
|
297
|
+
* server that decrypted whole objects would hold one per concurrent viewer, and on a
|
|
298
|
+
* Worker that is the memory limit rather than a slowdown. This reassembles exactly one
|
|
299
|
+
* chunk at a time and emits its plaintext as soon as the tag verifies.
|
|
300
|
+
*
|
|
301
|
+
* Emitting per chunk does mean unverified bytes are never emitted, but earlier chunks are
|
|
302
|
+
* released before later ones are checked — which is inherent to streaming anything
|
|
303
|
+
* authenticated, and is why the chunk is the unit of trust rather than the file.
|
|
304
|
+
*
|
|
305
|
+
* @param {Uint8Array} rawKey
|
|
306
|
+
* @param {EnvelopeHeader} header
|
|
307
|
+
* @param {ReadableStream<Uint8Array>} cipherStream body only, no header
|
|
308
|
+
* @param {number} [firstChunk] index of the first chunk in the stream
|
|
309
|
+
*/
|
|
310
|
+
export async function decryptStream(rawKey, header, cipherStream, firstChunk = 0) {
|
|
311
|
+
const key = await importKey(rawKey);
|
|
312
|
+
const stride = header.chunkSize + TAG_BYTES;
|
|
313
|
+
const reader = cipherStream.getReader();
|
|
314
|
+
let held = new Uint8Array(0);
|
|
315
|
+
let index = firstChunk;
|
|
316
|
+
|
|
317
|
+
const take = (n) => {
|
|
318
|
+
const out = held.subarray(0, n);
|
|
319
|
+
held = held.subarray(n);
|
|
320
|
+
return out;
|
|
321
|
+
};
|
|
322
|
+
const open = async (sealed) => {
|
|
323
|
+
try {
|
|
324
|
+
return new Uint8Array(await crypto.subtle.decrypt(
|
|
325
|
+
{ name: 'AES-GCM', iv: nonceFor(header.noncePrefix, index++) }, key, sealed,
|
|
326
|
+
));
|
|
327
|
+
} catch {
|
|
328
|
+
throw TroveError.invalid('Could not decrypt: wrong key, or the data has been altered');
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
return new ReadableStream({
|
|
333
|
+
async pull(controller) {
|
|
334
|
+
for (;;) {
|
|
335
|
+
if (held.length >= stride) {
|
|
336
|
+
controller.enqueue(await open(take(stride)));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const { value, done } = await reader.read();
|
|
340
|
+
if (done) {
|
|
341
|
+
// Whatever is left is the final, partial chunk — still a whole tag, but fewer
|
|
342
|
+
// than a chunk of plaintext.
|
|
343
|
+
if (held.length) {
|
|
344
|
+
if (held.length < TAG_BYTES) throw TroveError.invalid('Truncated encrypted object');
|
|
345
|
+
controller.enqueue(await open(take(held.length)));
|
|
346
|
+
}
|
|
347
|
+
controller.close();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const grown = new Uint8Array(held.length + value.length);
|
|
351
|
+
grown.set(held, 0);
|
|
352
|
+
grown.set(value, held.length);
|
|
353
|
+
held = grown;
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
cancel(reason) {
|
|
357
|
+
return reader.cancel(reason);
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Seal a plaintext stream into an envelope stream, a chunk at a time.
|
|
364
|
+
*
|
|
365
|
+
* The counterpart to `decryptStream`, and needed for the same reason: `encrypt` allocates
|
|
366
|
+
* the whole ciphertext, so re-encrypting a large object meant holding the file twice over —
|
|
367
|
+
* once decrypted and once sealed. On a Cloudflare isolate that is the memory limit rather
|
|
368
|
+
* than a slowdown, which capped key rotation at small files.
|
|
369
|
+
*
|
|
370
|
+
* The size has to be known up front because it goes in the header, which is written before
|
|
371
|
+
* any chunk. That is not a limitation in practice: every caller is re-sealing something
|
|
372
|
+
* whose size is already recorded.
|
|
373
|
+
*
|
|
374
|
+
* @param {Uint8Array} rawKey
|
|
375
|
+
* @param {ReadableStream<Uint8Array>} plaintext
|
|
376
|
+
* @param {{fingerprint: Uint8Array, plaintextSize: number, chunkSize?: number}} opts
|
|
377
|
+
*/
|
|
378
|
+
export async function encryptStream(rawKey, plaintext, { fingerprint, plaintextSize, chunkSize = DEFAULT_CHUNK_SIZE } = {}) {
|
|
379
|
+
assertChunkSize(chunkSize);
|
|
380
|
+
if (!fingerprint || fingerprint.length !== FINGERPRINT_BYTES) {
|
|
381
|
+
throw TroveError.invalid(`A fingerprint must be ${FINGERPRINT_BYTES} bytes`);
|
|
382
|
+
}
|
|
383
|
+
const key = await importKey(rawKey);
|
|
384
|
+
const noncePrefix = crypto.getRandomValues(new Uint8Array(NONCE_PREFIX_BYTES));
|
|
385
|
+
const header = encodeHeader({
|
|
386
|
+
version: VERSION, algorithm: ALG_AES_256_GCM, chunkSize, plaintextSize, noncePrefix, fingerprint,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const reader = plaintext.getReader();
|
|
390
|
+
let held = new Uint8Array(0);
|
|
391
|
+
let index = 0;
|
|
392
|
+
let wroteHeader = false;
|
|
393
|
+
let seen = 0;
|
|
394
|
+
|
|
395
|
+
const seal = async (piece) => new Uint8Array(await crypto.subtle.encrypt(
|
|
396
|
+
{ name: 'AES-GCM', iv: nonceFor(noncePrefix, index++) }, key, piece,
|
|
397
|
+
));
|
|
398
|
+
|
|
399
|
+
return new ReadableStream({
|
|
400
|
+
async pull(controller) {
|
|
401
|
+
if (!wroteHeader) {
|
|
402
|
+
wroteHeader = true;
|
|
403
|
+
controller.enqueue(header);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
for (;;) {
|
|
407
|
+
if (held.length >= chunkSize) {
|
|
408
|
+
const piece = held.subarray(0, chunkSize);
|
|
409
|
+
held = held.subarray(chunkSize);
|
|
410
|
+
seen += piece.length;
|
|
411
|
+
controller.enqueue(await seal(piece));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const { value, done } = await reader.read();
|
|
415
|
+
if (done) {
|
|
416
|
+
// The trailing partial chunk — and, for an empty file, the one empty chunk that
|
|
417
|
+
// makes "is this encrypted" answerable the same way at any size.
|
|
418
|
+
if (held.length || seen === 0) {
|
|
419
|
+
seen += held.length;
|
|
420
|
+
controller.enqueue(await seal(held));
|
|
421
|
+
held = new Uint8Array(0);
|
|
422
|
+
}
|
|
423
|
+
if (seen !== plaintextSize) {
|
|
424
|
+
// Refused rather than written: an envelope whose header disagrees with its body
|
|
425
|
+
// decrypts to the wrong length forever, and the header cannot be fixed later
|
|
426
|
+
// without re-encrypting.
|
|
427
|
+
throw TroveError.invalid(
|
|
428
|
+
`Expected ${plaintextSize} bytes to encrypt and received ${seen}`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
controller.close();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const grown = new Uint8Array(held.length + value.length);
|
|
435
|
+
grown.set(held, 0);
|
|
436
|
+
grown.set(value, held.length);
|
|
437
|
+
held = grown;
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
cancel(reason) {
|
|
441
|
+
return reader.cancel(reason);
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
}
|