@3sln/trove 0.0.7 → 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/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/uploads.js +197 -15
- package/packages/core/src/vfs.js +148 -9
- package/packages/server/src/engine/providers/core.js +9 -2
- 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
|
@@ -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.
|