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,477 @@
1
+ /**
2
+ * File-to-file sealing and opening, in constant memory.
3
+ *
4
+ * `envelope.ts` holds the whole plaintext, the whole compressed body and the
5
+ * whole ciphertext at once, so archiving a 4 GB agent rollout needed roughly
6
+ * 14.5 GB of resident memory. The archives these functions produce are the
7
+ * *same* format — a v2 envelope, gzip applied to the whole body before
8
+ * chunking — so anything written here opens with `decryptArchive`, and anything
9
+ * written by `encryptArchive` opens with `openArchiveToFile`. Only the memory
10
+ * profile changes.
11
+ *
12
+ * The one thing that stops a seal being a single streaming pass is the
13
+ * associated data: every chunk commits to the *total* chunk count, which is not
14
+ * known until the compressed body is complete. Rather than weaken that binding,
15
+ * the body is written once to scratch, measured, and then sealed. Two sequential
16
+ * streams, no format change, nothing larger than one chunk ever resident.
17
+ */
18
+ import { createReadStream, createWriteStream } from "node:fs";
19
+ import { open, rm, stat } from "node:fs/promises";
20
+ import { pipeline } from "node:stream/promises";
21
+ import { createGunzip, createGzip, gunzipSync, gzipSync } from "node:zlib";
22
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
23
+ import { dirname, join } from "node:path";
24
+ import { aeadCipher, aeadDecipher } from "./aead.js";
25
+ import { CryptoError, unwrapArchiveKey, zeroize } from "./recipients.js";
26
+ import { StreamingSha256 } from "./sha256-stream.js";
27
+ import { wrapAll, validateEnvelope } from "./envelope.js";
28
+ import { DEFAULT_CHUNK_BYTES, ENVELOPE_VERSION, KEY_BYTES, SUITES, TAG_BYTES, chunkAad, chunkNonce } from "./format.js";
29
+ const sha256 = (input) => createHash("sha256").update(input).digest("hex");
30
+ /**
31
+ * Both hash flavours answer in hex here; the branch exists so the seal loop can
32
+ * feed an injected resumable hasher and a fresh `createHash` identically. Note
33
+ * `StreamingSha256` survives its digest, so an injected instance can also be
34
+ * asked for base64 afterwards — the two output formats recorded for one seal.
35
+ */
36
+ const digestHex = (hash) => hash instanceof StreamingSha256 ? hash.digestHex() : hash.digest("hex");
37
+ /** `FileHandle.read` may come up short; a chunk boundary is not a suggestion. */
38
+ async function readExactly(handle, into, length, position) {
39
+ let filled = 0;
40
+ while (filled < length) {
41
+ const { bytesRead } = await handle.read(into, filled, length - filled, position + filled);
42
+ if (bytesRead === 0)
43
+ throw new CryptoError("ciphertext_length_mismatch", `Expected ${length} bytes at offset ${position}; the file ended after ${filled}`);
44
+ filled += bytesRead;
45
+ }
46
+ return into.subarray(0, length);
47
+ }
48
+ /**
49
+ * Seals a byte range of one file into another, holding one chunk at a time.
50
+ *
51
+ * The destination is written but never renamed here: the manifest hashes are
52
+ * only proven at the very end, so publishing the result is the caller's job and
53
+ * must happen after this resolves. On failure the destination is removed.
54
+ */
55
+ export async function sealArchiveToFile(source, destPath, options) {
56
+ const suite = options.suite ?? "chacha20-poly1305";
57
+ if (!SUITES.includes(suite))
58
+ throw new CryptoError("suite_unsupported", `Unsupported suite: ${suite}`);
59
+ const chunkBytes = options.chunkBytes ?? DEFAULT_CHUNK_BYTES;
60
+ if (!Number.isInteger(chunkBytes) || chunkBytes <= 0)
61
+ throw new CryptoError("invalid_chunk_size", "chunkBytes must be a positive integer");
62
+ const archiveId = options.archiveId ?? randomUUID();
63
+ const archiveKey = options.deterministic?.archiveKey ?? randomBytes(KEY_BYTES);
64
+ const noncePrefix = options.deterministic?.noncePrefix ?? randomBytes(4);
65
+ if (archiveKey.length !== KEY_BYTES)
66
+ throw new CryptoError("key_malformed", `An archive key must be ${KEY_BYTES} bytes`);
67
+ const compressed = (options.compression ?? "none") === "gzip";
68
+ const chunkCompressed = (options.compression ?? "none") === "gzip-chunk";
69
+ const start = source.start ?? 0;
70
+ const sourceBytes = (await stat(source.path)).size;
71
+ const end = Math.min(source.end ?? sourceBytes, sourceBytes);
72
+ if (start < 0 || end < start)
73
+ throw new CryptoError("invalid_range", `Range ${start}..${end} is not a range of ${source.path}`);
74
+ if (chunkCompressed) {
75
+ const dest0 = await open(destPath, "w", 0o600);
76
+ try {
77
+ const result = await sealChunksToSink(source, {
78
+ write: async (stored) => {
79
+ const { bytesWritten } = await dest0.write(stored);
80
+ if (bytesWritten !== stored.length) {
81
+ throw new CryptoError("destination_write_failed", `Only ${bytesWritten} of ${stored.length} bytes reached ${destPath} — the volume is probably full`);
82
+ }
83
+ }
84
+ }, { ...options, suite, chunkBytes, archiveId, archiveKey, noncePrefix });
85
+ await dest0.close();
86
+ if (!options.deterministic)
87
+ zeroize(archiveKey);
88
+ return result;
89
+ }
90
+ catch (error) {
91
+ await dest0.close();
92
+ if (!options.deterministic)
93
+ zeroize(archiveKey);
94
+ await rm(destPath, { force: true });
95
+ throw error;
96
+ }
97
+ }
98
+ const scratch = options.scratchDir ?? dirname(destPath);
99
+ const bodyPath = compressed ? join(scratch, `.vlbody-${randomUUID()}`) : source.path;
100
+ const originalHash = createHash("sha256");
101
+ let originalBytes = 0;
102
+ try {
103
+ if (compressed) {
104
+ // Pass one: the caller's bytes are hashed on the way past, so the source
105
+ // is read exactly once even though the body is produced separately.
106
+ await pipeline(createReadStream(source.path, { start, end: Math.max(start, end - 1) }), async function* (chunks) {
107
+ for await (const piece of chunks) {
108
+ originalHash.update(piece);
109
+ originalBytes += piece.length;
110
+ options.onProgress?.(originalBytes);
111
+ yield piece;
112
+ }
113
+ }, createGzip({ level: 6 }), createWriteStream(bodyPath, { mode: 0o600 }));
114
+ }
115
+ const bodyStart = compressed ? 0 : start;
116
+ const bodyBytes = compressed ? (await stat(bodyPath)).size : end - start;
117
+ const totalChunks = Math.max(1, Math.ceil(bodyBytes / chunkBytes));
118
+ const chunks = [];
119
+ const bodyHash = createHash("sha256");
120
+ const ciphertextHash = createHash("sha256");
121
+ let storedBytes = 0;
122
+ const buffer = Buffer.allocUnsafe(Math.min(chunkBytes, Math.max(bodyBytes, 1)));
123
+ const body = await open(bodyPath, "r");
124
+ const dest = await open(destPath, "w", 0o600);
125
+ try {
126
+ for (let index = 0; index < totalChunks; index += 1) {
127
+ const want = Math.max(0, Math.min(chunkBytes, bodyBytes - index * chunkBytes));
128
+ const slice = want === 0 ? buffer.subarray(0, 0) : await readExactly(body, buffer, want, bodyStart + index * chunkBytes);
129
+ const final = index === totalChunks - 1;
130
+ const nonce = chunkNonce(noncePrefix, index);
131
+ const cipher = aeadCipher(suite, archiveKey, nonce);
132
+ cipher.setAAD(chunkAad({ archiveId, suite, index, totalChunks, plaintextBytes: slice.length, final }));
133
+ const stored = Buffer.concat([cipher.update(slice), cipher.final(), cipher.getAuthTag()]);
134
+ bodyHash.update(slice);
135
+ if (!compressed) {
136
+ originalHash.update(slice);
137
+ originalBytes += slice.length;
138
+ options.onProgress?.(originalBytes);
139
+ }
140
+ ciphertextHash.update(stored);
141
+ // A short write is not an error, so a disk filling up mid-chunk can
142
+ // land fewer bytes than were hashed. That would be caught eventually —
143
+ // the recorded ciphertext hash would not match the file — but only on
144
+ // the restore, which is far too late if the source has been reclaimed
145
+ // in between. Refusing here keeps the archive and the hash the same
146
+ // object for the same reason the destination path checks its errno.
147
+ const { bytesWritten } = await dest.write(stored);
148
+ if (bytesWritten !== stored.length) {
149
+ throw new CryptoError("destination_write_failed", `Only ${bytesWritten} of ${stored.length} bytes of chunk ${index} reached ${destPath} — the volume is probably full`);
150
+ }
151
+ storedBytes += stored.length;
152
+ chunks.push({ index, plaintextBytes: slice.length, storedBytes: stored.length, sha256: sha256(stored), nonce: nonce.toString("base64"), final });
153
+ }
154
+ }
155
+ finally {
156
+ await body.close();
157
+ await dest.close();
158
+ }
159
+ const envelope = {
160
+ version: ENVELOPE_VERSION, archiveId, suite, chunkBytes, chunks,
161
+ manifest: {
162
+ plaintextSha256: bodyHash.digest("hex"), plaintextBytes: bodyBytes,
163
+ ...(compressed ? { compression: "gzip", originalBytes, originalSha256: originalHash.digest("hex") } : {}),
164
+ createdAt: options.createdAt ?? new Date().toISOString(), adapter: options.adapter
165
+ },
166
+ wrappedKeys: wrapAll(archiveKey, options.recipients, suite, archiveId, options.deterministic)
167
+ };
168
+ return { envelope, ciphertextSha256: ciphertextHash.digest("hex"), storedBytes, originalBytes, originalSha256: compressed ? envelope.manifest.originalSha256 : envelope.manifest.plaintextSha256 };
169
+ }
170
+ catch (error) {
171
+ await rm(destPath, { force: true });
172
+ throw error;
173
+ }
174
+ finally {
175
+ if (compressed)
176
+ await rm(bodyPath, { force: true });
177
+ if (!options.deterministic)
178
+ zeroize(archiveKey);
179
+ }
180
+ }
181
+ /**
182
+ * True for an errno the kernel handed back, as opposed to a cryptographic
183
+ * failure. Node stamps these as `ENOSPC`, `EACCES`, `EIO` and friends; zlib and
184
+ * this package's own failures never look like that.
185
+ */
186
+ function isSystemError(error) {
187
+ const code = error?.code;
188
+ return error instanceof Error && typeof code === "string" && /^E[A-Z]+$/.test(code);
189
+ }
190
+ /**
191
+ * Reports a failure to write the *destination* as exactly that.
192
+ *
193
+ * Restoring writes decrypted bytes to a file, so it can fail for reasons that
194
+ * have nothing to do with the archive: a full disk, a read-only volume, a
195
+ * revoked permission. Those used to fall through to the decompression branch
196
+ * above and surface as a hash failure, which the vault layer then reported as a
197
+ * wrong recovery phrase — telling a user whose disk had filled that their
198
+ * archive was unreadable, when the archive was fine and the disk was not.
199
+ */
200
+ function writeFailure(error, destPath) {
201
+ // Named by directory, not by file: callers restore through a temporary name
202
+ // they rename into place, and quoting that back at a user would be noise.
203
+ // The volume is what ran out of room anyway.
204
+ const where = dirname(destPath);
205
+ return new CryptoError("destination_write_failed", error.code === "ENOSPC"
206
+ ? `Ran out of space writing to ${where}. The archive is intact — free some space and restore again.`
207
+ : `Could not write to ${where} (${error.code}). The archive is intact.`);
208
+ }
209
+ /**
210
+ * Opens an archive straight to a file, holding one chunk at a time.
211
+ *
212
+ * Every chunk is authenticated before its bytes reach the destination, so what
213
+ * is written is never unauthenticated data — but the *whole-archive* manifest
214
+ * hashes can only be checked once the last chunk has gone past. A caller must
215
+ * therefore write to a temporary path and publish it only after this resolves;
216
+ * a rejection can leave a partial file behind, which is why `restoreArchive`
217
+ * renames into place rather than writing the destination directly.
218
+ */
219
+ export async function openArchiveToFile(envelope, ciphertextPath, destPath, unlock) {
220
+ const storedOnDisk = (await stat(ciphertextPath)).size;
221
+ validateEnvelope(envelope, storedOnDisk);
222
+ const compression = envelope.manifest.compression;
223
+ if (compression !== undefined && compression !== "gzip" && compression !== "gzip-chunk")
224
+ throw new CryptoError("envelope_malformed", "Unsupported compression");
225
+ const archiveKey = unwrapArchiveKey(envelope.wrappedKeys, envelope.suite, envelope.archiveId, unlock);
226
+ if (archiveKey.length !== KEY_BYTES) {
227
+ zeroize(archiveKey);
228
+ throw new CryptoError("key_malformed", "Unwrapped archive key has the wrong length");
229
+ }
230
+ const handle = await open(ciphertextPath, "r");
231
+ const bodyHash = createHash("sha256");
232
+ const originalHash = createHash("sha256");
233
+ let bodyBytes = 0;
234
+ let originalBytes = 0;
235
+ let bodyDigest = "";
236
+ async function* authenticatedBody() {
237
+ const widest = envelope.chunks.reduce((most, chunk) => Math.max(most, chunk.storedBytes), 1);
238
+ const buffer = Buffer.allocUnsafe(widest);
239
+ let offset = 0;
240
+ for (const chunk of envelope.chunks) {
241
+ const stored = await readExactly(handle, buffer, chunk.storedBytes, offset);
242
+ offset += chunk.storedBytes;
243
+ if (sha256(stored) !== chunk.sha256)
244
+ throw new CryptoError("chunk_hash_mismatch", `Chunk ${chunk.index} does not match its recorded hash`);
245
+ const decipher = aeadDecipher(envelope.suite, archiveKey, Buffer.from(chunk.nonce, "base64"));
246
+ decipher.setAAD(chunkAad({ archiveId: envelope.archiveId, suite: envelope.suite, index: chunk.index, totalChunks: envelope.chunks.length, plaintextBytes: chunk.plaintextBytes, final: chunk.final }));
247
+ decipher.setAuthTag(stored.subarray(stored.length - TAG_BYTES));
248
+ let plain;
249
+ try {
250
+ plain = Buffer.concat([decipher.update(stored.subarray(0, stored.length - TAG_BYTES)), decipher.final()]);
251
+ }
252
+ catch {
253
+ throw new CryptoError("chunk_auth_failed", `Chunk ${chunk.index} failed authentication`);
254
+ }
255
+ bodyHash.update(plain);
256
+ bodyBytes += plain.length;
257
+ yield plain;
258
+ }
259
+ if (bodyBytes !== envelope.manifest.plaintextBytes)
260
+ throw new CryptoError("plaintext_length_mismatch", "Decrypted length does not match the manifest");
261
+ bodyDigest = bodyHash.digest("hex");
262
+ if (bodyDigest !== envelope.manifest.plaintextSha256)
263
+ throw new CryptoError("plaintext_hash_mismatch", "Decrypted bytes do not match the manifest hash");
264
+ }
265
+ async function* measure(chunks) {
266
+ for await (const piece of chunks) {
267
+ originalHash.update(piece);
268
+ originalBytes += piece.length;
269
+ yield piece;
270
+ }
271
+ }
272
+ try {
273
+ if (compression === "gzip-chunk") {
274
+ // Each authenticated piece is exactly one chunk's compressed slice —
275
+ // undone on its own, in chunk order. This is the same access pattern a
276
+ // ranged fetch will use, just walked start to end.
277
+ async function* perChunk(chunks) {
278
+ for await (const piece of chunks) {
279
+ let slice;
280
+ try {
281
+ slice = gunzipSync(piece);
282
+ }
283
+ catch {
284
+ throw new CryptoError("plaintext_hash_mismatch", "Decompression failed after authentication");
285
+ }
286
+ originalHash.update(slice);
287
+ originalBytes += slice.length;
288
+ yield slice;
289
+ }
290
+ }
291
+ try {
292
+ await pipeline(authenticatedBody(), perChunk, createWriteStream(destPath, { mode: 0o600 }));
293
+ }
294
+ catch (error) {
295
+ if (error instanceof CryptoError)
296
+ throw error;
297
+ if (isSystemError(error))
298
+ throw writeFailure(error, destPath);
299
+ throw error;
300
+ }
301
+ if (originalBytes !== envelope.manifest.originalBytes)
302
+ throw new CryptoError("plaintext_length_mismatch", "Decompressed length does not match the manifest");
303
+ const digest = originalHash.digest("hex");
304
+ if (digest !== envelope.manifest.originalSha256)
305
+ throw new CryptoError("plaintext_hash_mismatch", "Decompressed bytes do not match the manifest hash");
306
+ return { bytes: originalBytes, sha256: digest };
307
+ }
308
+ if (compression === "gzip") {
309
+ try {
310
+ await pipeline(authenticatedBody(), createGunzip(), measure, createWriteStream(destPath, { mode: 0o600 }));
311
+ }
312
+ catch (error) {
313
+ // A zlib failure here is not a tampering signal — every byte fed to the
314
+ // decompressor was authenticated first — but it still means the archive
315
+ // cannot produce the caller's bytes, so it is reported as such.
316
+ if (error instanceof CryptoError)
317
+ throw error;
318
+ if (isSystemError(error))
319
+ throw writeFailure(error, destPath);
320
+ throw new CryptoError("plaintext_hash_mismatch", "Decompression failed after authentication");
321
+ }
322
+ // The archive is honoured only once the caller's own bytes are proven, not
323
+ // merely the compressed stream they were derived from.
324
+ if (originalBytes !== envelope.manifest.originalBytes)
325
+ throw new CryptoError("plaintext_length_mismatch", "Decompressed length does not match the manifest");
326
+ const digest = originalHash.digest("hex");
327
+ if (digest !== envelope.manifest.originalSha256)
328
+ throw new CryptoError("plaintext_hash_mismatch", "Decompressed bytes do not match the manifest hash");
329
+ return { bytes: originalBytes, sha256: digest };
330
+ }
331
+ try {
332
+ await pipeline(authenticatedBody(), createWriteStream(destPath, { mode: 0o600 }));
333
+ }
334
+ catch (error) {
335
+ if (error instanceof CryptoError)
336
+ throw error;
337
+ if (isSystemError(error))
338
+ throw writeFailure(error, destPath);
339
+ throw error;
340
+ }
341
+ return { bytes: bodyBytes, sha256: bodyDigest };
342
+ }
343
+ finally {
344
+ await handle.close();
345
+ zeroize(archiveKey);
346
+ }
347
+ }
348
+ /**
349
+ * SHA-256 of several prefixes of a file, in one read and in constant memory.
350
+ *
351
+ * This is how an append is *proved* rather than assumed: hashing `[0, N)` of the
352
+ * live file and comparing it with what an archive recorded is the only evidence
353
+ * that the archive's bytes are still a prefix of it. Several offsets are
354
+ * answered together because a transcript usually carries several archives, and
355
+ * asking one at a time would re-read it once per archive.
356
+ *
357
+ * An offset past the end of the file is simply absent from the result.
358
+ */
359
+ export async function hashFilePrefixes(path, offsets) {
360
+ const wanted = [...new Set(offsets)].filter((offset) => offset > 0).sort((a, b) => a - b);
361
+ const digests = new Map();
362
+ if (wanted.length === 0)
363
+ return digests;
364
+ // One pass, with the running digest snapshotted at each boundary: a transcript
365
+ // carrying a dozen snapshots costs one read of it, not a dozen.
366
+ const hash = createHash("sha256");
367
+ const limit = wanted[wanted.length - 1];
368
+ let read = 0;
369
+ let next = 0;
370
+ for await (const piece of createReadStream(path, { start: 0, end: limit - 1 })) {
371
+ const buffer = piece;
372
+ let offset = 0;
373
+ while (offset < buffer.length && next < wanted.length) {
374
+ const take = Math.min(buffer.length - offset, wanted[next] - read);
375
+ hash.update(buffer.subarray(offset, offset + take));
376
+ read += take;
377
+ offset += take;
378
+ while (next < wanted.length && wanted[next] === read)
379
+ digests.set(wanted[next++], hash.copy().digest("hex"));
380
+ }
381
+ }
382
+ return digests;
383
+ }
384
+ /**
385
+ * SHA-256 of a byte range of a file, read in constant memory.
386
+ *
387
+ * This is how an append is *proved* rather than assumed: hashing `[0, N)` of the
388
+ * live file and comparing it with what an archive recorded is the only evidence
389
+ * that the archive's bytes are still a prefix of it.
390
+ */
391
+ export async function hashFileRange(path, start = 0, end) {
392
+ const size = (await stat(path)).size;
393
+ const last = Math.min(end ?? size, size);
394
+ const hash = createHash("sha256");
395
+ let bytes = 0;
396
+ if (last > start) {
397
+ for await (const piece of createReadStream(path, { start, end: last - 1 })) {
398
+ hash.update(piece);
399
+ bytes += piece.length;
400
+ }
401
+ }
402
+ return { sha256: hash.digest("hex"), bytes };
403
+ }
404
+ /**
405
+ * The single-pass gzip-chunk seal, destination-agnostic. This is what makes
406
+ * seal-to-cloud a sink implementation instead of an architecture project: the
407
+ * chunk loop neither knows nor cares whether `write` lands on a disk, a GCS
408
+ * resumable session, or an S3 multipart part buffer. Local cost is one slice
409
+ * in memory, full stop.
410
+ */
411
+ export async function sealChunksToSink(source, sink, options) {
412
+ const suite = options.suite ?? "chacha20-poly1305";
413
+ const chunkBytes = options.chunkBytes ?? DEFAULT_CHUNK_BYTES;
414
+ const archiveId = options.archiveId ?? randomUUID();
415
+ const ownKey = !options.archiveKey;
416
+ const archiveKey = options.archiveKey ?? randomBytes(KEY_BYTES);
417
+ const noncePrefix = options.noncePrefix ?? randomBytes(4);
418
+ const firstChunkIndex = options.firstChunkIndex ?? 0;
419
+ if (!Number.isInteger(firstChunkIndex) || firstChunkIndex < 0)
420
+ throw new CryptoError("invalid_range", "firstChunkIndex must be a non-negative integer");
421
+ const start = source.start ?? 0;
422
+ const sourceBytes = (await stat(source.path)).size;
423
+ const end = Math.min(source.end ?? sourceBytes, sourceBytes);
424
+ if (start < 0 || end < start)
425
+ throw new CryptoError("invalid_range", `Range ${start}..${end} is not a range of ${source.path}`);
426
+ const raw = end - start;
427
+ const localChunks = Math.max(1, Math.ceil(raw / chunkBytes));
428
+ const totalChunks = firstChunkIndex + localChunks;
429
+ const chunks = [];
430
+ const originalHash = options.hashers?.plaintext ?? createHash("sha256");
431
+ const bodyHash = createHash("sha256");
432
+ const ciphertextHash = options.hashers?.stored ?? createHash("sha256");
433
+ let originalBytes = 0;
434
+ let bodyBytes = 0;
435
+ let storedBytesTotal = 0;
436
+ const buffer = Buffer.allocUnsafe(Math.min(chunkBytes, Math.max(raw, 1)));
437
+ const src = await open(source.path, "r");
438
+ try {
439
+ for (let position = 0; position < localChunks; position += 1) {
440
+ const index = firstChunkIndex + position;
441
+ const want = Math.max(0, Math.min(chunkBytes, raw - position * chunkBytes));
442
+ const slice = want === 0 ? buffer.subarray(0, 0) : await readExactly(src, buffer, want, start + position * chunkBytes);
443
+ originalHash.update(slice);
444
+ originalBytes += slice.length;
445
+ options.onProgress?.(originalBytes);
446
+ options.onPlaintext?.(index, slice);
447
+ const packed = gzipSync(slice, { level: 6 });
448
+ const final = index === totalChunks - 1;
449
+ const nonce = chunkNonce(noncePrefix, index);
450
+ const cipher = aeadCipher(suite, archiveKey, nonce);
451
+ cipher.setAAD(chunkAad({ archiveId, suite, index, totalChunks, plaintextBytes: packed.length, final }));
452
+ const stored = Buffer.concat([cipher.update(packed), cipher.final(), cipher.getAuthTag()]);
453
+ bodyHash.update(packed);
454
+ bodyBytes += packed.length;
455
+ ciphertextHash.update(stored);
456
+ await sink.write(stored);
457
+ storedBytesTotal += stored.length;
458
+ chunks.push({ index, plaintextBytes: packed.length, rawBytes: slice.length, storedBytes: stored.length, sha256: createHash("sha256").update(stored).digest("hex"), nonce: nonce.toString("base64"), final });
459
+ }
460
+ }
461
+ finally {
462
+ await src.close();
463
+ }
464
+ const originalSha256 = digestHex(originalHash);
465
+ const envelope = {
466
+ version: ENVELOPE_VERSION, archiveId, suite, chunkBytes, chunks,
467
+ manifest: {
468
+ plaintextSha256: bodyHash.digest("hex"), plaintextBytes: bodyBytes,
469
+ compression: "gzip-chunk", originalBytes, originalSha256,
470
+ createdAt: options.createdAt ?? new Date().toISOString(), adapter: options.adapter
471
+ },
472
+ wrappedKeys: wrapAll(archiveKey, options.recipients, suite, archiveId, options.deterministic)
473
+ };
474
+ if (ownKey && !options.deterministic)
475
+ zeroize(archiveKey);
476
+ return { envelope, ciphertextSha256: digestHex(ciphertextHash), storedBytes: storedBytesTotal, originalBytes, originalSha256 };
477
+ }