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.
Files changed (180) hide show
  1. package/ARCHITECTURE.md +201 -0
  2. package/CHANGELOG.md +218 -0
  3. package/CONTROL_PLANE.md +86 -0
  4. package/LICENSE +34 -0
  5. package/README.md +249 -0
  6. package/THIRD_PARTY.md +22 -0
  7. package/THREAT_MODEL.md +107 -0
  8. package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
  9. package/dist/packages/vaultline-crypto/src/aead.js +24 -0
  10. package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
  11. package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
  12. package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
  13. package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
  14. package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
  15. package/dist/packages/vaultline-crypto/src/format.js +43 -0
  16. package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
  17. package/dist/packages/vaultline-crypto/src/index.js +5 -0
  18. package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
  19. package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
  20. package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
  21. package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
  22. package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
  23. package/dist/packages/vaultline-crypto/src/stream.js +477 -0
  24. package/dist/site/index.html +1542 -0
  25. package/dist/site.zip +0 -0
  26. package/dist/src/activity.d.ts +22 -0
  27. package/dist/src/activity.js +52 -0
  28. package/dist/src/adapters.d.ts +212 -0
  29. package/dist/src/adapters.js +533 -0
  30. package/dist/src/audit.d.ts +24 -0
  31. package/dist/src/audit.js +41 -0
  32. package/dist/src/autopilot.d.ts +77 -0
  33. package/dist/src/autopilot.js +148 -0
  34. package/dist/src/bip39-wordlist.d.ts +15 -0
  35. package/dist/src/bip39-wordlist.js +272 -0
  36. package/dist/src/branding.d.ts +31 -0
  37. package/dist/src/branding.js +31 -0
  38. package/dist/src/chunk-store.d.ts +142 -0
  39. package/dist/src/chunk-store.js +502 -0
  40. package/dist/src/cli.d.ts +2 -0
  41. package/dist/src/cli.js +2035 -0
  42. package/dist/src/cloud.d.ts +434 -0
  43. package/dist/src/cloud.js +851 -0
  44. package/dist/src/control-plane/auth.d.ts +62 -0
  45. package/dist/src/control-plane/auth.js +123 -0
  46. package/dist/src/control-plane/server.d.ts +31 -0
  47. package/dist/src/control-plane/server.js +263 -0
  48. package/dist/src/control-plane/store.d.ts +101 -0
  49. package/dist/src/control-plane/store.js +82 -0
  50. package/dist/src/control-plane-cli.d.ts +2 -0
  51. package/dist/src/control-plane-cli.js +37 -0
  52. package/dist/src/control-plane-server.d.ts +10 -0
  53. package/dist/src/control-plane-server.js +11 -0
  54. package/dist/src/control-plane.d.ts +78 -0
  55. package/dist/src/control-plane.js +61 -0
  56. package/dist/src/crypto.d.ts +56 -0
  57. package/dist/src/crypto.js +132 -0
  58. package/dist/src/daemon.d.ts +52 -0
  59. package/dist/src/daemon.js +142 -0
  60. package/dist/src/dashboard-cli.d.ts +2 -0
  61. package/dist/src/dashboard-cli.js +20 -0
  62. package/dist/src/disk.d.ts +110 -0
  63. package/dist/src/disk.js +169 -0
  64. package/dist/src/doctor.d.ts +11 -0
  65. package/dist/src/doctor.js +198 -0
  66. package/dist/src/enroll.d.ts +27 -0
  67. package/dist/src/enroll.js +136 -0
  68. package/dist/src/errors.d.ts +26 -0
  69. package/dist/src/errors.js +23 -0
  70. package/dist/src/heartbeat.d.ts +89 -0
  71. package/dist/src/heartbeat.js +120 -0
  72. package/dist/src/index-sync.d.ts +53 -0
  73. package/dist/src/index-sync.js +147 -0
  74. package/dist/src/leakscan.d.ts +48 -0
  75. package/dist/src/leakscan.js +222 -0
  76. package/dist/src/local-api.d.ts +132 -0
  77. package/dist/src/local-api.js +1757 -0
  78. package/dist/src/managed-chunks.d.ts +55 -0
  79. package/dist/src/managed-chunks.js +108 -0
  80. package/dist/src/mcp-install.d.ts +52 -0
  81. package/dist/src/mcp-install.js +140 -0
  82. package/dist/src/mcp.d.ts +1 -0
  83. package/dist/src/mcp.js +59 -0
  84. package/dist/src/migrate.d.ts +35 -0
  85. package/dist/src/migrate.js +88 -0
  86. package/dist/src/mnemonic.d.ts +60 -0
  87. package/dist/src/mnemonic.js +134 -0
  88. package/dist/src/net.d.ts +2 -0
  89. package/dist/src/net.js +16 -0
  90. package/dist/src/notify.d.ts +46 -0
  91. package/dist/src/notify.js +84 -0
  92. package/dist/src/offload.d.ts +117 -0
  93. package/dist/src/offload.js +331 -0
  94. package/dist/src/onboarding.d.ts +10 -0
  95. package/dist/src/onboarding.js +44 -0
  96. package/dist/src/packages.d.ts +126 -0
  97. package/dist/src/packages.js +114 -0
  98. package/dist/src/passkey.d.ts +26 -0
  99. package/dist/src/passkey.js +54 -0
  100. package/dist/src/password-lock.d.ts +19 -0
  101. package/dist/src/password-lock.js +156 -0
  102. package/dist/src/paths.d.ts +9 -0
  103. package/dist/src/paths.js +24 -0
  104. package/dist/src/providers/gcs.d.ts +133 -0
  105. package/dist/src/providers/gcs.js +235 -0
  106. package/dist/src/providers/gdrive.d.ts +156 -0
  107. package/dist/src/providers/gdrive.js +335 -0
  108. package/dist/src/providers/index.d.ts +45 -0
  109. package/dist/src/providers/index.js +74 -0
  110. package/dist/src/providers/s3.d.ts +174 -0
  111. package/dist/src/providers/s3.js +345 -0
  112. package/dist/src/providers/sigv4.d.ts +78 -0
  113. package/dist/src/providers/sigv4.js +112 -0
  114. package/dist/src/queue.d.ts +185 -0
  115. package/dist/src/queue.js +286 -0
  116. package/dist/src/recovery.d.ts +40 -0
  117. package/dist/src/recovery.js +132 -0
  118. package/dist/src/rehydrate.d.ts +43 -0
  119. package/dist/src/rehydrate.js +66 -0
  120. package/dist/src/restore.d.ts +34 -0
  121. package/dist/src/restore.js +80 -0
  122. package/dist/src/retention.d.ts +251 -0
  123. package/dist/src/retention.js +446 -0
  124. package/dist/src/rotate.d.ts +47 -0
  125. package/dist/src/rotate.js +95 -0
  126. package/dist/src/search.d.ts +147 -0
  127. package/dist/src/search.js +677 -0
  128. package/dist/src/secrets.d.ts +86 -0
  129. package/dist/src/secrets.js +220 -0
  130. package/dist/src/service.d.ts +73 -0
  131. package/dist/src/service.js +197 -0
  132. package/dist/src/share.d.ts +34 -0
  133. package/dist/src/share.js +68 -0
  134. package/dist/src/spool.d.ts +97 -0
  135. package/dist/src/spool.js +213 -0
  136. package/dist/src/start-tui.d.ts +17 -0
  137. package/dist/src/start-tui.js +113 -0
  138. package/dist/src/start.d.ts +75 -0
  139. package/dist/src/start.js +101 -0
  140. package/dist/src/storage-setup.d.ts +49 -0
  141. package/dist/src/storage-setup.js +222 -0
  142. package/dist/src/storage-targets.d.ts +40 -0
  143. package/dist/src/storage-targets.js +147 -0
  144. package/dist/src/stream-to-cloud.d.ts +76 -0
  145. package/dist/src/stream-to-cloud.js +820 -0
  146. package/dist/src/sync-rules.d.ts +85 -0
  147. package/dist/src/sync-rules.js +125 -0
  148. package/dist/src/trash.d.ts +15 -0
  149. package/dist/src/trash.js +63 -0
  150. package/dist/src/tui.d.ts +18 -0
  151. package/dist/src/tui.js +179 -0
  152. package/dist/src/types.d.ts +191 -0
  153. package/dist/src/types.js +3 -0
  154. package/dist/src/ui-server.d.ts +187 -0
  155. package/dist/src/ui-server.js +293 -0
  156. package/dist/src/ui.d.ts +41 -0
  157. package/dist/src/ui.js +102 -0
  158. package/dist/src/update.d.ts +30 -0
  159. package/dist/src/update.js +56 -0
  160. package/dist/src/upload.d.ts +46 -0
  161. package/dist/src/upload.js +80 -0
  162. package/dist/src/vault.d.ts +208 -0
  163. package/dist/src/vault.js +812 -0
  164. package/dist/src/watcher.d.ts +34 -0
  165. package/dist/src/watcher.js +121 -0
  166. package/dist/src/worker.d.ts +52 -0
  167. package/dist/src/worker.js +190 -0
  168. package/package.json +65 -0
  169. package/web/app.js +1372 -0
  170. package/web/index.html +476 -0
  171. package/web/rail.js +308 -0
  172. package/web/retention.html +17 -0
  173. package/web/rules-view.js +249 -0
  174. package/web/sessions-view.js +448 -0
  175. package/web/sessions.html +17 -0
  176. package/web/setup-api.js +181 -0
  177. package/web/setup-logic.js +394 -0
  178. package/web/setup.html +419 -0
  179. package/web/setup.js +697 -0
  180. 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
+ }