@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.
Files changed (33) hide show
  1. package/README.md +6 -0
  2. package/package.json +1 -1
  3. package/packages/core/src/collections/index.js +146 -1
  4. package/packages/core/src/encryption/envelope.js +444 -0
  5. package/packages/core/src/encryption/exposure.js +101 -0
  6. package/packages/core/src/encryption/keys.js +88 -0
  7. package/packages/core/src/encryption/policy.js +112 -0
  8. package/packages/core/src/encryption/rotation.js +313 -0
  9. package/packages/core/src/index.js +17 -1
  10. package/packages/core/src/links.js +85 -0
  11. package/packages/core/src/metadata/memory.js +3 -1
  12. package/packages/core/src/metadata/sqlite.js +27 -7
  13. package/packages/core/src/scan.js +35 -1
  14. package/packages/core/src/storage/cost.js +228 -0
  15. package/packages/core/src/storage/drivers.js +13 -4
  16. package/packages/core/src/storage/registry.js +37 -4
  17. package/packages/core/src/uploads.js +197 -15
  18. package/packages/core/src/vfs.js +148 -9
  19. package/packages/server/src/engine/providers/core.js +38 -3
  20. package/packages/server/src/index.js +8 -0
  21. package/packages/server/src/routes.js +29 -1
  22. package/packages/web/dist/assets/main-y778bpte.js +356 -0
  23. package/packages/web/dist/assets/{main-f0f2tfhp.js.map → main-y778bpte.js.map} +10 -8
  24. package/packages/web/dist/assets/styles-nfy8t3n1.css +1 -0
  25. package/packages/web/dist/index.html +9 -3
  26. package/packages/web/dist/sw.js +1 -1
  27. package/packages/web/src/bl/actions.js +20 -4
  28. package/packages/web/src/bl/services.js +64 -4
  29. package/packages/web/src/platform/api.js +134 -11
  30. package/packages/web/src/styles.css +3 -0
  31. package/packages/web/src/ui/components/overlays.js +13 -0
  32. package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
  33. package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
@@ -0,0 +1,101 @@
1
+ // What encryption does not protect you from, said plainly and per collection.
2
+ //
3
+ // Encryption here defends the BUCKET. Anything that indexes a file sees it in the clear,
4
+ // because indexing is by definition reading the contents — the server decrypts before
5
+ // handing bytes to an indexer, and that is the whole reason full search still works on an
6
+ // encrypted collection. So a badge saying "encrypted" is true and, on its own, misleading.
7
+ //
8
+ // The disclosure that matters is therefore not about encryption at all. It is: which things
9
+ // read your files, which of them are third-party code, and where that code is allowed to
10
+ // send what it reads. A built-in indexer runs in this drive and talks to nobody. A plugin
11
+ // indexer might be pointed at an external API, and the manifest already says which one.
12
+ //
13
+ // Read off the manifest rather than written as prose. A sentence in a settings page drifts
14
+ // from what a plugin is actually permitted the moment either changes; a list derived from
15
+ // the declaration it is enforced against cannot. If it says a plugin may reach one host,
16
+ // that is because the plugin may reach exactly that host.
17
+
18
+ /**
19
+ * @typedef {object} IndexerExposure
20
+ * @property {string} id
21
+ * @property {string} name
22
+ * @property {'built-in'|'plugin'} source
23
+ * @property {string|null} pluginId
24
+ * @property {string[]} endpoints where this one may send what it reads; empty means nowhere
25
+ */
26
+
27
+ /**
28
+ * Who reads the files in this collection, and where it can send them.
29
+ *
30
+ * @param {object} deps
31
+ * @param {Array<{id: string, displayName?: string}>} deps.indexers what will run
32
+ * @param {Array<object>} [deps.plugins] installed plugin records, with manifests
33
+ * @param {(manifest: object) => string[]} [deps.endpointsOf] how to read declared egress
34
+ * @param {object|null} [deps.encryption] the collection's encryption config
35
+ */
36
+ export function describeExposure({ indexers = [], plugins = [], endpointsOf = null, encryption = null } = {}) {
37
+ // Defaulting this to `() => []` would have every plugin report "reaches nowhere" whenever
38
+ // a caller forgot to wire it — an affirmative safety claim made with no evidence, which
39
+ // is the same mistake as calling an unresolved plugin built-in. No reader means unknown.
40
+ const readEndpoints = typeof endpointsOf === 'function' ? endpointsOf : null;
41
+ // A plugin indexer's id is a contribution URI — `trove+contrib:<domain>/<name>/<what>` —
42
+ // so the plugin it belongs to is derivable from the id rather than tracked separately.
43
+ const byId = new Map();
44
+ for (const p of plugins) {
45
+ const id = p.id || p.manifest?.name;
46
+ if (id) byId.set(id, p);
47
+ }
48
+
49
+ const rows = indexers.map((i) => {
50
+ // Whether something is a plugin is decided by its ID, not by whether we managed to find
51
+ // its install record. An indexer whose plugin we cannot resolve is still third-party
52
+ // code, and calling it built-in would be the most dangerous mislabel available here.
53
+ const contributed = String(i.id || '').startsWith('trove+contrib:');
54
+ const owner = contributed ? pluginOf(i.id, byId) : null;
55
+ return {
56
+ id: i.id,
57
+ name: i.displayName || i.id,
58
+ source: contributed ? 'plugin' : 'built-in',
59
+ pluginId: owner ? (owner.id || owner.manifest?.name || null) : null,
60
+ // `[]` is an affirmative claim that this reaches nowhere. Without a manifest we
61
+ // cannot make it, so an unresolved plugin gets `null` — unknown — and is counted
62
+ // among the things that might send data out rather than among the things that cannot.
63
+ endpoints: owner && readEndpoints ? [...new Set(readEndpoints(owner.manifest) || [])] : (contributed ? null : []),
64
+ };
65
+ });
66
+
67
+ const reachOut = rows.filter((r) => r.endpoints === null || r.endpoints.length);
68
+ const unknown = rows.filter((r) => r.endpoints === null);
69
+ return {
70
+ encrypted: !!encryption?.enabled,
71
+ // Said explicitly, because "encrypted" without a scope is the thing people
72
+ // over-read. This is what the encryption is and is not.
73
+ protects: encryption?.enabled
74
+ ? 'Files are encrypted before they reach the storage provider, so the bucket holds ciphertext. '
75
+ + 'It is not end-to-end: this drive holds the key, and anything that indexes a file reads it in the clear.'
76
+ : null,
77
+ indexers: rows,
78
+ // The single fact someone should be able to see without reading a list.
79
+ anyEgress: reachOut.length > 0,
80
+ egressSummary: reachOut.length
81
+ ? `${reachOut.length} of ${rows.length} indexers may send file contents outside this drive.`
82
+ + (unknown.length
83
+ ? ` ${unknown.length} could not be checked, because the plugin that provides it is not installed here.`
84
+ : '')
85
+ : rows.length
86
+ ? 'No indexer on this collection may send file contents anywhere.'
87
+ : 'Nothing indexes this collection.',
88
+ };
89
+ }
90
+
91
+ /** Which installed plugin an indexer id belongs to, if any. */
92
+ function pluginOf(indexerId, byId) {
93
+ const id = String(indexerId || '');
94
+ if (!id.startsWith('trove+contrib:')) return null;
95
+ // `trove+contrib:<domain>/<name>/<contribution>` — the plugin is domain/name.
96
+ const path = id.slice('trove+contrib:'.length);
97
+ const parts = path.split('/');
98
+ if (parts.length < 2) return null;
99
+ const owner = `${parts[0]}/${parts[1]}`;
100
+ return byId.get(owner) || byId.get(parts[1]) || null;
101
+ }
@@ -0,0 +1,88 @@
1
+ // The key a collection's objects are encrypted with, and the fingerprint that names it.
2
+ //
3
+ // The threat model is the STORAGE HOST, and only that: the bucket holds ciphertext, so a
4
+ // leaked bucket credential, a public-bucket misconfiguration, or a storage vendor who is
5
+ // not the compute vendor learns sizes and timestamps and no content. The server holds the
6
+ // key — it has to, in order to hand it to a client and to decrypt for an indexer — so this
7
+ // is explicitly not end-to-end and does not pretend to be.
8
+ //
9
+ // Which is why the key is GENERATED rather than derived from something a user types.
10
+ // A passphrase would buy nothing here: the server knows the key either way, so there is no
11
+ // protection to gain from the user holding it, and every cost still applies — a slow KDF
12
+ // on every unlock, a prompt in front of every collection, a key that can be forgotten and
13
+ // then cannot be reset by anyone, and a re-encryption of everything whenever someone
14
+ // changes their password. A random 256-bit key has none of that and is stronger than any
15
+ // passphrase a person would choose.
16
+ //
17
+ // Access to the key is therefore an ACCESS-CONTROL question, not a knowledge one: whoever
18
+ // may read the collection may have the key, because they may already read its contents.
19
+ //
20
+ // If this ever becomes end-to-end, the change is confined to where the key comes from and
21
+ // who is allowed it. The envelope, the fingerprint, and every stored object are unchanged.
22
+
23
+ import { TroveError } from '../errors.js';
24
+
25
+ const enc = new TextEncoder();
26
+
27
+ export const KEY_BYTES = 32; // AES-256
28
+ export const FINGERPRINT_BYTES = 16;
29
+
30
+ /** A new collection key. Random, because there is nothing to derive it from. */
31
+ export function generateDataKey() {
32
+ return crypto.getRandomValues(new Uint8Array(KEY_BYTES));
33
+ }
34
+
35
+ /**
36
+ * The public name of a key.
37
+ *
38
+ * Stamped into every object and recorded on the collection, so an object can be matched to
39
+ * a key by whoever holds it — which is what makes a sideloaded object identifiable and
40
+ * what lets a key rotation tell what it has already converted.
41
+ *
42
+ * Derived through HKDF rather than being a plain hash of the key. With a random 256-bit key
43
+ * there is nothing to guess, so this is no longer load-bearing against an offline attack;
44
+ * it stays because a fingerprint should be a value derived FOR this purpose, and a bare
45
+ * `SHA-256(key)` is a value that might mean something somewhere else. The label makes it
46
+ * unambiguously this and nothing else.
47
+ *
48
+ * @param {Uint8Array} dataKey
49
+ * @returns {Promise<Uint8Array>} 16 bytes — far past collision risk for "which key is this"
50
+ */
51
+ export async function fingerprint(dataKey) {
52
+ if (!(dataKey instanceof Uint8Array) || dataKey.length !== KEY_BYTES) {
53
+ throw TroveError.invalid('A data key must be 32 bytes');
54
+ }
55
+ const base = await crypto.subtle.importKey('raw', dataKey, 'HKDF', false, ['deriveBits']);
56
+ const bits = await crypto.subtle.deriveBits(
57
+ { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: enc.encode('trove-key-id') },
58
+ base,
59
+ FINGERPRINT_BYTES * 8,
60
+ );
61
+ return new Uint8Array(bits);
62
+ }
63
+
64
+ /** Bytes as lowercase hex — how a key and a fingerprint are written down. */
65
+ export function toHex(b) {
66
+ return [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
67
+ }
68
+
69
+ export function fromHex(hex) {
70
+ if (typeof hex !== 'string' || !hex.length || hex.length % 2 || /[^0-9a-f]/i.test(hex)) {
71
+ throw TroveError.invalid('Not hex');
72
+ }
73
+ return new Uint8Array(hex.match(/../g).map((h) => parseInt(h, 16)));
74
+ }
75
+
76
+ /** Kept as the name the rest of the code already uses for a fingerprint in hex. */
77
+ export const fingerprintHex = toHex;
78
+
79
+ /**
80
+ * A fresh key and what the collection records about it.
81
+ *
82
+ * The key is returned separately from the config because they go to different places: the
83
+ * config is what any reader may see, and the key is what the server keeps.
84
+ */
85
+ export async function newCollectionKey() {
86
+ const dataKey = generateDataKey();
87
+ return { dataKey, config: { fingerprint: toHex(await fingerprint(dataKey)) } };
88
+ }
@@ -0,0 +1,112 @@
1
+ // Which items in a collection get encrypted, and what the collection tells a client
2
+ // about its key.
3
+ //
4
+ // Encryption is per collection and selective within it, because "encrypt everything" is
5
+ // not always what someone wants and the cost is not free: an encrypted object cannot be
6
+ // served straight from the bucket to something that does not hold the key, and the storage
7
+ // host can no longer deduplicate it. So a collection says which extensions and which media
8
+ // types are sensitive, and the rest is stored as it always was.
9
+ //
10
+ // The rules are matched at UPLOAD time and the answer is recorded in the object itself —
11
+ // the envelope header — rather than re-derived later. Rules change; an object that was
12
+ // encrypted must stay readable as an encrypted object regardless of what the collection
13
+ // says today, and one that was not must not suddenly be interpreted as one.
14
+
15
+ import { TroveError } from '../errors.js';
16
+
17
+ /**
18
+ * What a collection stores about its encryption.
19
+ *
20
+ * The fingerprint is safe to show anyone who may see the collection: it names the key
21
+ * without being it. The key itself is never part of this — it reaches a client through a
22
+ * transfer plan, which is authorized per operation.
23
+ *
24
+ * @typedef {object} EncryptionConfig
25
+ * @property {boolean} enabled
26
+ * @property {string} fingerprint hex — which key this collection's objects are sealed with
27
+ * @property {{extensions: string[], mimeTypes: string[], all: boolean}} rules
28
+ */
29
+
30
+ const normList = (v) => (Array.isArray(v) ? v : [])
31
+ .map((s) => String(s || '').trim().toLowerCase())
32
+ .filter(Boolean);
33
+
34
+ /** An extension without its dot, so ".PDF", "PDF" and "pdf" are one rule. */
35
+ const normExt = (e) => e.replace(/^\./, '');
36
+
37
+ /**
38
+ * Validate and normalise what a caller asked for, against the key the server holds.
39
+ *
40
+ * The fingerprint is a separate argument rather than a field of `input` because the two
41
+ * come from different places and only one of them is the caller's to decide: rules are
42
+ * asked for, the key is minted. A collection recorded as encrypted with no fingerprint is
43
+ * one whose objects could never be matched to a key, so it is refused rather than repaired.
44
+ *
45
+ * @param {object|null} input what the caller asked for: `{ enabled, rules }`
46
+ * @param {string} [fingerprint] hex, from the collection's key
47
+ */
48
+ export function normalizeEncryption(input, fingerprint) {
49
+ if (!input || input.enabled === false) return null;
50
+ const fp = fingerprint ?? input.fingerprint;
51
+ if (!fp) throw TroveError.invalid('An encrypted collection needs a key fingerprint');
52
+ if (!/^[0-9a-f]{32}$/.test(String(fp))) {
53
+ throw TroveError.invalid('Not a key fingerprint');
54
+ }
55
+ const r = input.rules || {};
56
+ const out = {
57
+ enabled: true,
58
+ fingerprint: String(fp),
59
+ rules: {
60
+ all: !!r.all,
61
+ extensions: [...new Set(normList(r.extensions).map(normExt))],
62
+ mimeTypes: [...new Set(normList(r.mimeTypes))],
63
+ },
64
+ };
65
+ if (!out.rules.all && !out.rules.extensions.length && !out.rules.mimeTypes.length) {
66
+ // Enabling encryption and matching nothing is almost certainly a mistake, and a silent
67
+ // one: every upload would be stored in the clear on a collection labelled encrypted.
68
+ throw TroveError.invalid(
69
+ 'This collection is set to encrypt, but no file would match. Choose "all files", or name some extensions or media types.',
70
+ );
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * Should this item be encrypted?
77
+ *
78
+ * A media type match is by full type or by its leading part, so `image` covers
79
+ * `image/png` without listing every format — which is how someone actually thinks about
80
+ * "encrypt my photos".
81
+ */
82
+ export function shouldEncrypt(encryption, { name = '', contentType = '' } = {}) {
83
+ if (!encryption?.enabled) return false;
84
+ const { rules } = encryption;
85
+ if (rules.all) return true;
86
+
87
+ const ext = normExt((String(name).match(/\.[^./\\]+$/) || [''])[0].toLowerCase());
88
+ if (ext && rules.extensions.includes(ext)) return true;
89
+
90
+ const type = String(contentType || '').toLowerCase().split(';')[0].trim();
91
+ if (!type) return false;
92
+ if (rules.mimeTypes.includes(type)) return true;
93
+ const [top] = type.split('/');
94
+ return !!top && rules.mimeTypes.includes(top);
95
+ }
96
+
97
+ /**
98
+ * What a client is told about a collection's encryption.
99
+ *
100
+ * Enough to know that objects here are sealed and which key seals them; never the key.
101
+ * There is no "locked" state and nothing to prompt for — a client that may read the
102
+ * collection is handed the key with the transfer plan, because being allowed to read the
103
+ * contents and being allowed to decrypt them are the same permission.
104
+ */
105
+ export function describeEncryption(encryption) {
106
+ if (!encryption?.enabled) return null;
107
+ return {
108
+ enabled: true,
109
+ fingerprint: encryption.fingerprint,
110
+ rules: { ...encryption.rules },
111
+ };
112
+ }
@@ -0,0 +1,313 @@
1
+ // Moving a collection's objects onto a new key, a slice at a time.
2
+ //
3
+ // Rotation is the one operation here that cannot be atomic. A collection can hold hundreds
4
+ // of thousands of objects and every one has to be read, decrypted, re-encrypted and
5
+ // written back — far more than fits in a request, a Worker invocation, or anyone's
6
+ // patience. So it is a long job that runs in pieces, and the design follows from what
7
+ // happens when a piece does not finish.
8
+ //
9
+ // TWO KEYS ARE LIVE THROUGHOUT. `beginRotation` mints the new key and makes it current
10
+ // immediately, so everything uploaded from that moment is already correct and the job only
11
+ // ever has to deal with a shrinking set. Everything not yet moved still opens with the old
12
+ // key, because its envelope names it. Nothing is unreadable at any point, including
13
+ // halfway through, including after a crash.
14
+ //
15
+ // THE CURSOR IS PERSISTED, NOT HELD. The process doing the work can vanish between slices
16
+ // — an evicted isolate, a redeploy, a laptop closing. Progress lives in the KeyValueStore
17
+ // with the rest of the drive's durable state, so the next slice picks up where the last
18
+ // one stopped rather than starting again. Re-running a slice that already ran is harmless:
19
+ // an object already on the current key is skipped, so the work is idempotent by
20
+ // construction rather than by bookkeeping.
21
+ //
22
+ // IT FINISHES BY OBSERVATION. The old key is retired when a full pass finds nothing left
23
+ // on it — not when a counter says the job is done. A count can be wrong; a pass that finds
24
+ // nothing cannot be. That also handles the awkward case of an object uploaded onto the old
25
+ // key by a request that was in flight when the rotation started.
26
+
27
+ import { TroveError } from '../errors.js';
28
+ import { encryptStream, decodeHeader } from './envelope.js';
29
+ import { fromHex, fingerprint, toHex } from './keys.js';
30
+
31
+ const NS = 'rotations';
32
+
33
+ /** How long one slice may run before yielding, so a cron firing stays inside its budget. */
34
+ const DEFAULT_BUDGET_MS = 15_000;
35
+
36
+ /**
37
+ * How much sealed output to gather before sending a part.
38
+ *
39
+ * Above S3's 5 MiB floor for non-final parts, and small enough that peak memory during a
40
+ * rotation is a few megabytes rather than a function of the file.
41
+ */
42
+ const PART_TARGET_BYTES = 8 * 1024 * 1024;
43
+
44
+ /**
45
+ * @typedef {object} RotationState
46
+ * @property {string} collectionId
47
+ * @property {string} to fingerprint of the key being moved onto
48
+ * @property {string[]} from fingerprints being moved off
49
+ * @property {string|null} cursor where the last slice stopped
50
+ * @property {number} moved
51
+ * @property {number} failed
52
+ * @property {number} startedAt
53
+ * @property {'running'|'done'} status
54
+ */
55
+
56
+ export class RotationService {
57
+ /**
58
+ * @param {object} deps
59
+ * @param {import('../kv.js').KeyValueStore} deps.kv where progress survives
60
+ * @param {import('../vfs.js').Vfs} deps.vfs
61
+ * @param {import('../collections/index.js').CollectionService} deps.collections
62
+ */
63
+ constructor({ kv, vfs, collections }) {
64
+ if (!kv || !vfs || !collections) throw TroveError.invalid('RotationService needs kv, vfs and collections');
65
+ this.kv = kv;
66
+ this.vfs = vfs;
67
+ this.collections = collections;
68
+ }
69
+
70
+ async state(collectionId) {
71
+ return this.kv.get(NS, collectionId);
72
+ }
73
+
74
+ /**
75
+ * Start moving a collection onto a fresh key.
76
+ *
77
+ * The new key becomes current here, before any object moves, so every upload from now on
78
+ * is already on it. Refuses to start a second rotation over an unfinished one — two
79
+ * walkers on one collection would fight over the cursor and neither would know what the
80
+ * other had done.
81
+ */
82
+ async begin(collectionId, principal) {
83
+ const running = await this.state(collectionId);
84
+ if (running && running.status === 'running') {
85
+ throw TroveError.invalid('A key rotation is already running on this collection');
86
+ }
87
+ const { fingerprint: to, previous } = await this.collections.beginRotation(collectionId, principal);
88
+ const state = {
89
+ collectionId,
90
+ to,
91
+ // Everything currently live except the new key. Usually one, but a rotation started
92
+ // over an unfinished one would leave more, and dropping any of them would strand
93
+ // whatever is still sealed with it.
94
+ from: (await this.collections.keyRingFor(collectionId))
95
+ .map((k) => k.fingerprint)
96
+ .filter((fp) => fp !== to),
97
+ cursor: null,
98
+ moved: 0,
99
+ failed: 0,
100
+ startedAt: Date.now(),
101
+ status: 'running',
102
+ previous,
103
+ };
104
+ await this.kv.set(NS, collectionId, state);
105
+ return state;
106
+ }
107
+
108
+ /**
109
+ * Do a slice of the work.
110
+ *
111
+ * Bounded by time rather than by count, because objects vary from bytes to gigabytes and
112
+ * a count is not a budget. Returns the state, so a caller can loop until `done`.
113
+ */
114
+ async step(collectionId, { budgetMs = DEFAULT_BUDGET_MS, now = () => Date.now() } = {}) {
115
+ const state = await this.state(collectionId);
116
+ if (!state || state.status !== 'running') return state;
117
+
118
+ // One walker at a time, claimed the way a scan claims its collection.
119
+ //
120
+ // Two slices running together — a cron overlapping a manual run, or two cron firings on
121
+ // a slow collection — can both pick up the same object. Each writes a new object and
122
+ // points the item at it, and then each deletes the object IT replaced: the second
123
+ // delete removes the object the item is now pointing at. With the old key still in the
124
+ // ring nothing reports an error, and the file is simply gone.
125
+ //
126
+ // A lease rather than a flag, because the holder can die mid-slice and a lock that
127
+ // outlives its holder stops the rotation permanently with nobody left to notice.
128
+ const claim = await this.kv.acquire('rotation', collectionId, Math.max(30_000, budgetMs * 3));
129
+ if (!claim) return state;
130
+ try {
131
+ return await this.#slice(collectionId, state, { budgetMs, now });
132
+ } finally {
133
+ await this.kv.release('rotation', collectionId, claim).catch(() => {});
134
+ }
135
+ }
136
+
137
+ async #slice(collectionId, state, { budgetMs, now }) {
138
+
139
+ const deadline = now() + budgetMs;
140
+ const key = await this.collections.dataKeyFor(collectionId, state.to);
141
+ if (!key) throw TroveError.invalid('The key this rotation is moving onto is gone');
142
+ const fp = await fingerprint(key);
143
+
144
+ let cursor = state.cursor;
145
+ let moved = state.moved;
146
+ let failed = state.failed;
147
+ let sawStragglers = false;
148
+
149
+ for (;;) {
150
+ const page = await this.vfs.metadata.listItems(collectionId, { cursor, limit: 50 });
151
+ const items = page.items || [];
152
+ for (const node of items) {
153
+ // Only encrypted items, and only ones not already on the current key. This is what
154
+ // makes re-running a slice free rather than destructive.
155
+ if (!node.encryption || node.encryption.fingerprint === state.to) continue;
156
+ sawStragglers = true;
157
+ try {
158
+ await this.#move(node, key, fp);
159
+ moved++;
160
+ } catch (err) {
161
+ // One unreadable object must not stop the rotation: the rest of the collection
162
+ // still needs to move, and the old key cannot be retired while anything is left
163
+ // on it — which is exactly the signal a failure should produce.
164
+ failed++;
165
+ console.error(`[trove] rotating ${node.name} failed:`, err?.message || err);
166
+ }
167
+ if (now() >= deadline) break;
168
+ }
169
+ cursor = page.nextCursor || null;
170
+ if (!cursor || now() >= deadline) break;
171
+ }
172
+
173
+ const finished = !cursor && !sawStragglers;
174
+ const next = {
175
+ ...state,
176
+ // A finished pass starts the next one from the beginning, because items added during
177
+ // it may still be behind. Two clean passes in a row is what actually ends the job.
178
+ cursor: finished ? null : cursor,
179
+ moved,
180
+ failed,
181
+ status: finished && failed === 0 ? 'done' : 'running',
182
+ finishedAt: finished && failed === 0 ? now() : undefined,
183
+ };
184
+ await this.kv.set(NS, collectionId, next);
185
+
186
+ // Retire by observation: a pass that found nothing left on the old keys is proof, in a
187
+ // way that a counter never is.
188
+ if (next.status === 'done') {
189
+ for (const old of state.from) {
190
+ // System work: it is finishing what an admin authorized when they started the
191
+ // rotation, and there is no user behind a cron firing. A failure here is logged
192
+ // rather than swallowed — the rotation itself succeeded, but a key that should
193
+ // have been retired and was not is worth knowing about.
194
+ await this.collections.retireKey(collectionId, old, null, { system: true })
195
+ .catch((err) => console.error(`[trove] retiring key ${old} failed:`, err?.message || err));
196
+ }
197
+ }
198
+ return next;
199
+ }
200
+
201
+ /**
202
+ * Read one object under whichever key it names, and write it back under the new one.
203
+ *
204
+ * Written to a NEW storage key rather than over the old one. AES-GCM cannot survive a
205
+ * nonce being reused with a key, and rewriting in place invites exactly that on a retry;
206
+ * a new object also means a failure halfway leaves the original intact and readable
207
+ * rather than a half-written file that is neither.
208
+ */
209
+ async #move(node, newKey, newFingerprint) {
210
+ const storage = await this.vfs.storageFor(node.collectionId);
211
+ const chunkSize = node.encryption.chunkSize;
212
+ // Written to a NEW storage key rather than over the old one. AES-GCM cannot survive a
213
+ // nonce being reused with a key, and rewriting in place invites exactly that on a
214
+ // retry; a new object also means a failure halfway leaves the original intact and
215
+ // readable rather than a half-written file that is neither.
216
+ const nextKey = `${node.storageKey}.rot${Date.now().toString(36)}`;
217
+
218
+ const read = await this.vfs.readStream(node.id);
219
+ const sealed = await encryptStream(newKey, read.stream, {
220
+ fingerprint: newFingerprint,
221
+ plaintextSize: read.size ?? node.size,
222
+ chunkSize,
223
+ });
224
+
225
+ // Streamed into multipart parts rather than collected.
226
+ //
227
+ // Buffering meant holding the file twice — once decrypted and once sealed — so a
228
+ // rotation was capped at whatever fits in an isolate. On Workers that is 128 MB for
229
+ // everything, which put the ceiling somewhere around a 50 MB file and made rotation
230
+ // simply unavailable for the collections most likely to want it. Peak memory is now one
231
+ // part, whatever the object weighs.
232
+ //
233
+ // A backend that cannot do multipart is a local one — filesystem, memory — where the
234
+ // whole object is already in reach and a single put is both simpler and fine.
235
+ if (storage.capabilities?.multipart) {
236
+ await this.#putStreamed(storage, nextKey, sealed, node.contentType);
237
+ } else {
238
+ await storage.put(nextKey, new Uint8Array(await new Response(sealed).arrayBuffer()), {
239
+ contentType: node.contentType,
240
+ });
241
+ }
242
+
243
+ // The item points at the new object before the old one is removed. In the window
244
+ // between, both exist and the item is readable; in the reverse order there is a window
245
+ // where it is readable through neither.
246
+ const oldKey = node.storageKey;
247
+ await this.vfs.metadata.update(node.id, {
248
+ storageKey: nextKey,
249
+ encryption: { fingerprint: toHex(newFingerprint), chunkSize },
250
+ });
251
+ await storage.delete(oldKey).catch(() => {});
252
+ }
253
+
254
+ /**
255
+ * Upload a stream as a multipart object, holding one part at a time.
256
+ *
257
+ * Parts are accumulated to a target size because S3 requires every part except the last
258
+ * to clear a 5 MiB floor — an envelope chunk is far smaller than that, so one chunk per
259
+ * part would be rejected.
260
+ *
261
+ * A failure aborts the multipart. Without that the parts already sent stay in the bucket,
262
+ * billed, with nothing left able to reclaim them.
263
+ */
264
+ async #putStreamed(storage, key, stream, contentType) {
265
+ const uploadId = await storage.createMultipart(key, { contentType });
266
+ try {
267
+ const reader = stream.getReader();
268
+ const parts = [];
269
+ let held = [];
270
+ let size = 0;
271
+ let n = 1;
272
+ const flush = async () => {
273
+ const part = new Uint8Array(size);
274
+ let at = 0;
275
+ for (const p of held) { part.set(p, at); at += p.length; }
276
+ held = [];
277
+ size = 0;
278
+ const etag = await storage.putPart(key, uploadId, n, part);
279
+ parts.push({ partNumber: n, etag: etag?.etag ?? etag });
280
+ n++;
281
+ };
282
+ for (;;) {
283
+ const { value, done } = await reader.read();
284
+ if (done) break;
285
+ held.push(value);
286
+ size += value.length;
287
+ if (size >= PART_TARGET_BYTES) await flush();
288
+ }
289
+ // The final part may be under the floor, and only the final part may be.
290
+ if (size || parts.length === 0) await flush();
291
+ await storage.completeMultipart(key, uploadId, parts);
292
+ } catch (err) {
293
+ await storage.abortMultipart(key, uploadId).catch(() => {});
294
+ throw err;
295
+ }
296
+ }
297
+
298
+ /** Abandon a rotation. The new key stays current; what has moved stays moved. */
299
+ async cancel(collectionId) {
300
+ const state = await this.state(collectionId);
301
+ if (!state) return null;
302
+ const next = { ...state, status: 'done', cancelled: true };
303
+ await this.kv.set(NS, collectionId, next);
304
+ return next;
305
+ }
306
+ }
307
+
308
+ /** Read the fingerprint an object actually carries, for checking rather than trusting. */
309
+ export async function fingerprintOf(bytes) {
310
+ return toHex(decodeHeader(bytes).fingerprint);
311
+ }
312
+
313
+ export { fromHex };
@@ -16,6 +16,7 @@ export { MemoryStorage } from './storage/memory.js';
16
16
  export { StorageDriverRegistry } from './storage/registry.js';
17
17
  export { portableDrivers } from './storage/drivers.js';
18
18
  export { diagnoseStorage, corsPolicy, STORAGE_ISSUE_CODES } from './storage/diagnose.js';
19
+ export { estimateRotationCost, recognizeProvider, RATES_AS_OF } from './storage/cost.js';
19
20
  export { S3Storage } from './storage/s3.js';
20
21
  export { PrefixedStorage } from './storage/prefixed.js';
21
22
 
@@ -36,7 +37,22 @@ export { SqliteVectorStore, SqliteKeywordStore, SEARCH_DB_KEY } from './search/s
36
37
 
37
38
  export { IndexerRegistry, textIndexer, chunkText } from './indexers/registry.js';
38
39
  export { PluginService, PackageStore, StoragePackageStore, PluginInstallStore, SqlitePluginInstallStore, MemoryPluginInstallStore, parsePluginPackage, capabilityList, ALL_CAPABILITIES, IndexerRuntime, InProcessIndexerRuntime, PluginIndexers, matchFromSelector } from './plugins/index.js';
39
- export { UploadManager, DEFAULT_PART_SIZE } from './uploads.js';
40
+ export { UploadManager, KvSessionStore, DEFAULT_PART_SIZE } from './uploads.js';
41
+ // Encryption at rest: the bucket holds ciphertext, the drive holds the key. Protects
42
+ // against the STORAGE host (a leaked bucket credential, a storage vendor who is not the
43
+ // compute vendor) and deliberately not against the server, which must read plaintext to
44
+ // index it. See encryption/keys.js for why nothing here is a plain hash of a passphrase.
45
+ export {
46
+ encrypt, encryptStream, decrypt, decryptRange, decryptStream, encodeHeader, decodeHeader, isEnvelope,
47
+ cipherSize, plaintextSizeOf, cipherRangeFor,
48
+ HEADER_BYTES, TAG_BYTES, DEFAULT_CHUNK_SIZE,
49
+ } from './encryption/envelope.js';
50
+ export {
51
+ generateDataKey, newCollectionKey, fingerprint, fingerprintHex, toHex, fromHex,
52
+ } from './encryption/keys.js';
53
+ export { normalizeEncryption, shouldEncrypt, describeEncryption } from './encryption/policy.js';
54
+ export { RotationService } from './encryption/rotation.js';
55
+ export { describeExposure } from './encryption/exposure.js';
40
56
  export { Vfs, CONTENT_TYPES } from './vfs.js';
41
57
  export { IndexingCoordinator } from './indexing.js';
42
58
  // Work in flight (ephemeral) and standing problems (durable) — see the header of each.