sealkeep 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +201 -0
- package/CHANGELOG.md +218 -0
- package/CONTROL_PLANE.md +86 -0
- package/LICENSE +34 -0
- package/README.md +249 -0
- package/THIRD_PARTY.md +22 -0
- package/THREAT_MODEL.md +107 -0
- package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
- package/dist/packages/vaultline-crypto/src/aead.js +24 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
- package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
- package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
- package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
- package/dist/packages/vaultline-crypto/src/format.js +43 -0
- package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
- package/dist/packages/vaultline-crypto/src/index.js +5 -0
- package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
- package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
- package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
- package/dist/packages/vaultline-crypto/src/stream.js +477 -0
- package/dist/site/index.html +1542 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +22 -0
- package/dist/src/activity.js +52 -0
- package/dist/src/adapters.d.ts +212 -0
- package/dist/src/adapters.js +533 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +41 -0
- package/dist/src/autopilot.d.ts +77 -0
- package/dist/src/autopilot.js +148 -0
- package/dist/src/bip39-wordlist.d.ts +15 -0
- package/dist/src/bip39-wordlist.js +272 -0
- package/dist/src/branding.d.ts +31 -0
- package/dist/src/branding.js +31 -0
- package/dist/src/chunk-store.d.ts +142 -0
- package/dist/src/chunk-store.js +502 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +2035 -0
- package/dist/src/cloud.d.ts +434 -0
- package/dist/src/cloud.js +851 -0
- package/dist/src/control-plane/auth.d.ts +62 -0
- package/dist/src/control-plane/auth.js +123 -0
- package/dist/src/control-plane/server.d.ts +31 -0
- package/dist/src/control-plane/server.js +263 -0
- package/dist/src/control-plane/store.d.ts +101 -0
- package/dist/src/control-plane/store.js +82 -0
- package/dist/src/control-plane-cli.d.ts +2 -0
- package/dist/src/control-plane-cli.js +37 -0
- package/dist/src/control-plane-server.d.ts +10 -0
- package/dist/src/control-plane-server.js +11 -0
- package/dist/src/control-plane.d.ts +78 -0
- package/dist/src/control-plane.js +61 -0
- package/dist/src/crypto.d.ts +56 -0
- package/dist/src/crypto.js +132 -0
- package/dist/src/daemon.d.ts +52 -0
- package/dist/src/daemon.js +142 -0
- package/dist/src/dashboard-cli.d.ts +2 -0
- package/dist/src/dashboard-cli.js +20 -0
- package/dist/src/disk.d.ts +110 -0
- package/dist/src/disk.js +169 -0
- package/dist/src/doctor.d.ts +11 -0
- package/dist/src/doctor.js +198 -0
- package/dist/src/enroll.d.ts +27 -0
- package/dist/src/enroll.js +136 -0
- package/dist/src/errors.d.ts +26 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/heartbeat.d.ts +89 -0
- package/dist/src/heartbeat.js +120 -0
- package/dist/src/index-sync.d.ts +53 -0
- package/dist/src/index-sync.js +147 -0
- package/dist/src/leakscan.d.ts +48 -0
- package/dist/src/leakscan.js +222 -0
- package/dist/src/local-api.d.ts +132 -0
- package/dist/src/local-api.js +1757 -0
- package/dist/src/managed-chunks.d.ts +55 -0
- package/dist/src/managed-chunks.js +108 -0
- package/dist/src/mcp-install.d.ts +52 -0
- package/dist/src/mcp-install.js +140 -0
- package/dist/src/mcp.d.ts +1 -0
- package/dist/src/mcp.js +59 -0
- package/dist/src/migrate.d.ts +35 -0
- package/dist/src/migrate.js +88 -0
- package/dist/src/mnemonic.d.ts +60 -0
- package/dist/src/mnemonic.js +134 -0
- package/dist/src/net.d.ts +2 -0
- package/dist/src/net.js +16 -0
- package/dist/src/notify.d.ts +46 -0
- package/dist/src/notify.js +84 -0
- package/dist/src/offload.d.ts +117 -0
- package/dist/src/offload.js +331 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +44 -0
- package/dist/src/packages.d.ts +126 -0
- package/dist/src/packages.js +114 -0
- package/dist/src/passkey.d.ts +26 -0
- package/dist/src/passkey.js +54 -0
- package/dist/src/password-lock.d.ts +19 -0
- package/dist/src/password-lock.js +156 -0
- package/dist/src/paths.d.ts +9 -0
- package/dist/src/paths.js +24 -0
- package/dist/src/providers/gcs.d.ts +133 -0
- package/dist/src/providers/gcs.js +235 -0
- package/dist/src/providers/gdrive.d.ts +156 -0
- package/dist/src/providers/gdrive.js +335 -0
- package/dist/src/providers/index.d.ts +45 -0
- package/dist/src/providers/index.js +74 -0
- package/dist/src/providers/s3.d.ts +174 -0
- package/dist/src/providers/s3.js +345 -0
- package/dist/src/providers/sigv4.d.ts +78 -0
- package/dist/src/providers/sigv4.js +112 -0
- package/dist/src/queue.d.ts +185 -0
- package/dist/src/queue.js +286 -0
- package/dist/src/recovery.d.ts +40 -0
- package/dist/src/recovery.js +132 -0
- package/dist/src/rehydrate.d.ts +43 -0
- package/dist/src/rehydrate.js +66 -0
- package/dist/src/restore.d.ts +34 -0
- package/dist/src/restore.js +80 -0
- package/dist/src/retention.d.ts +251 -0
- package/dist/src/retention.js +446 -0
- package/dist/src/rotate.d.ts +47 -0
- package/dist/src/rotate.js +95 -0
- package/dist/src/search.d.ts +147 -0
- package/dist/src/search.js +677 -0
- package/dist/src/secrets.d.ts +86 -0
- package/dist/src/secrets.js +220 -0
- package/dist/src/service.d.ts +73 -0
- package/dist/src/service.js +197 -0
- package/dist/src/share.d.ts +34 -0
- package/dist/src/share.js +68 -0
- package/dist/src/spool.d.ts +97 -0
- package/dist/src/spool.js +213 -0
- package/dist/src/start-tui.d.ts +17 -0
- package/dist/src/start-tui.js +113 -0
- package/dist/src/start.d.ts +75 -0
- package/dist/src/start.js +101 -0
- package/dist/src/storage-setup.d.ts +49 -0
- package/dist/src/storage-setup.js +222 -0
- package/dist/src/storage-targets.d.ts +40 -0
- package/dist/src/storage-targets.js +147 -0
- package/dist/src/stream-to-cloud.d.ts +76 -0
- package/dist/src/stream-to-cloud.js +820 -0
- package/dist/src/sync-rules.d.ts +85 -0
- package/dist/src/sync-rules.js +125 -0
- package/dist/src/trash.d.ts +15 -0
- package/dist/src/trash.js +63 -0
- package/dist/src/tui.d.ts +18 -0
- package/dist/src/tui.js +179 -0
- package/dist/src/types.d.ts +191 -0
- package/dist/src/types.js +3 -0
- package/dist/src/ui-server.d.ts +187 -0
- package/dist/src/ui-server.js +293 -0
- package/dist/src/ui.d.ts +41 -0
- package/dist/src/ui.js +102 -0
- package/dist/src/update.d.ts +30 -0
- package/dist/src/update.js +56 -0
- package/dist/src/upload.d.ts +46 -0
- package/dist/src/upload.js +80 -0
- package/dist/src/vault.d.ts +208 -0
- package/dist/src/vault.js +812 -0
- package/dist/src/watcher.d.ts +34 -0
- package/dist/src/watcher.js +121 -0
- package/dist/src/worker.d.ts +52 -0
- package/dist/src/worker.js +190 -0
- package/package.json +65 -0
- package/web/app.js +1372 -0
- package/web/index.html +476 -0
- package/web/rail.js +308 -0
- package/web/retention.html +17 -0
- package/web/rules-view.js +249 -0
- package/web/sessions-view.js +448 -0
- package/web/sessions.html +17 -0
- package/web/setup-api.js +181 -0
- package/web/setup-logic.js +394 -0
- package/web/setup.html +419 -0
- package/web/setup.js +697 -0
- package/web/style.css +990 -0
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { open, stat } from "node:fs/promises";
|
|
3
|
+
import { gzipSync } from "node:zlib";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { fail, isVaultlineError } from "./errors.js";
|
|
6
|
+
import { recordAudit } from "./audit.js";
|
|
7
|
+
import { canonicalPhrase } from "./mnemonic.js";
|
|
8
|
+
import { ADAPTER_VERSION, configuredRecipients, readConfig, writeRecord } from "./vault.js";
|
|
9
|
+
import { uploadClientFromStore } from "./providers/index.js";
|
|
10
|
+
import { compositeChecksum } from "./providers/s3.js";
|
|
11
|
+
import { acquireSpoolLock, createSpool, findSpoolForSource, shredSpool, unwrapSpoolKey, updateSpool } from "./spool.js";
|
|
12
|
+
import { DEFAULT_CHUNK_BYTES, ENVELOPE_VERSION, KEY_BYTES, StreamingSha256, TAG_BYTES, chunkAad, chunkNonce, hashFileRange, sealChunksToSink, wrapAll, zeroize } from "../packages/vaultline-crypto/src/index.js";
|
|
13
|
+
// The package's index deliberately exports formats and flows, not raw AEAD
|
|
14
|
+
// primitives. Resuming needs exactly one primitive operation the flows do not
|
|
15
|
+
// expose: re-producing a single chunk's ciphertext to prove the source still
|
|
16
|
+
// seals to the bytes the provider already holds. Reaching into the package for
|
|
17
|
+
// that beats re-implementing the cipher setup here and drifting from it.
|
|
18
|
+
import { aeadCipher, aeadDecipher } from "../packages/vaultline-crypto/src/aead.js";
|
|
19
|
+
const STREAMABLE = new Set(["s3", "r2", "b2", "gcs"]);
|
|
20
|
+
const SEAL_SUITE = "chacha20-poly1305";
|
|
21
|
+
const sha256hex = (input) => createHash("sha256").update(input).digest("hex");
|
|
22
|
+
/** SHA-256 of zero bytes — what `consumedPrefixSha256` means before any chunk is confirmed. Derived, not pasted, so it cannot rot. */
|
|
23
|
+
const EMPTY_PREFIX_SHA256 = createHash("sha256").digest("hex");
|
|
24
|
+
/**
|
|
25
|
+
* A tiny handoff channel: the seal loop pushes stored chunks, the provider
|
|
26
|
+
* stream consumes them, and neither side ever holds more than `capacity`
|
|
27
|
+
* chunks. If the consumer dies, `push` throws its error into the seal loop so
|
|
28
|
+
* the file handle closes through the normal error path instead of hanging.
|
|
29
|
+
*/
|
|
30
|
+
class ChunkChannel {
|
|
31
|
+
capacity;
|
|
32
|
+
buffered = [];
|
|
33
|
+
done = false;
|
|
34
|
+
error = null;
|
|
35
|
+
wakeConsumer = null;
|
|
36
|
+
wakeProducer = null;
|
|
37
|
+
constructor(capacity = 4) {
|
|
38
|
+
this.capacity = capacity;
|
|
39
|
+
}
|
|
40
|
+
async push(chunk) {
|
|
41
|
+
if (this.error)
|
|
42
|
+
throw this.error;
|
|
43
|
+
while (this.buffered.length >= this.capacity) {
|
|
44
|
+
await new Promise((wake) => { this.wakeProducer = wake; });
|
|
45
|
+
if (this.error)
|
|
46
|
+
throw this.error;
|
|
47
|
+
}
|
|
48
|
+
this.buffered.push(chunk);
|
|
49
|
+
this.wakeConsumer?.();
|
|
50
|
+
this.wakeConsumer = null;
|
|
51
|
+
}
|
|
52
|
+
close() {
|
|
53
|
+
this.done = true;
|
|
54
|
+
this.wakeConsumer?.();
|
|
55
|
+
this.wakeConsumer = null;
|
|
56
|
+
}
|
|
57
|
+
failWith(error) {
|
|
58
|
+
this.error = error;
|
|
59
|
+
this.done = true;
|
|
60
|
+
this.wakeConsumer?.();
|
|
61
|
+
this.wakeConsumer = null;
|
|
62
|
+
this.wakeProducer?.();
|
|
63
|
+
this.wakeProducer = null;
|
|
64
|
+
}
|
|
65
|
+
async *[Symbol.asyncIterator]() {
|
|
66
|
+
for (;;) {
|
|
67
|
+
if (this.buffered.length > 0) {
|
|
68
|
+
const next = this.buffered.shift();
|
|
69
|
+
this.wakeProducer?.();
|
|
70
|
+
this.wakeProducer = null;
|
|
71
|
+
yield next;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (this.error)
|
|
75
|
+
throw this.error;
|
|
76
|
+
if (this.done)
|
|
77
|
+
return;
|
|
78
|
+
await new Promise((wake) => { this.wakeConsumer = wake; });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function interpretProviderState(state) {
|
|
83
|
+
if (typeof state !== "object" || state === null)
|
|
84
|
+
return null;
|
|
85
|
+
const record = state;
|
|
86
|
+
if (typeof record.uploadId === "string" && Array.isArray(record.parts)) {
|
|
87
|
+
const s3 = state;
|
|
88
|
+
// Only a prefix of FULL parts is a resumable seed: the S3 client refuses a
|
|
89
|
+
// short non-final part because the resume offset must land on a part
|
|
90
|
+
// boundary. The final short part of a finished stream may appear in the
|
|
91
|
+
// last onState — trim it (and count only what remains as confirmed for
|
|
92
|
+
// resume purposes) so what lands in the journal is always seedable. The
|
|
93
|
+
// trimmed tail part is not lost: a resume re-seals its chunks and S3
|
|
94
|
+
// overwrites the same part number with the identical bytes.
|
|
95
|
+
const partBytes = s3.parts[0]?.bytes ?? 0;
|
|
96
|
+
let full = s3.parts.length;
|
|
97
|
+
while (full > 0 && s3.parts[full - 1].bytes !== partBytes)
|
|
98
|
+
full -= 1;
|
|
99
|
+
const usable = s3.parts.slice(0, full);
|
|
100
|
+
return {
|
|
101
|
+
confirmedBytes: usable.reduce((sum, part) => sum + part.bytes, 0),
|
|
102
|
+
journalable: { uploadId: s3.uploadId, parts: usable },
|
|
103
|
+
s3UploadId: s3.uploadId
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (typeof record.session === "string" && typeof record.confirmedBytes === "number") {
|
|
107
|
+
const gcs = state;
|
|
108
|
+
return { confirmedBytes: gcs.confirmedBytes, journalable: gcs };
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* True for a failure that means the journaled state can never be resumed —
|
|
114
|
+
* as opposed to a transient failure, after which the journal is exactly what
|
|
115
|
+
* the next attempt needs. The distinction decides whether the journal is
|
|
116
|
+
* shredded (and the seal restarted fresh) or kept for another try:
|
|
117
|
+
* - "Cannot resume …": the provider layer's own entry refusals, and this
|
|
118
|
+
* module's pre-flight refusals, all thrown before a data byte moved.
|
|
119
|
+
* - "resumable session …": a GCS session that is finalized, expired, or
|
|
120
|
+
* answering the status query with an error — dead either way.
|
|
121
|
+
* - "NoSuchUpload": the S3 multipart the journal references was aborted or
|
|
122
|
+
* completed; its seeded parts do not exist anymore.
|
|
123
|
+
* Anything else (network drop, full disk, a provider 5xx) keeps the journal.
|
|
124
|
+
*/
|
|
125
|
+
function resumeRefused(error) {
|
|
126
|
+
if (!isVaultlineError(error))
|
|
127
|
+
return false;
|
|
128
|
+
return /cannot resume/i.test(error.message) || /resumable session/.test(error.message) || /NoSuchUpload/.test(error.message);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Kills whatever half-object the provider still holds for a journal that will
|
|
132
|
+
* never finish. S3-family multiparts must be aborted explicitly or their
|
|
133
|
+
* invisible parts bill forever; GCS resumable sessions need no call — an
|
|
134
|
+
* unfinished session expires on its own within a week and holds no visible
|
|
135
|
+
* object until finalized. Best-effort on purpose: abandoning must not fail
|
|
136
|
+
* because the provider is unreachable, and a missed abort costs money, not
|
|
137
|
+
* correctness.
|
|
138
|
+
*/
|
|
139
|
+
async function abortProviderState(client, objectKey, progress) {
|
|
140
|
+
if (progress?.s3UploadId && client.abortMultipart) {
|
|
141
|
+
await client.abortMultipart(objectKey, progress.s3UploadId).catch(() => undefined);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Aborts the remote state, writes the one honest audit line, and shreds the journal. */
|
|
145
|
+
async function abandonJournal(dataDir, client, journal, reason) {
|
|
146
|
+
await abortProviderState(client, journal.objectKey, interpretProviderState(journal.providerState));
|
|
147
|
+
await recordAudit(dataDir, "upload.verify", "denied", { archiveId: journal.archiveId, objectKey: journal.objectKey, resumeAbandoned: reason });
|
|
148
|
+
await shredSpool(dataDir, journal.archiveId);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* After an archive of this source lands, any journal still naming the source
|
|
152
|
+
* describes an upload nobody will ever finish — an older crashed attempt, or
|
|
153
|
+
* one superseded by --fresh. Left alone it would win `findSpoolForSource` on
|
|
154
|
+
* some later run and resurrect a dead upload as a duplicate archive.
|
|
155
|
+
*/
|
|
156
|
+
async function sweepStaleJournals(dataDir, client, sourcePath) {
|
|
157
|
+
for (;;) {
|
|
158
|
+
const stale = await findSpoolForSource(dataDir, sourcePath);
|
|
159
|
+
if (!stale)
|
|
160
|
+
return;
|
|
161
|
+
await abandonJournal(dataDir, client, stale, "superseded: a completed archive of this source landed");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export async function sealArchiveToCloud(dataDir, sourcePath, rawPhrase, agent, options = {}) {
|
|
165
|
+
const config = await readConfig(dataDir);
|
|
166
|
+
if (config.storageMode === "plain") {
|
|
167
|
+
fail("invalid_argument", "This vault stores archives as plain readable files; streaming them to a bucket would upload your transcripts unencrypted. Use a sealed vault for cloud archiving.");
|
|
168
|
+
}
|
|
169
|
+
const remoteStorage = config.remoteStorage ?? fail("storage_not_configured", "No remote target configured. Run: sealkeep storage configure ...");
|
|
170
|
+
if (!STREAMABLE.has(remoteStorage.provider)) {
|
|
171
|
+
fail("invalid_argument", remoteStorage.provider === "vaultline"
|
|
172
|
+
? "Managed storage cannot take a streamed seal yet: the control plane signs one upload of a known size, and a stream's size is not known until it ends. Seal locally and let the upload pass offload it — same end state, one extra local write."
|
|
173
|
+
: `Streaming seals are not supported for ${remoteStorage.provider} yet; seal locally and upload.`);
|
|
174
|
+
}
|
|
175
|
+
const absolute = resolve(sourcePath);
|
|
176
|
+
const source = await stat(absolute).catch(() => null);
|
|
177
|
+
if (!source || !source.isFile())
|
|
178
|
+
fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
|
|
179
|
+
const phrase = canonicalPhrase(rawPhrase);
|
|
180
|
+
const rawClient = options.client ?? await uploadClientFromStore(dataDir, config.vaultId, remoteStorage);
|
|
181
|
+
if (!("uploadStream" in rawClient) || typeof rawClient.uploadStream !== "function") {
|
|
182
|
+
fail("invalid_argument", `The ${remoteStorage.provider} client cannot stream uploads`);
|
|
183
|
+
}
|
|
184
|
+
const client = rawClient;
|
|
185
|
+
const shared = { dataDir, client, config, remoteStorage, absolute, totalBytes: source.size, phrase, agent, now: options.now };
|
|
186
|
+
// Journal triage. Each pass either resumes the newest journal for this
|
|
187
|
+
// source or abandons it with the reason and looks again, so a stack of
|
|
188
|
+
// crashed attempts drains to the one worth continuing — or to none.
|
|
189
|
+
for (;;) {
|
|
190
|
+
const journal = await findSpoolForSource(dataDir, absolute);
|
|
191
|
+
if (!journal)
|
|
192
|
+
break;
|
|
193
|
+
if (options.resume === false) {
|
|
194
|
+
await abandonJournal(dataDir, client, journal, "a fresh start was requested (--fresh)");
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (journal.provider !== remoteStorage.provider || journal.bucket !== remoteStorage.bucket
|
|
198
|
+
|| journal.source.totalBytes !== source.size
|
|
199
|
+
|| !Number.isInteger(journal.source.chunkBytes) || journal.source.chunkBytes <= 0
|
|
200
|
+
|| !Number.isInteger(journal.nextChunkIndex) || journal.nextChunkIndex < 0) {
|
|
201
|
+
await abandonJournal(dataDir, client, journal, "the source file or the storage target is no longer the one the journal describes");
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
// THE SAFETY GATE, before anything else: the same (archiveKey,
|
|
205
|
+
// noncePrefix, chunkIndex) triple must never encrypt two different
|
|
206
|
+
// plaintexts, and every resume decision must be provably on the safe side
|
|
207
|
+
// of that line. Chunks below `nextChunkIndex` were already encrypted and
|
|
208
|
+
// handed over under this journal's key, so this seal may only continue if
|
|
209
|
+
// the bytes they covered are still byte-for-byte the bytes on disk. Hash
|
|
210
|
+
// the consumed prefix and compare; on any mismatch the journal — key,
|
|
211
|
+
// nonce prefix, provider session, all of it — is dead, and the archive
|
|
212
|
+
// starts over with a fresh identity.
|
|
213
|
+
const consumed = Math.min(journal.nextChunkIndex * journal.source.chunkBytes, source.size);
|
|
214
|
+
const gate = await hashFileRange(absolute, 0, consumed);
|
|
215
|
+
if (gate.sha256 !== journal.consumedPrefixSha256) {
|
|
216
|
+
await abandonJournal(dataDir, client, journal, "the source bytes under the already-uploaded chunks changed, so the sealed prefix no longer describes this file");
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// One process per journal, held across the whole resume including its
|
|
220
|
+
// abandon path — a second process asking meanwhile is told to wait.
|
|
221
|
+
const lock = await acquireSpoolLock(dataDir, journal.archiveId, { now: options.now });
|
|
222
|
+
try {
|
|
223
|
+
return await resumeSeal(shared, journal);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (!resumeRefused(error))
|
|
227
|
+
throw error;
|
|
228
|
+
await abandonJournal(dataDir, client, journal, error instanceof Error ? error.message : "the journaled provider state was not resumable");
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
await lock.release();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return freshSeal(shared);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The seal↔upload bridge both the fresh and the resumed path run: seal chunks
|
|
239
|
+
* into the channel, stream the channel into the provider, and journal every
|
|
240
|
+
* durability point the provider reports.
|
|
241
|
+
*/
|
|
242
|
+
async function streamSealCore(ctx, args) {
|
|
243
|
+
const channel = new ChunkChannel();
|
|
244
|
+
let heldAtMostBytes = 0;
|
|
245
|
+
let cumStored = args.prefixStoredBytes;
|
|
246
|
+
const producedHeaders = [];
|
|
247
|
+
// One snapshot per produced chunk: the exact journal fields that describe
|
|
248
|
+
// "everything up to and including this chunk". `onState` picks the highest
|
|
249
|
+
// snapshot the provider's confirmed bytes fully cover — never a partial one,
|
|
250
|
+
// so `nextChunkIndex` only ever names chunks that are durably placed.
|
|
251
|
+
const snapshots = [];
|
|
252
|
+
const sink = {
|
|
253
|
+
write: async (stored) => {
|
|
254
|
+
const index = args.firstChunkIndex + producedHeaders.length;
|
|
255
|
+
const rawStart = index * args.chunkBytes;
|
|
256
|
+
// Derived from first principles rather than read back out of
|
|
257
|
+
// sealChunksToSink, which only reveals its headers when the whole seal
|
|
258
|
+
// resolves — too late for a journal that must describe chunks as they
|
|
259
|
+
// leave. Every field is forced by the format: plaintextBytes is the
|
|
260
|
+
// stored length minus the tag, the nonce is the prefix plus the index,
|
|
261
|
+
// final is a position.
|
|
262
|
+
const header = {
|
|
263
|
+
index,
|
|
264
|
+
plaintextBytes: stored.length - TAG_BYTES,
|
|
265
|
+
rawBytes: Math.max(0, Math.min(args.chunkBytes, ctx.totalBytes - rawStart)),
|
|
266
|
+
storedBytes: stored.length,
|
|
267
|
+
sha256: sha256hex(stored),
|
|
268
|
+
nonce: chunkNonce(args.noncePrefix, index).toString("base64"),
|
|
269
|
+
final: index === args.totalChunks - 1
|
|
270
|
+
};
|
|
271
|
+
const first = index === args.firstChunkIndex;
|
|
272
|
+
if (first && args.skipStoredBytes > 0 && header.sha256 !== args.spliceGuardSha256) {
|
|
273
|
+
// The provider holds a prefix of this very chunk from the interrupted
|
|
274
|
+
// run. If re-sealing it does not reproduce those bytes exactly,
|
|
275
|
+
// splicing would corrupt the object AND re-encrypt a different
|
|
276
|
+
// plaintext under an already-exposed nonce — so the refusal happens
|
|
277
|
+
// here, before a single byte of it reaches the channel.
|
|
278
|
+
fail("invalid_argument", `Cannot resume the interrupted upload of ${args.objectKey}: re-sealing chunk ${index} did not reproduce the bytes the provider already holds, so continuing would corrupt the stored object. The archive starts over from byte zero with a fresh key.`, { archiveId: args.archiveId });
|
|
279
|
+
}
|
|
280
|
+
if (args.bodyHasher) {
|
|
281
|
+
// The whole-body manifest hash covers each chunk's compressed slice,
|
|
282
|
+
// which only exists inside the seal loop. Opening the chunk we sealed
|
|
283
|
+
// a microsecond ago recovers it without a second gzip pass.
|
|
284
|
+
args.bodyHasher.update(openStoredChunk(args, header, stored));
|
|
285
|
+
}
|
|
286
|
+
heldAtMostBytes = Math.max(heldAtMostBytes, stored.length);
|
|
287
|
+
cumStored += stored.length;
|
|
288
|
+
producedHeaders.push(header);
|
|
289
|
+
snapshots.push({
|
|
290
|
+
cumStored,
|
|
291
|
+
// The final chunk's live state would carry the source's last
|
|
292
|
+
// totalBytes % 64 bytes RAW in its unprocessed tail — a plaintext
|
|
293
|
+
// slice sitting in a journal on disk. After the final chunk no state
|
|
294
|
+
// is ever resumed INTO (nothing further is absorbed), so the finished
|
|
295
|
+
// digests are journaled instead; every non-final boundary is 64-aligned
|
|
296
|
+
// (chunkBytes is a multiple of 64) and its tail is provably empty.
|
|
297
|
+
plaintext: header.final ? doneDigestState(args.hashers.plaintext) : args.hashers.plaintext.exportState(),
|
|
298
|
+
stored: header.final ? doneDigestState(args.hashers.stored) : args.hashers.stored.exportState(),
|
|
299
|
+
// The plaintext hasher has absorbed exactly the source bytes up to
|
|
300
|
+
// this chunk boundary, so its (non-destructive) digest IS the consumed
|
|
301
|
+
// prefix hash the safety gate will verify on the next resume.
|
|
302
|
+
consumedPrefix: args.hashers.plaintext.digestHex()
|
|
303
|
+
});
|
|
304
|
+
// THE ORDER THAT CLOSES THE EMISSION RACE: the journal learns this
|
|
305
|
+
// chunk's header BEFORE the chunk can reach the provider. A crash
|
|
306
|
+
// between a provider PUT and a journal write can therefore only ever
|
|
307
|
+
// leave the provider holding chunks the journal ALREADY describes —
|
|
308
|
+
// which is exactly what lets a resume verify, byte for byte, everything
|
|
309
|
+
// the provider might hold before re-encrypting anything.
|
|
310
|
+
await updateSpool(ctx.dataDir, args.archiveId, { chunkHeaders: [...args.prefixHeaders, ...producedHeaders] });
|
|
311
|
+
await channel.push(first && args.skipStoredBytes > 0 ? stored.subarray(args.skipStoredBytes) : stored);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
const onState = async (state) => {
|
|
315
|
+
const progress = interpretProviderState(state);
|
|
316
|
+
if (!progress)
|
|
317
|
+
return;
|
|
318
|
+
let advanced = 0;
|
|
319
|
+
while (advanced < snapshots.length && snapshots[advanced].cumStored <= progress.confirmedBytes)
|
|
320
|
+
advanced += 1;
|
|
321
|
+
const at = advanced > 0 ? snapshots[advanced - 1] : null;
|
|
322
|
+
await updateSpool(ctx.dataDir, args.archiveId, {
|
|
323
|
+
nextChunkIndex: args.firstChunkIndex + advanced,
|
|
324
|
+
consumedPrefixSha256: at ? at.consumedPrefix : args.consumedPrefixFallback,
|
|
325
|
+
hashState: at ? { plaintext: at.plaintext, stored: at.stored } : args.hashStateFallback,
|
|
326
|
+
providerState: progress.journalable
|
|
327
|
+
});
|
|
328
|
+
};
|
|
329
|
+
const sealPromise = sealChunksToSink({ path: ctx.absolute, start: args.firstChunkIndex * args.chunkBytes, end: ctx.totalBytes }, sink, {
|
|
330
|
+
recipients: args.recipients, archiveId: args.archiveId, adapter: { agent: ctx.agent, version: ADAPTER_VERSION },
|
|
331
|
+
suite: args.suite, chunkBytes: args.chunkBytes, archiveKey: args.archiveKey, noncePrefix: args.noncePrefix,
|
|
332
|
+
firstChunkIndex: args.firstChunkIndex, hashers: args.hashers,
|
|
333
|
+
...(args.createdAt ? { createdAt: args.createdAt } : {})
|
|
334
|
+
}).then((sealed) => { channel.close(); return sealed; }, (error) => { channel.failWith(error); throw error; });
|
|
335
|
+
const uploadPromise = ctx.client.uploadStream(args.objectKey, channel, { ...(args.resume === null ? {} : { resume: args.resume }), onState })
|
|
336
|
+
.catch((error) => { channel.failWith(error); throw error; });
|
|
337
|
+
const [sealed, uploaded] = await Promise.all([sealPromise, uploadPromise]);
|
|
338
|
+
return { sealed, uploaded, heldAtMostBytes };
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* A finished hasher's journal form: digests only, no internal state, no tail.
|
|
342
|
+
* Recognised (and refused as a resume source) by its `alg` marker.
|
|
343
|
+
*/
|
|
344
|
+
function doneDigestState(hasher) {
|
|
345
|
+
return JSON.stringify({ alg: "sha256-done", hex: hasher.digestHex(), b64: hasher.digestBase64() });
|
|
346
|
+
}
|
|
347
|
+
/** Opens one just-sealed chunk back to its compressed slice. Never fails for bytes this process produced; a failure is reported as the internal wrongness it is. */
|
|
348
|
+
function openStoredChunk(args, header, stored) {
|
|
349
|
+
const decipher = aeadDecipher(args.suite, args.archiveKey, chunkNonce(args.noncePrefix, header.index));
|
|
350
|
+
decipher.setAAD(chunkAad({ archiveId: args.archiveId, suite: args.suite, index: header.index, totalChunks: args.totalChunks, plaintextBytes: header.plaintextBytes, final: header.final }));
|
|
351
|
+
decipher.setAuthTag(stored.subarray(stored.length - TAG_BYTES));
|
|
352
|
+
try {
|
|
353
|
+
return Buffer.concat([decipher.update(stored.subarray(0, stored.length - TAG_BYTES)), decipher.final()]);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return fail("internal", `Chunk ${header.index} failed to open immediately after it was sealed; refusing to continue with a cipher that disagrees with itself.`, { archiveId: args.archiveId });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* The same verification `uploadArchive` performs, in the provider's own
|
|
361
|
+
* checksum language — a streamed object earns "durable" the same way a copied
|
|
362
|
+
* one does, or the record is not written at all.
|
|
363
|
+
*/
|
|
364
|
+
async function verifyAndRecord(ctx, flight) {
|
|
365
|
+
const { archiveId, objectKey, uploaded } = flight;
|
|
366
|
+
const head = await ctx.client.head({ objectKey });
|
|
367
|
+
const problems = [];
|
|
368
|
+
if (!head.exists)
|
|
369
|
+
problems.push("the provider does not report the object as present");
|
|
370
|
+
if (head.bytes !== uploaded.bytes)
|
|
371
|
+
problems.push(`the provider stored ${head.bytes} bytes; ${uploaded.bytes} were sent`);
|
|
372
|
+
if (uploaded.remoteChecksum === null) {
|
|
373
|
+
// A resumed GCS upload cannot roll the provider-language MD5 over bytes an
|
|
374
|
+
// earlier process sent, so this run has no provider checksum to compare
|
|
375
|
+
// (src/providers/gcs.ts returns null exactly then). The whole-object proof
|
|
376
|
+
// is the byte count above plus our own resumable SHA-256 over every stored
|
|
377
|
+
// byte — the same digest the record carries as `remote.checksum` — whose
|
|
378
|
+
// prefix state was journaled only after the provider confirmed those bytes.
|
|
379
|
+
}
|
|
380
|
+
else if (head.checksum === undefined)
|
|
381
|
+
problems.push("the provider did not report a stored checksum, so the bytes it holds cannot be proven");
|
|
382
|
+
else if (head.checksum !== uploaded.remoteChecksum)
|
|
383
|
+
problems.push(`the provider's stored checksum ${head.checksum} does not match the ${uploaded.remoteChecksum} we streamed`);
|
|
384
|
+
if (flight.wholeStoredBytes !== uploaded.bytes)
|
|
385
|
+
problems.push(`the seal produced ${flight.wholeStoredBytes} bytes but ${uploaded.bytes} reached the provider`);
|
|
386
|
+
if (problems.length > 0) {
|
|
387
|
+
await recordAudit(ctx.dataDir, "upload.verify", "denied", { archiveId, objectKey, problems: problems.length });
|
|
388
|
+
// No record, and no lingering halves either: the unverifiable remote state
|
|
389
|
+
// is torn down and the journal shredded, so the next attempt starts clean
|
|
390
|
+
// instead of resuming into an object verification already refused.
|
|
391
|
+
await abortProviderState(ctx.client, objectKey, flight.progress);
|
|
392
|
+
await shredSpool(ctx.dataDir, archiveId);
|
|
393
|
+
fail("ciphertext_integrity_failed", `Refusing to record the streamed archive as durable: ${problems.join("; ")}. Nothing was written locally; re-run the archive.`, { archiveId, objectKey });
|
|
394
|
+
}
|
|
395
|
+
const now = new Date(ctx.now ?? Date.now()).toISOString();
|
|
396
|
+
// `remote.checksum` stays OUR whole-object stored SHA-256 (base64), resumed
|
|
397
|
+
// or not — the resumable hasher covered every stored byte across processes.
|
|
398
|
+
const remote = { provider: ctx.remoteStorage.provider, bucket: ctx.remoteStorage.bucket, objectKey, bytes: flight.wholeStoredBytes, checksum: flight.hashers.stored.digestBase64(), verifiedAt: now };
|
|
399
|
+
const record = {
|
|
400
|
+
version: 2, id: archiveId, createdAt: flight.envelope.manifest.createdAt,
|
|
401
|
+
source: { path: ctx.absolute, agent: ctx.agent, bytes: ctx.totalBytes, sha256: flight.hashers.plaintext.digestHex() },
|
|
402
|
+
cipher: { algorithm: flight.envelope.suite, ciphertextSha256: flight.hashers.stored.digestHex(), storedBytes: flight.wholeStoredBytes, chunks: flight.envelope.chunks.length },
|
|
403
|
+
envelope: flight.envelope,
|
|
404
|
+
// Born offloaded: this path names where the blob would live, and the
|
|
405
|
+
// offloaded marker says truthfully that no file is there — the same state
|
|
406
|
+
// an uploaded-then-offloaded archive reaches, arrived at without the detour.
|
|
407
|
+
objectPath: join(ctx.config.storage.root, `${archiveId}.vlarchive`),
|
|
408
|
+
remote,
|
|
409
|
+
offloaded: { at: now, provider: ctx.remoteStorage.provider, bucket: ctx.remoteStorage.bucket, objectKey }
|
|
410
|
+
};
|
|
411
|
+
await writeRecord(ctx.config.storage.root, record);
|
|
412
|
+
await recordAudit(ctx.dataDir, "archive.create", "allowed", { archiveId, agent: ctx.agent, bytes: ctx.totalBytes, recipients: flight.envelope.wrappedKeys.length, streamed: true, ...(flight.resumed ? { resumed: true } : {}) });
|
|
413
|
+
await recordAudit(ctx.dataDir, "upload.verify", "allowed", { archiveId, objectKey, bytes: flight.wholeStoredBytes, provider: ctx.remoteStorage.provider, streamed: true, ...(flight.resumed ? { resumed: true } : {}) });
|
|
414
|
+
await shredSpool(ctx.dataDir, archiveId);
|
|
415
|
+
await sweepStaleJournals(ctx.dataDir, ctx.client, ctx.absolute);
|
|
416
|
+
return { record, objectKey, storedBytes: flight.wholeStoredBytes, heldAtMostBytes: flight.heldAtMostBytes };
|
|
417
|
+
}
|
|
418
|
+
/** A brand-new streamed seal: fresh identity, fresh key, journal from byte zero. */
|
|
419
|
+
async function freshSeal(ctx) {
|
|
420
|
+
const archiveId = randomUUID();
|
|
421
|
+
const prefix = ctx.remoteStorage.prefix.replace(/^\/+|\/+$/g, "");
|
|
422
|
+
const objectKey = `${prefix}/${archiveId}.vlarchive`;
|
|
423
|
+
const chunkBytes = DEFAULT_CHUNK_BYTES;
|
|
424
|
+
const totalChunks = Math.max(1, Math.ceil(ctx.totalBytes / chunkBytes));
|
|
425
|
+
const recipients = configuredRecipients(ctx.config, ctx.phrase);
|
|
426
|
+
// The key is minted here, not inside the seal, because the journal must
|
|
427
|
+
// carry it (wrapped for the same recipients an envelope would name) BEFORE
|
|
428
|
+
// the first byte goes out — a journal written any later could describe
|
|
429
|
+
// uploaded chunks that no surviving key can ever finish.
|
|
430
|
+
const archiveKey = randomBytes(KEY_BYTES);
|
|
431
|
+
const noncePrefix = randomBytes(4);
|
|
432
|
+
const lock = await acquireSpoolLock(ctx.dataDir, archiveId, { now: ctx.now });
|
|
433
|
+
try {
|
|
434
|
+
const hashers = { plaintext: new StreamingSha256(), stored: new StreamingSha256() };
|
|
435
|
+
const hashStateInit = { plaintext: hashers.plaintext.exportState(), stored: hashers.stored.exportState() };
|
|
436
|
+
await createSpool(ctx.dataDir, {
|
|
437
|
+
archiveId, suite: SEAL_SUITE, provider: ctx.remoteStorage.provider, bucket: ctx.remoteStorage.bucket, objectKey,
|
|
438
|
+
source: { path: ctx.absolute, totalBytes: ctx.totalBytes, chunkBytes },
|
|
439
|
+
wrappedKeys: wrapAll(archiveKey, recipients, SEAL_SUITE, archiveId),
|
|
440
|
+
noncePrefixB64: noncePrefix.toString("base64"),
|
|
441
|
+
nextChunkIndex: 0,
|
|
442
|
+
consumedPrefixSha256: EMPTY_PREFIX_SHA256,
|
|
443
|
+
chunkHeaders: [],
|
|
444
|
+
hashState: hashStateInit,
|
|
445
|
+
providerState: null
|
|
446
|
+
}, { now: ctx.now });
|
|
447
|
+
const core = await streamSealCore(ctx, {
|
|
448
|
+
objectKey, archiveId, suite: SEAL_SUITE, archiveKey, noncePrefix, recipients, chunkBytes, totalChunks,
|
|
449
|
+
firstChunkIndex: 0, skipStoredBytes: 0, spliceGuardSha256: null, prefixHeaders: [], prefixStoredBytes: 0,
|
|
450
|
+
hashers, hashStateFallback: hashStateInit, consumedPrefixFallback: EMPTY_PREFIX_SHA256,
|
|
451
|
+
bodyHasher: null, resume: null
|
|
452
|
+
});
|
|
453
|
+
return await verifyAndRecord(ctx, {
|
|
454
|
+
archiveId, objectKey, envelope: core.sealed.envelope, wholeStoredBytes: core.sealed.storedBytes,
|
|
455
|
+
uploaded: core.uploaded, hashers, heldAtMostBytes: core.heldAtMostBytes, resumed: false,
|
|
456
|
+
progress: null
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
zeroize(archiveKey);
|
|
461
|
+
await lock.release();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/** `FileHandle.read` may come up short; a chunk boundary is not a suggestion. */
|
|
465
|
+
async function readExactly(handle, into, length, position) {
|
|
466
|
+
let filled = 0;
|
|
467
|
+
while (filled < length) {
|
|
468
|
+
const { bytesRead } = await handle.read(into, filled, length - filled, position + filled);
|
|
469
|
+
if (bytesRead === 0)
|
|
470
|
+
fail("source_unreadable", `Expected ${length} bytes at offset ${position}; the file ended after ${filled}`, { position });
|
|
471
|
+
filled += bytesRead;
|
|
472
|
+
}
|
|
473
|
+
return into.subarray(0, length);
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Continues an interrupted streamed seal from its journal.
|
|
477
|
+
*
|
|
478
|
+
* THE INVARIANT this function exists to uphold: the same (archiveKey,
|
|
479
|
+
* noncePrefix, chunkIndex) triple must never encrypt two different plaintexts.
|
|
480
|
+
* Ciphertext can reach the provider AHEAD of any journal write, so the bound
|
|
481
|
+
* on "what may already be out there" is never the journal's cursor — it is
|
|
482
|
+
* the PROVIDER'S OWN ANSWER (ListParts for a multipart, the session status
|
|
483
|
+
* query for GCS), and the journal's emission-ordered chunk headers must vouch
|
|
484
|
+
* for every byte of it:
|
|
485
|
+
*
|
|
486
|
+
* 1. Headers are journaled at EMISSION, before a chunk can leave (see the
|
|
487
|
+
* sink), so the provider can never hold a chunk the journal cannot name.
|
|
488
|
+
* A provider frontier past the journaled headers is therefore tampering or
|
|
489
|
+
* corruption — refused, remote state aborted, fresh key.
|
|
490
|
+
* 2. Every chunk inside the provider's frontier is re-sealed locally (read,
|
|
491
|
+
* gzip, encrypt — CPU only) and must reproduce its recorded ciphertext
|
|
492
|
+
* hash exactly; for S3-family, each provider-held part's ETag must also
|
|
493
|
+
* equal the MD5 of the re-sealed bytes, proving the bucket holds OUR bytes
|
|
494
|
+
* and not some other write. Any mismatch — including a source that changed
|
|
495
|
+
* anywhere under the held range — refuses before a single byte is pushed.
|
|
496
|
+
* 3. The whole-object hashers are REBUILT during that verification walk, so a
|
|
497
|
+
* resume needs nothing from the journaled hash state — the provider's
|
|
498
|
+
* frontier may be behind or ahead of the journal's cursor and the hashes
|
|
499
|
+
* are still exactly right.
|
|
500
|
+
* 4. A journal whose headers cover the whole archive is checked against a
|
|
501
|
+
* HEAD of the object first: a crash after completion but before the record
|
|
502
|
+
* write is recovered as the finished object it is, not re-uploaded.
|
|
503
|
+
*/
|
|
504
|
+
async function resumeSeal(ctx, journal) {
|
|
505
|
+
const refuse = (why) => fail("invalid_argument", `Cannot resume the interrupted upload of ${journal.objectKey}: ${why}. The archive starts over from byte zero with a fresh key.`, { archiveId: journal.archiveId });
|
|
506
|
+
const chunkBytes = journal.source.chunkBytes;
|
|
507
|
+
const totalChunks = Math.max(1, Math.ceil(ctx.totalBytes / chunkBytes));
|
|
508
|
+
if (journal.chunkHeaders.length > totalChunks)
|
|
509
|
+
return refuse("the journal names more chunks than this archive has");
|
|
510
|
+
const cumStoredAt = [0];
|
|
511
|
+
for (const header of journal.chunkHeaders)
|
|
512
|
+
cumStoredAt.push(cumStoredAt[cumStoredAt.length - 1] + header.storedBytes);
|
|
513
|
+
const headersTotal = cumStoredAt[cumStoredAt.length - 1];
|
|
514
|
+
const journaled = interpretProviderState(journal.providerState);
|
|
515
|
+
const noncePrefix = Buffer.from(journal.noncePrefixB64, "base64");
|
|
516
|
+
if (noncePrefix.length !== 4)
|
|
517
|
+
return refuse("its nonce prefix is malformed");
|
|
518
|
+
// The wrong phrase must surface as the wrong phrase — silently "resuming" by
|
|
519
|
+
// starting a fresh seal would mint an archive the real phrase cannot open.
|
|
520
|
+
const archiveKey = unwrapSpoolKey(journal, { phrase: ctx.phrase });
|
|
521
|
+
try {
|
|
522
|
+
const suite = journal.suite;
|
|
523
|
+
const recipients = configuredRecipients(ctx.config, ctx.phrase);
|
|
524
|
+
/**
|
|
525
|
+
* Re-seals chunks [0, count) from the CURRENT source, verifying each
|
|
526
|
+
* against the journaled header — and, when the S3 part map is known,
|
|
527
|
+
* verifying each provider-held part's ETag against the MD5 of the very
|
|
528
|
+
* bytes being re-produced. Returns everything the rest of the resume
|
|
529
|
+
* needs: rebuilt whole-object hashers, the compressed-body hash, the
|
|
530
|
+
* verified headers, and the per-part checksums a seeded resume must carry.
|
|
531
|
+
*/
|
|
532
|
+
const reproducePrefix = async (count, trueParts, feedPartialTo = 0) => {
|
|
533
|
+
const hashers = { plaintext: new StreamingSha256(), stored: new StreamingSha256() };
|
|
534
|
+
const bodyHasher = createHash("sha256");
|
|
535
|
+
const prefixHeaders = [];
|
|
536
|
+
let prefixStoredBytes = 0;
|
|
537
|
+
let prefixBodyBytes = 0;
|
|
538
|
+
let heldAtMostBytes = 0;
|
|
539
|
+
// Part-boundary bookkeeping for the ETag proof and the reseeded checksums.
|
|
540
|
+
let partIndex = 0;
|
|
541
|
+
let partHash = trueParts ? createHash("md5") : null;
|
|
542
|
+
let partSha = trueParts ? createHash("sha256") : null;
|
|
543
|
+
let partFilled = 0;
|
|
544
|
+
const partChecksums = [];
|
|
545
|
+
const feedPart = (bytes) => {
|
|
546
|
+
if (!trueParts)
|
|
547
|
+
return;
|
|
548
|
+
let offset = 0;
|
|
549
|
+
while (offset < bytes.length && partIndex < trueParts.length) {
|
|
550
|
+
const want = trueParts[partIndex].bytes - partFilled;
|
|
551
|
+
const piece = bytes.subarray(offset, offset + Math.min(want, bytes.length - offset));
|
|
552
|
+
partHash.update(piece);
|
|
553
|
+
partSha.update(piece);
|
|
554
|
+
partFilled += piece.length;
|
|
555
|
+
offset += piece.length;
|
|
556
|
+
if (partFilled === trueParts[partIndex].bytes) {
|
|
557
|
+
const etag = partHash.digest("hex");
|
|
558
|
+
if (trueParts[partIndex].etag !== "" && etag !== trueParts[partIndex].etag) {
|
|
559
|
+
refuse(`the provider's part ${trueParts[partIndex].partNumber} does not contain the bytes this vault sealed — the stored object cannot be trusted`);
|
|
560
|
+
}
|
|
561
|
+
partChecksums.push(partSha.digest("base64"));
|
|
562
|
+
partIndex += 1;
|
|
563
|
+
partHash = createHash("md5");
|
|
564
|
+
partSha = createHash("sha256");
|
|
565
|
+
partFilled = 0;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
if (count > 0 || (feedPartialTo > 0 && trueParts)) {
|
|
570
|
+
const handle = await open(ctx.absolute, "r");
|
|
571
|
+
try {
|
|
572
|
+
const buffer = Buffer.allocUnsafe(Math.min(chunkBytes, Math.max(ctx.totalBytes, 1)));
|
|
573
|
+
for (let index = 0; index < count; index += 1) {
|
|
574
|
+
const want = Math.max(0, Math.min(chunkBytes, ctx.totalBytes - index * chunkBytes));
|
|
575
|
+
const slice = want === 0 ? buffer.subarray(0, 0) : await readExactly(handle, buffer, want, index * chunkBytes);
|
|
576
|
+
const packed = gzipSync(slice, { level: 6 });
|
|
577
|
+
const final = index === totalChunks - 1;
|
|
578
|
+
const cipher = aeadCipher(suite, archiveKey, chunkNonce(noncePrefix, index));
|
|
579
|
+
cipher.setAAD(chunkAad({ archiveId: journal.archiveId, suite, index, totalChunks, plaintextBytes: packed.length, final }));
|
|
580
|
+
const stored = Buffer.concat([cipher.update(packed), cipher.final(), cipher.getAuthTag()]);
|
|
581
|
+
const digest = sha256hex(stored);
|
|
582
|
+
if (digest !== journal.chunkHeaders[index].sha256) {
|
|
583
|
+
refuse(`re-sealing chunk ${index} did not reproduce the bytes already sent, so the source changed under the uploaded range`);
|
|
584
|
+
}
|
|
585
|
+
hashers.plaintext.update(slice);
|
|
586
|
+
hashers.stored.update(stored);
|
|
587
|
+
bodyHasher.update(packed);
|
|
588
|
+
feedPart(stored);
|
|
589
|
+
prefixBodyBytes += packed.length;
|
|
590
|
+
prefixStoredBytes += stored.length;
|
|
591
|
+
heldAtMostBytes = Math.max(heldAtMostBytes, stored.length);
|
|
592
|
+
prefixHeaders.push({ index, plaintextBytes: packed.length, rawBytes: slice.length, storedBytes: stored.length, sha256: digest, nonce: chunkNonce(noncePrefix, index).toString("base64"), final });
|
|
593
|
+
}
|
|
594
|
+
// A provider part can straddle the frontier into the NEXT chunk —
|
|
595
|
+
// the one the streamed tail will re-send from `skip`. Its bytes up
|
|
596
|
+
// to the frontier must still flow through the part hashes, or the
|
|
597
|
+
// straddling part could never be seeded with a real checksum and a
|
|
598
|
+
// verified ETag. The chunk is re-sealed and header-verified exactly
|
|
599
|
+
// like the ones before it, but it joins neither the prefix headers
|
|
600
|
+
// nor the whole-object hashers — the streamed tail owns it.
|
|
601
|
+
if (feedPartialTo > 0 && trueParts && count < journal.chunkHeaders.length) {
|
|
602
|
+
const index = count;
|
|
603
|
+
const want = Math.max(0, Math.min(chunkBytes, ctx.totalBytes - index * chunkBytes));
|
|
604
|
+
const slice = want === 0 ? buffer.subarray(0, 0) : await readExactly(handle, buffer, want, index * chunkBytes);
|
|
605
|
+
const packed = gzipSync(slice, { level: 6 });
|
|
606
|
+
const final = index === totalChunks - 1;
|
|
607
|
+
const cipher = aeadCipher(suite, archiveKey, chunkNonce(noncePrefix, index));
|
|
608
|
+
cipher.setAAD(chunkAad({ archiveId: journal.archiveId, suite, index, totalChunks, plaintextBytes: packed.length, final }));
|
|
609
|
+
const stored = Buffer.concat([cipher.update(packed), cipher.final(), cipher.getAuthTag()]);
|
|
610
|
+
if (sha256hex(stored) !== journal.chunkHeaders[index].sha256) {
|
|
611
|
+
refuse(`re-sealing chunk ${index} did not reproduce the bytes already sent, so the source changed under the uploaded range`);
|
|
612
|
+
}
|
|
613
|
+
feedPart(stored.subarray(0, Math.min(feedPartialTo, stored.length)));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
finally {
|
|
617
|
+
await handle.close();
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return { hashers, bodyHasher, prefixHeaders, prefixStoredBytes, prefixBodyBytes, heldAtMostBytes, partChecksums };
|
|
621
|
+
};
|
|
622
|
+
/** The finish line for an object the provider already holds whole. */
|
|
623
|
+
const recordWholeObject = async () => {
|
|
624
|
+
// A completed multipart's provider checksum is the composite over ALL
|
|
625
|
+
// its parts — including the short tail the conservative journal never
|
|
626
|
+
// seeds. The layout is our own uploader's arithmetic (full parts of the
|
|
627
|
+
// journaled size, one short tail), so the verification walk rebuilds
|
|
628
|
+
// every part checksum from re-sealed bytes; empty etags mean "nothing
|
|
629
|
+
// provider-side to compare" — the retired multipart cannot be listed.
|
|
630
|
+
let layout = null;
|
|
631
|
+
if (journaled?.s3UploadId) {
|
|
632
|
+
const partBytes = journaled.journalable.parts[0]?.bytes ?? 0;
|
|
633
|
+
if (partBytes > 0) {
|
|
634
|
+
layout = [];
|
|
635
|
+
let remaining = headersTotal;
|
|
636
|
+
while (remaining > 0) {
|
|
637
|
+
const bytes = Math.min(partBytes, remaining);
|
|
638
|
+
layout.push({ partNumber: layout.length + 1, etag: "", bytes });
|
|
639
|
+
remaining -= bytes;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const walk = await reproducePrefix(totalChunks, layout);
|
|
644
|
+
const remoteChecksum = layout ? compositeChecksum(walk.partChecksums) : null;
|
|
645
|
+
const envelope = {
|
|
646
|
+
version: ENVELOPE_VERSION, archiveId: journal.archiveId, suite, chunkBytes, chunks: walk.prefixHeaders,
|
|
647
|
+
manifest: {
|
|
648
|
+
plaintextSha256: walk.bodyHasher.digest("hex"), plaintextBytes: walk.prefixBodyBytes,
|
|
649
|
+
compression: "gzip-chunk", originalBytes: ctx.totalBytes, originalSha256: walk.hashers.plaintext.digestHex(),
|
|
650
|
+
createdAt: journal.createdAt, adapter: { agent: ctx.agent, version: ADAPTER_VERSION }
|
|
651
|
+
},
|
|
652
|
+
wrappedKeys: wrapAll(archiveKey, recipients, suite, journal.archiveId)
|
|
653
|
+
};
|
|
654
|
+
return verifyAndRecord(ctx, {
|
|
655
|
+
archiveId: journal.archiveId, objectKey: journal.objectKey, envelope, wholeStoredBytes: walk.prefixStoredBytes,
|
|
656
|
+
uploaded: { remoteChecksum, bytes: walk.prefixStoredBytes }, hashers: walk.hashers,
|
|
657
|
+
heldAtMostBytes: walk.heldAtMostBytes, resumed: true, progress: journaled
|
|
658
|
+
});
|
|
659
|
+
};
|
|
660
|
+
// Recovery before anything else: if every chunk was emitted, the object
|
|
661
|
+
// may already be complete — a crash between completion and the record
|
|
662
|
+
// write. HEAD is cheap and turns "re-upload 2 GB" into "verify and record".
|
|
663
|
+
if (journal.chunkHeaders.length === totalChunks) {
|
|
664
|
+
const already = await ctx.client.head({ objectKey: journal.objectKey });
|
|
665
|
+
if (already.exists && already.bytes === headersTotal) {
|
|
666
|
+
return await recordWholeObject();
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// THE PROVIDER'S TRUTH. The journal's cursor lags emission by design; what
|
|
670
|
+
// bounds re-encryption is what the bucket actually holds.
|
|
671
|
+
let trueHeld;
|
|
672
|
+
let trueParts = null;
|
|
673
|
+
let resumeState;
|
|
674
|
+
if (journaled?.s3UploadId) {
|
|
675
|
+
if (typeof ctx.client.listParts !== "function")
|
|
676
|
+
return refuse("this provider client cannot list an in-flight upload's parts, so what the bucket holds cannot be proven");
|
|
677
|
+
let parts;
|
|
678
|
+
try {
|
|
679
|
+
parts = await ctx.client.listParts(journal.objectKey, journaled.s3UploadId);
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
// The multipart is gone — aborted, expired, or already completed. If
|
|
683
|
+
// completed, the whole object is sitting at the key: recover it.
|
|
684
|
+
const already = await ctx.client.head({ objectKey: journal.objectKey }).catch(() => null);
|
|
685
|
+
if (already?.exists && already.bytes === headersTotal && journal.chunkHeaders.length === totalChunks) {
|
|
686
|
+
return await recordWholeObject();
|
|
687
|
+
}
|
|
688
|
+
return refuse(error instanceof Error ? `the provider no longer holds the in-flight upload (${error.message})` : "the provider no longer holds the in-flight upload");
|
|
689
|
+
}
|
|
690
|
+
parts.forEach((part, index) => { if (part.partNumber !== index + 1)
|
|
691
|
+
refuse("the provider's held parts are not a contiguous prefix, which this vault never produces"); });
|
|
692
|
+
trueParts = parts;
|
|
693
|
+
trueHeld = parts.reduce((sum, part) => sum + part.bytes, 0);
|
|
694
|
+
}
|
|
695
|
+
else if (journaled && typeof journaled.journalable.session === "string") {
|
|
696
|
+
const session = journaled.journalable.session;
|
|
697
|
+
if (typeof ctx.client.resumableStatus !== "function")
|
|
698
|
+
return refuse("this provider client cannot query the session's confirmed bytes, so what the bucket holds cannot be proven");
|
|
699
|
+
const status = await ctx.client.resumableStatus(session);
|
|
700
|
+
trueHeld = status.confirmedBytes;
|
|
701
|
+
resumeState = { session, confirmedBytes: trueHeld };
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
// No provider state was ever journaled: nothing durable exists remotely,
|
|
705
|
+
// and (headers being emission-ordered) nothing may have left either —
|
|
706
|
+
// but "may" is not "provably did not". Bytes could sit in a multipart
|
|
707
|
+
// this journal never learned the id of. Without an id there is nothing
|
|
708
|
+
// to query, so the only provably-safe move is a fresh key.
|
|
709
|
+
return refuse("no provider upload state was journaled, so what the bucket may hold cannot be proven");
|
|
710
|
+
}
|
|
711
|
+
// Headers must vouch for every provider-held byte (guaranteed by
|
|
712
|
+
// emission-ordering unless the journal was cut down by hand) and the
|
|
713
|
+
// frontier must land exactly on our chunk arithmetic.
|
|
714
|
+
if (trueHeld > headersTotal)
|
|
715
|
+
return refuse("the provider holds bytes beyond every chunk the journal ever emitted — the journal cannot vouch for them");
|
|
716
|
+
let kEff = 0;
|
|
717
|
+
while (kEff < journal.chunkHeaders.length && cumStoredAt[kEff + 1] <= trueHeld)
|
|
718
|
+
kEff += 1;
|
|
719
|
+
const skip = trueHeld - cumStoredAt[kEff];
|
|
720
|
+
if (skip > 0 && kEff >= journal.chunkHeaders.length)
|
|
721
|
+
return refuse("the provider's frontier lands inside a chunk the journal has no record of");
|
|
722
|
+
if (skip > 0 && skip >= journal.chunkHeaders[kEff].storedBytes)
|
|
723
|
+
return refuse("the provider's frontier does not match the journaled chunk sizes");
|
|
724
|
+
if (trueParts) {
|
|
725
|
+
resumeState = {
|
|
726
|
+
uploadId: journaled.s3UploadId,
|
|
727
|
+
// Checksums rebuilt below by the verification walk; placeholders are
|
|
728
|
+
// replaced before anything is sent.
|
|
729
|
+
parts: trueParts.map((part) => ({ partNumber: part.partNumber, etag: part.etag, checksum: "", bytes: part.bytes }))
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
// Step 2+3 of the invariant, over the PROVIDER'S range: re-seal [0, kEff)
|
|
733
|
+
// — and the partial chunk at the frontier is guarded in the sink before
|
|
734
|
+
// its first byte is pushed. This walk also rebuilds every whole-object
|
|
735
|
+
// hash from the verified bytes, so nothing is trusted from the journal's
|
|
736
|
+
// own hash state.
|
|
737
|
+
const walk = await reproducePrefix(kEff, trueParts, skip);
|
|
738
|
+
if (trueParts && resumeState) {
|
|
739
|
+
const seeded = resumeState.parts;
|
|
740
|
+
// Only fully re-verified parts can be seeded with a real checksum; the
|
|
741
|
+
// walk covers [0, kEff) which by the boundary checks spans every FULL
|
|
742
|
+
// part (a part ends inside a chunk only at the frontier).
|
|
743
|
+
if (walk.partChecksums.length < seeded.length) {
|
|
744
|
+
// The frontier part is partially covered: S3 seeding must stop at the
|
|
745
|
+
// last full part, and the remainder re-ships from the chunk boundary.
|
|
746
|
+
seeded.length = walk.partChecksums.length;
|
|
747
|
+
}
|
|
748
|
+
seeded.forEach((part, index) => { part.checksum = walk.partChecksums[index]; });
|
|
749
|
+
const seededBytes = seeded.reduce((sum, part) => sum + part.bytes, 0);
|
|
750
|
+
// Recompute the frontier against what is actually seedable.
|
|
751
|
+
let kSeed = 0;
|
|
752
|
+
while (kSeed < journal.chunkHeaders.length && cumStoredAt[kSeed + 1] <= seededBytes)
|
|
753
|
+
kSeed += 1;
|
|
754
|
+
if (seededBytes !== trueHeld) {
|
|
755
|
+
return await resumeFromFrontier(kSeed, seededBytes - cumStoredAt[kSeed], resumeState);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return await resumeFromFrontier(kEff, skip, resumeState);
|
|
759
|
+
/** Streams the tail from a verified frontier and splices the envelope. */
|
|
760
|
+
async function resumeFromFrontier(k, skipBytes, resume) {
|
|
761
|
+
const walkAt = k === kEff && skipBytes === skip ? walk : await reproducePrefix(k, null);
|
|
762
|
+
if (k === totalChunks) {
|
|
763
|
+
if (skipBytes !== 0)
|
|
764
|
+
return refuse("the provider confirms more bytes than the finished archive contains");
|
|
765
|
+
const uploaded = await ctx.client.uploadStream(journal.objectKey, (async function* () { })(), { resume });
|
|
766
|
+
const envelope = {
|
|
767
|
+
version: ENVELOPE_VERSION, archiveId: journal.archiveId, suite, chunkBytes, chunks: walkAt.prefixHeaders,
|
|
768
|
+
manifest: {
|
|
769
|
+
plaintextSha256: walkAt.bodyHasher.digest("hex"), plaintextBytes: walkAt.prefixBodyBytes,
|
|
770
|
+
compression: "gzip-chunk", originalBytes: ctx.totalBytes, originalSha256: walkAt.hashers.plaintext.digestHex(),
|
|
771
|
+
createdAt: journal.createdAt, adapter: { agent: ctx.agent, version: ADAPTER_VERSION }
|
|
772
|
+
},
|
|
773
|
+
wrappedKeys: wrapAll(archiveKey, recipients, suite, journal.archiveId)
|
|
774
|
+
};
|
|
775
|
+
return verifyAndRecord(ctx, {
|
|
776
|
+
archiveId: journal.archiveId, objectKey: journal.objectKey, envelope, wholeStoredBytes: walkAt.prefixStoredBytes,
|
|
777
|
+
uploaded, hashers: walkAt.hashers, heldAtMostBytes: walkAt.heldAtMostBytes, resumed: true, progress: journaled
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
await recordAudit(ctx.dataDir, "upload.verify", "allowed", { archiveId: journal.archiveId, objectKey: journal.objectKey, resumedAtChunk: k, confirmedBytes: cumStoredAt[k] + skipBytes });
|
|
781
|
+
const guard = journal.chunkHeaders[k] ?? null;
|
|
782
|
+
const core = await streamSealCore(ctx, {
|
|
783
|
+
objectKey: journal.objectKey, archiveId: journal.archiveId, suite, archiveKey, noncePrefix, recipients,
|
|
784
|
+
chunkBytes, totalChunks, firstChunkIndex: k, skipStoredBytes: skipBytes,
|
|
785
|
+
spliceGuardSha256: skipBytes > 0 && guard ? guard.sha256 : null,
|
|
786
|
+
prefixHeaders: walkAt.prefixHeaders, prefixStoredBytes: walkAt.prefixStoredBytes, hashers: walkAt.hashers,
|
|
787
|
+
hashStateFallback: { plaintext: walkAt.hashers.plaintext.exportState(), stored: walkAt.hashers.stored.exportState() },
|
|
788
|
+
consumedPrefixFallback: walkAt.hashers.plaintext.digestHex(),
|
|
789
|
+
bodyHasher: walkAt.bodyHasher, resume, createdAt: journal.createdAt
|
|
790
|
+
});
|
|
791
|
+
const envelope = {
|
|
792
|
+
version: ENVELOPE_VERSION, archiveId: journal.archiveId, suite, chunkBytes,
|
|
793
|
+
chunks: [...walkAt.prefixHeaders, ...core.sealed.envelope.chunks],
|
|
794
|
+
manifest: {
|
|
795
|
+
plaintextSha256: walkAt.bodyHasher.digest("hex"),
|
|
796
|
+
plaintextBytes: walkAt.prefixBodyBytes + core.sealed.envelope.manifest.plaintextBytes,
|
|
797
|
+
compression: "gzip-chunk", originalBytes: ctx.totalBytes, originalSha256: core.sealed.originalSha256,
|
|
798
|
+
createdAt: core.sealed.envelope.manifest.createdAt, adapter: core.sealed.envelope.manifest.adapter
|
|
799
|
+
},
|
|
800
|
+
wrappedKeys: core.sealed.envelope.wrappedKeys
|
|
801
|
+
};
|
|
802
|
+
return verifyAndRecord(ctx, {
|
|
803
|
+
archiveId: journal.archiveId, objectKey: journal.objectKey, envelope,
|
|
804
|
+
wholeStoredBytes: walkAt.prefixStoredBytes + core.sealed.storedBytes,
|
|
805
|
+
uploaded: core.uploaded, hashers: walkAt.hashers,
|
|
806
|
+
heldAtMostBytes: Math.max(walkAt.heldAtMostBytes, core.heldAtMostBytes), resumed: true, progress: journaled
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
finally {
|
|
811
|
+
zeroize(archiveKey);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/** The provider-language checksum a completed S3 multipart reports: the composite of its part checksums. GCS state yields null — nothing rollable across runs. */
|
|
815
|
+
function s3Composite(progress) {
|
|
816
|
+
const state = progress.journalable;
|
|
817
|
+
if (!progress.s3UploadId || !Array.isArray(state.parts) || state.parts.length === 0)
|
|
818
|
+
return null;
|
|
819
|
+
return compositeChecksum(state.parts.map((part) => part.checksum));
|
|
820
|
+
}
|