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,812 @@
1
+ import { mkdir, readFile, writeFile, rename, access, rm, stat } from "node:fs/promises";
2
+ import { constants, createReadStream, createWriteStream, existsSync } from "node:fs";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { createGzip, createGunzip, gunzipSync } from "node:zlib";
6
+ import { pipeline } from "node:stream/promises";
7
+ import { decryptLegacyArchive, equalHex, isLegacyPhraseCheck, phraseCheck, sha256, upgradeLegacyPhraseCheck } from "./crypto.js";
8
+ import { canonicalPhrase, generateRecoveryPhrase } from "./mnemonic.js";
9
+ import { decryptArchive as openEnvelope, hashFilePrefixes, hashFileRange, keyRecipientId, openArchiveToFile, sealArchiveToFile, unwrapArchiveKey, x25519PublicKeyFromRaw, zeroize } from "../packages/vaultline-crypto/src/index.js";
10
+ import { fail, VaultlineError } from "./errors.js";
11
+ import { recordAudit } from "./audit.js";
12
+ import { isV2 } from "./types.js";
13
+ /** Bumped when the adapter's preservation behaviour changes, recorded in every envelope. */
14
+ /**
15
+ * Writes one archive record atomically.
16
+ *
17
+ * Written to a temporary file beside the target and renamed into place, so a
18
+ * crash never leaves a half-written record that would strand an archive. This
19
+ * lived as three byte-identical private copies in upload, migrate and
20
+ * retention; a fourth was about to be added for the managed push path.
21
+ */
22
+ export async function writeRecord(root, record) {
23
+ const path = join(root, `${record.id}.json`);
24
+ const temp = `${path}.${randomUUID()}.tmp`;
25
+ await writeFile(temp, JSON.stringify(record, null, 2) + "\n", { mode: 0o600 });
26
+ await rename(temp, path);
27
+ }
28
+ export const ADAPTER_VERSION = "1";
29
+ export const CONFIG_VERSION = 1;
30
+ /**
31
+ * ~/.sealkeep for new vaults; an existing ~/.Sealkeep keeps working forever —
32
+ * the rebrand must never strand a vault that predates it.
33
+ */
34
+ export const defaultDataDir = () => {
35
+ const home = process.env.HOME ?? ".";
36
+ const legacy = join(home, ".vaultline");
37
+ const current = join(home, ".sealkeep");
38
+ if (!existsSync(current) && existsSync(legacy))
39
+ return legacy;
40
+ return current;
41
+ };
42
+ const configPath = (dataDir) => join(dataDir, "config.json");
43
+ const archiveDir = (dataDir) => join(dataDir, "archives");
44
+ async function exists(path) { try {
45
+ await access(path, constants.F_OK);
46
+ return true;
47
+ }
48
+ catch {
49
+ return false;
50
+ } }
51
+ async function writeJson(path, value) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); }
52
+ /**
53
+ * Reads and version-gates the vault config. A config written by a newer build is
54
+ * refused rather than silently reinterpreted; migrations attach here when v2 lands.
55
+ */
56
+ export async function readConfig(dataDir) {
57
+ let raw;
58
+ try {
59
+ raw = await readFile(configPath(dataDir), "utf8");
60
+ }
61
+ catch {
62
+ return fail("vault_not_initialized", `No vault at ${dataDir}. Run: sealkeep setup --data-dir ${dataDir}`, { dataDir });
63
+ }
64
+ const config = JSON.parse(raw);
65
+ if (config.version !== CONFIG_VERSION)
66
+ fail("config_unsupported_version", `Vault config version ${String(config.version)} is not supported by this build (expected ${CONFIG_VERSION})`, { found: config.version, expected: CONFIG_VERSION });
67
+ // Defect #49: builds before this one stored `recovery.phraseCheck` as a
68
+ // bare, unsalted SHA-256 -- a cheap offline oracle. upgradeLegacyPhraseCheck
69
+ // re-derives the cost-matched value from that stored digest alone, with no
70
+ // phrase needed, so every vault created before the fix is repaired the
71
+ // first time anything reads its config -- nobody has to run a migration or
72
+ // even unlock the vault first for this to happen.
73
+ if (isLegacyPhraseCheck(config.recovery.phraseCheck)) {
74
+ config.recovery = { ...config.recovery, phraseCheck: upgradeLegacyPhraseCheck(config.recovery.phraseCheck) };
75
+ // Best effort: an unwritable dataDir still gets the upgraded value for
76
+ // this call (below), and simply repeats the upgrade on its next read.
77
+ await writeJson(configPath(dataDir), config).catch(() => { });
78
+ }
79
+ return config;
80
+ }
81
+ export async function vaultStatus(dataDir) {
82
+ const config = await readConfig(dataDir);
83
+ const archives = await listArchives(dataDir);
84
+ // Resolved to "sealed" rather than passed through raw, so no consumer ever
85
+ // has to know that absence and sealed are the same thing.
86
+ return { vaultId: config.vaultId, provider: config.storage.provider, storageMode: config.storageMode ?? "sealed", remoteStorage: config.remoteStorage, archiveCount: archives.length, archivedBytes: archives.reduce((total, archive) => total + archive.source.bytes, 0) };
87
+ }
88
+ export async function configureRemoteStorage(dataDir, remoteStorage) {
89
+ if (!remoteStorage.bucket.trim() || !remoteStorage.prefix.trim())
90
+ fail("invalid_argument", "--bucket and --prefix are required");
91
+ const config = await readConfig(dataDir);
92
+ const normalized = { ...remoteStorage, bucket: remoteStorage.bucket.trim(), prefix: remoteStorage.prefix.replace(/^\/+|\/+$/g, "") };
93
+ if (!normalized.prefix)
94
+ fail("invalid_argument", "--prefix cannot be empty");
95
+ const next = { ...config, remoteStorage: normalized };
96
+ await writeJson(configPath(dataDir), next);
97
+ return next;
98
+ }
99
+ export async function initialize(dataDir, providedPhrase, opts = {}) {
100
+ if (await exists(configPath(dataDir)))
101
+ fail("vault_already_initialized", `Vault already initialized at ${dataDir}`, { dataDir });
102
+ const phrase = canonicalPhrase(providedPhrase ?? generateRecoveryPhrase());
103
+ // Defect #49: `??` only falls back to a generated phrase on null/undefined,
104
+ // so `setup --recovery-phrase ""` sailed through with an empty phrase and no
105
+ // length, word-count, or entropy check ever ran. Whitespace-only input
106
+ // (`" "`) passes canonicalPhrase() unchanged too -- it only rewrites
107
+ // recognised word phrases -- so checking the trimmed result after
108
+ // canonicalizing catches both, before anything is written to disk.
109
+ if (!phrase.trim())
110
+ fail("invalid_argument", "Recovery phrase cannot be empty or whitespace. Omit --recovery-phrase to generate a secure one, or supply a non-empty phrase.");
111
+ // A plain vault still gets a real phrase and a real phraseCheck: the phrase
112
+ // names the vault across machines and is what a future re-seal would wrap
113
+ // to. Only "plain" is ever written — a sealed config must stay byte-for-byte
114
+ // what it was before this mode existed.
115
+ const config = {
116
+ version: CONFIG_VERSION, vaultId: randomUUID(), storage: { provider: "local", root: archiveDir(dataDir) },
117
+ ...(opts.storageMode === "plain" ? { storageMode: "plain" } : {}),
118
+ recovery: { phraseCheck: phraseCheck(phrase) }
119
+ };
120
+ await writeJson(configPath(dataDir), config);
121
+ await mkdir(config.storage.root, { recursive: true });
122
+ return { config, phrase };
123
+ }
124
+ /**
125
+ * Refuses a transcript larger than a ceiling the operator asked for.
126
+ *
127
+ * This used to derive a ceiling from installed RAM, because archiving read the
128
+ * whole transcript, gzipped it and sealed it with all three resident — 3.64x
129
+ * the file's size, so a 4 GB rollout wanted about 14.5 GB and the three largest
130
+ * sessions on a 16 GB machine were simply refused. Archiving now streams a
131
+ * chunk at a time, so a transcript's size no longer predicts what it costs to
132
+ * archive and a machine-derived refusal would only turn away sessions this
133
+ * build can handle. Only an explicit VAULTLINE_MAX_ARCHIVE_BYTES still refuses.
134
+ */
135
+ function assertArchivable(absolute, size) {
136
+ const ceiling = Number(process.env.VAULTLINE_MAX_ARCHIVE_BYTES ?? "");
137
+ if (!Number.isFinite(ceiling) || ceiling <= 0 || size <= ceiling)
138
+ return;
139
+ const gb = (value) => `${(value / 1024 ** 3).toFixed(1)} GB`;
140
+ fail("source_unreadable", `${absolute} is ${gb(size)}, above the VAULTLINE_MAX_ARCHIVE_BYTES ceiling of ${gb(ceiling)}. Raise or unset VAULTLINE_MAX_ARCHIVE_BYTES to archive it.`, { sourcePath: absolute, bytes: size, ceiling });
141
+ }
142
+ /**
143
+ * The archive whose bytes are still a prefix of this source, and how many.
144
+ *
145
+ * Agent transcripts are append-only JSONL, which is the property that makes
146
+ * incremental archiving possible at all: if the first N bytes have not changed,
147
+ * everything before N is already sealed and only the tail is new. "Have not
148
+ * changed" is *proved* here, not assumed — the prefix is re-read and hashed
149
+ * against what the archive recorded, in constant memory — because a wrong
150
+ * answer would produce an archive that cannot reconstruct the file.
151
+ *
152
+ * The longest proven prefix wins, so a transcript archived at 1 MB and again at
153
+ * 3 MB reports the 3 MB archive. Nothing is returned when the file was
154
+ * rewritten, truncated, or has never been archived.
155
+ */
156
+ export async function provenAppendOffset(archives, sourcePath, currentBytes) {
157
+ const candidates = archives
158
+ .filter((record) => record.source.path === sourcePath && record.source.bytes > 0 && record.source.bytes <= currentBytes)
159
+ .sort((a, b) => b.source.bytes - a.source.bytes);
160
+ if (candidates.length === 0)
161
+ return null;
162
+ const digests = await hashFilePrefixes(sourcePath, candidates.map((record) => record.source.bytes));
163
+ for (const record of candidates) {
164
+ const prefix = digests.get(record.source.bytes);
165
+ if (prefix && equalHex(prefix, record.source.sha256))
166
+ return { archiveId: record.id, bytes: record.source.bytes };
167
+ }
168
+ return null;
169
+ }
170
+ /**
171
+ * The delta pointer on a record, when the record is a delta archive.
172
+ *
173
+ * Defensive on purpose: the field is not in the typed record (types.ts is the
174
+ * wire contract and moves separately), so this is the one place its shape is
175
+ * checked before anything trusts it.
176
+ */
177
+ export function deltaOf(record) {
178
+ if (!isV2(record))
179
+ return null;
180
+ const delta = record.delta;
181
+ if (!delta || typeof delta !== "object")
182
+ return null;
183
+ const { baseArchiveId, baseBytes } = delta;
184
+ if (typeof baseArchiveId !== "string" || typeof baseBytes !== "number" || !(baseBytes > 0))
185
+ return null;
186
+ return { baseArchiveId, baseBytes };
187
+ }
188
+ /**
189
+ * The plain-mode object writer: gzip, and nothing else.
190
+ *
191
+ * Both hashes are taken as the bytes stream past so the source is read exactly
192
+ * once, the same discipline the sealed path follows. On failure the staged file
193
+ * is removed for the same reason sealArchiveToFile removes its destination — a
194
+ * crash must never leave a .partial that could be mistaken for an archive.
195
+ */
196
+ async function writePlainArchive(sourcePath, staged, onProgress) {
197
+ const originalHash = createHash("sha256");
198
+ const storedHash = createHash("sha256");
199
+ let originalBytes = 0;
200
+ let storedBytes = 0;
201
+ try {
202
+ await pipeline(createReadStream(sourcePath), async function* (chunks) {
203
+ for await (const piece of chunks) {
204
+ originalHash.update(piece);
205
+ originalBytes += piece.length;
206
+ onProgress?.(originalBytes);
207
+ yield piece;
208
+ }
209
+ }, createGzip({ level: 6 }), async function* (chunks) {
210
+ for await (const piece of chunks) {
211
+ storedHash.update(piece);
212
+ storedBytes += piece.length;
213
+ yield piece;
214
+ }
215
+ }, createWriteStream(staged, { mode: 0o600 }));
216
+ }
217
+ catch (error) {
218
+ await rm(staged, { force: true });
219
+ throw error;
220
+ }
221
+ return { originalBytes, originalSha256: originalHash.digest("hex"), storedBytes, storedSha256: storedHash.digest("hex") };
222
+ }
223
+ /**
224
+ * Seals one transcript, reading and writing a chunk at a time.
225
+ *
226
+ * An existing archive is returned unchanged when the same source bytes are
227
+ * archived again, so a duplicate lifecycle event costs a hash rather than an
228
+ * archive.
229
+ *
230
+ * `hooks.delta` opts in to incremental sealing: when the newest archive of
231
+ * this path is a *proven* prefix of the live file, only the appended range is
232
+ * sealed and the record carries a `delta` pointer to its base. Off by default
233
+ * so every existing caller keeps writing complete, standalone archives; the
234
+ * queue worker turns it on, because re-sealing a growing session whole on
235
+ * every snapshot is exactly how 4 GB of transcript became 40 GB of storage.
236
+ */
237
+ export async function archiveFile(dataDir, sourcePath, rawPhrase, agent = "custom", hooks = {}) {
238
+ const phrase = canonicalPhrase(rawPhrase);
239
+ const config = await readConfig(dataDir);
240
+ if (!equalHex(config.recovery.phraseCheck, phraseCheck(phrase)))
241
+ fail("recovery_phrase_mismatch", "Recovery phrase does not match this vault");
242
+ const absolute = resolve(sourcePath);
243
+ const source = await stat(absolute).catch(() => null);
244
+ if (!source?.isFile())
245
+ return fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
246
+ assertArchivable(absolute, source.size);
247
+ // Hashing the source to answer "have I already archived exactly this?" is a
248
+ // whole extra read of a file that may be gigabytes, so it is only worth doing
249
+ // when some archive of this path already claims the same length. Anything of
250
+ // a different length has certainly changed.
251
+ const archives = await listArchives(dataDir);
252
+ const sameLength = archives.filter((item) => item.source.path === absolute && item.source.bytes === source.size);
253
+ if (sameLength.length > 0) {
254
+ const { sha256: digest } = await hashFileRange(absolute);
255
+ const existing = sameLength.find((item) => equalHex(item.source.sha256, digest));
256
+ if (existing)
257
+ return { ...existing, deduplicated: true };
258
+ }
259
+ // What this snapshot adds to the newest one that is still a prefix of the
260
+ // file. Recorded, not acted on: the archive below is a complete, standalone
261
+ // archive of the whole transcript. See the note on `supersedes` in types.ts.
262
+ const base = await provenAppendOffset(archives, absolute, source.size);
263
+ // The delta path: the caller asked for it, the prefix is proven, and there
264
+ // is genuinely a tail to seal. Plain vaults are excluded — a bare gzip has
265
+ // no ranged layout, and "portable, readable memory" should stay whole files.
266
+ // (base.bytes === size never reaches here: the dedupe above already returned
267
+ // that archive.)
268
+ if (hooks.delta === true && config.storageMode !== "plain" && base && base.bytes < source.size) {
269
+ return sealDeltaArchive(dataDir, config, absolute, agent, phrase, source.size, base, hooks);
270
+ }
271
+ const id = randomUUID();
272
+ const objectPath = join(config.storage.root, `${id}.vlarchive`);
273
+ const staged = `${objectPath}.${randomUUID()}.partial`;
274
+ if (config.storageMode === "plain") {
275
+ const plain = await writePlainArchive(absolute, staged, hooks.onProgress).catch((error) => {
276
+ const code = error.code;
277
+ if (code && ["ENOENT", "EACCES", "EPERM", "EISDIR", "EBUSY"].includes(code))
278
+ return fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
279
+ throw error;
280
+ });
281
+ // The record keeps the sealed shape minus the envelope, because everything
282
+ // downstream — dedupe, verify, offload, push — reads `cipher` as "the
283
+ // stored object's summary" and never as proof of encryption. In particular
284
+ // `ciphertextSha256` keeps its name: it has always meant "hash of the
285
+ // bytes on disk", which here is the gzip, and renaming it would fork every
286
+ // consumer into two field names for one meaning. No envelope is written
287
+ // because inventing one would claim key wrapping that never happened.
288
+ const record = {
289
+ version: 2, id, createdAt: new Date().toISOString(),
290
+ source: { path: absolute, agent, bytes: plain.originalBytes, sha256: plain.originalSha256 },
291
+ cipher: { algorithm: "none", ciphertextSha256: plain.storedSha256, storedBytes: plain.storedBytes, chunks: 0 },
292
+ objectPath, ...(base ? { supersedes: base } : {})
293
+ };
294
+ try {
295
+ await rename(staged, objectPath);
296
+ }
297
+ catch (error) {
298
+ await rm(staged, { force: true });
299
+ throw error;
300
+ }
301
+ await writeJson(join(config.storage.root, `${id}.json`), record);
302
+ await recordAudit(dataDir, "archive.create", "allowed", { archiveId: id, agent, bytes: plain.originalBytes, recipients: 0, supersedes: base?.archiveId ?? null });
303
+ return { ...record, deduplicated: false };
304
+ }
305
+ // Compression happens before the seal because ciphertext is incompressible
306
+ // by construction. How much it buys was assumed here to be about 2x; measured
307
+ // on a real 1.6 GB Codex rollout it is 1.5x, because these files carry a lot
308
+ // of base64 and tool output that has already been compressed once. The
309
+ // difference matters wherever space is planned rather than merely hoped for —
310
+ // see spaceNeededFor in src/disk.ts, which uses the measured figure.
311
+ const sealed = await sealArchiveToFile({ path: absolute, end: source.size }, staged, {
312
+ // gzip-chunk: compression lives inside each chunk, so sealing is a single
313
+ // pass with no staged body — the ×2 disk window is gone — and every chunk
314
+ // can later be fetched and opened alone. Archives sealed under the legacy
315
+ // whole-body layout stay readable forever; this changes what is WRITTEN.
316
+ recipients: configuredRecipients(config, phrase), archiveId: id, compression: "gzip-chunk",
317
+ adapter: { agent, version: ADAPTER_VERSION }, scratchDir: config.storage.root,
318
+ ...(hooks.onProgress ? { onProgress: hooks.onProgress } : {})
319
+ }).catch((error) => {
320
+ const code = error.code;
321
+ if (code && ["ENOENT", "EACCES", "EPERM", "EISDIR", "EBUSY"].includes(code))
322
+ return fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
323
+ throw error;
324
+ });
325
+ const record = {
326
+ version: 2, id, createdAt: sealed.envelope.manifest.createdAt,
327
+ source: { path: absolute, agent, bytes: sealed.originalBytes, sha256: sealed.originalSha256 },
328
+ cipher: { algorithm: sealed.envelope.suite, ciphertextSha256: sealed.ciphertextSha256, storedBytes: sealed.storedBytes, chunks: sealed.envelope.chunks.length },
329
+ envelope: sealed.envelope, objectPath, ...(base ? { supersedes: base } : {})
330
+ };
331
+ // The object only becomes an archive once it is whole: the manifest hashes are
332
+ // proved by the last chunk, so a crash mid-seal leaves a .partial nobody reads.
333
+ try {
334
+ await rename(staged, objectPath);
335
+ }
336
+ catch (error) {
337
+ await rm(staged, { force: true });
338
+ throw error;
339
+ }
340
+ await writeJson(join(config.storage.root, `${id}.json`), record);
341
+ await recordAudit(dataDir, "archive.create", "allowed", { archiveId: id, agent, bytes: sealed.originalBytes, recipients: sealed.envelope.wrappedKeys.length, supersedes: base?.archiveId ?? null });
342
+ return { ...record, deduplicated: false };
343
+ }
344
+ /**
345
+ * Seals only what an appended transcript added since its proven base archive.
346
+ *
347
+ * The stored object covers `[base.bytes, size)` of the source — the append,
348
+ * nothing else — which is what makes archiving a growing session cost its
349
+ * growth instead of its length. The *record*, though, still answers for the
350
+ * whole transcript: `source.bytes`/`source.sha256` describe the entire live
351
+ * file at seal time, exactly as a full archive's record would. That one choice
352
+ * keeps every consumer honest without knowing deltas exist — dedupe still
353
+ * recognises an unchanged file, retention's "source changed since archived"
354
+ * gate still compares like with like, and `provenAppendOffset` can prove the
355
+ * *next* append against this record and chain on top of it.
356
+ *
357
+ * Deliberately absent: `supersedes`. That field's contract is "every byte of
358
+ * the archive it names is inside this one", and `pruneRedundantArchives`
359
+ * deletes on the strength of it. A delta does not contain its base — it
360
+ * *needs* it — so writing `supersedes` here would offer the base for pruning
361
+ * and turn a storage saving into data loss.
362
+ */
363
+ async function sealDeltaArchive(dataDir, config, absolute, agent, phrase, size, base, hooks) {
364
+ const id = randomUUID();
365
+ const objectPath = join(config.storage.root, `${id}.vlarchive`);
366
+ const staged = `${objectPath}.${randomUUID()}.partial`;
367
+ const sealed = await sealArchiveToFile({ path: absolute, start: base.bytes, end: size }, staged, {
368
+ recipients: configuredRecipients(config, phrase), archiveId: id, compression: "gzip-chunk",
369
+ adapter: { agent, version: ADAPTER_VERSION }, scratchDir: config.storage.root,
370
+ ...(hooks.onProgress ? { onProgress: hooks.onProgress } : {})
371
+ }).catch((error) => {
372
+ const code = error.code;
373
+ if (code && ["ENOENT", "EACCES", "EPERM", "EISDIR", "EBUSY"].includes(code))
374
+ return fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
375
+ throw error;
376
+ });
377
+ // The whole-file identity this record answers for. Hashed after the seal so
378
+ // a file that grew meanwhile is still described exactly as sealed: the range
379
+ // ends at `size` either way. A file that *shrank* mid-seal is not an append
380
+ // at all — refuse rather than record a chain that cannot reproduce anything;
381
+ // the queued retry re-proves the prefix and archives it whole.
382
+ const whole = await hashFileRange(absolute, 0, size);
383
+ if (sealed.originalBytes !== size - base.bytes || whole.bytes !== size) {
384
+ await rm(staged, { force: true });
385
+ fail("source_unreadable", `${absolute} changed while its append was being sealed; it will re-archive whole on the next pass`, { sourcePath: absolute, bytes: size });
386
+ }
387
+ const record = {
388
+ version: 2, id, createdAt: sealed.envelope.manifest.createdAt,
389
+ source: { path: absolute, agent, bytes: size, sha256: whole.sha256 },
390
+ cipher: { algorithm: sealed.envelope.suite, ciphertextSha256: sealed.ciphertextSha256, storedBytes: sealed.storedBytes, chunks: sealed.envelope.chunks.length },
391
+ envelope: sealed.envelope, objectPath,
392
+ delta: { baseArchiveId: base.archiveId, baseBytes: base.bytes }
393
+ };
394
+ try {
395
+ await rename(staged, objectPath);
396
+ }
397
+ catch (error) {
398
+ await rm(staged, { force: true });
399
+ throw error;
400
+ }
401
+ await writeJson(join(config.storage.root, `${id}.json`), record);
402
+ await recordAudit(dataDir, "archive.create", "allowed", { archiveId: id, agent, bytes: size, appendedBytes: sealed.originalBytes, recipients: sealed.envelope.wrappedKeys.length, supersedes: null, deltaBase: base.archiveId });
403
+ return { ...record, deduplicated: false };
404
+ }
405
+ /**
406
+ * The recovery phrase always gets a recipient, so a recovery kit alone can restore.
407
+ * Registered device and backup keys are added alongside it.
408
+ */
409
+ export function configuredRecipients(config, rawPhrase, group) {
410
+ const phrase = canonicalPhrase(rawPhrase);
411
+ const keys = (config.recipients ?? [])
412
+ .filter((recipient) => !group || recipient.group === group)
413
+ .map((recipient) => ({ kind: "x25519", publicKey: x25519PublicKeyFromRaw(Buffer.from(recipient.publicKey, "base64")) }));
414
+ return [{ kind: "phrase", phrase }, ...keys];
415
+ }
416
+ export async function listArchives(dataDir) {
417
+ const config = await readConfig(dataDir);
418
+ const { readdir } = await import("node:fs/promises");
419
+ const files = await readdir(config.storage.root);
420
+ const records = await Promise.all(files.filter((file) => file.endsWith(".json")).map(async (file) => JSON.parse(await readFile(join(config.storage.root, file), "utf8"))));
421
+ return records.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
422
+ }
423
+ /**
424
+ * Registers an X25519 public key that may open future archives. Only the public
425
+ * key is stored; the matching private key stays on its own device.
426
+ */
427
+ export async function addRecipient(dataDir, label, publicKeyBase64, group) {
428
+ if (!label.trim())
429
+ fail("invalid_argument", "A recipient label is required");
430
+ const raw = Buffer.from(publicKeyBase64, "base64");
431
+ if (raw.length !== 32)
432
+ fail("invalid_argument", "An X25519 public key must be 32 bytes, base64 encoded");
433
+ const config = await readConfig(dataDir);
434
+ const id = keyRecipientId(raw);
435
+ const recipients = (config.recipients ?? []).filter((recipient) => recipient.id !== id);
436
+ recipients.push({ id, label: label.trim(), publicKey: raw.toString("base64"), addedAt: new Date().toISOString(), ...(group ? { group } : {}) });
437
+ const next = { ...config, recipients };
438
+ await writeJson(configPath(dataDir), next);
439
+ await recordAudit(dataDir, "recipient.add", "allowed", { recipientId: id, label: label.trim(), group: group ?? null });
440
+ return next;
441
+ }
442
+ /** Removes a recipient from future archives. Run `sealkeep rewrap` to revoke it on existing ones. */
443
+ export async function removeRecipient(dataDir, id) {
444
+ const config = await readConfig(dataDir);
445
+ const recipients = (config.recipients ?? []).filter((recipient) => recipient.id !== id);
446
+ if (recipients.length === (config.recipients ?? []).length)
447
+ fail("invalid_argument", `No recipient with id ${id}`, { recipientId: id });
448
+ const next = { ...config, recipients };
449
+ await writeJson(configPath(dataDir), next);
450
+ await recordAudit(dataDir, "recipient.remove", "allowed", { recipientId: id });
451
+ return next;
452
+ }
453
+ /** Persists a full config document after validation by the caller. */
454
+ export async function writeConfig(dataDir, config) {
455
+ await writeJson(configPath(dataDir), config);
456
+ }
457
+ /** A non-mutating preview. Policy evaluation lives in `src/retention.ts`. */
458
+ export async function previewRetention(dataDir, olderThanDays = 30) {
459
+ const { evaluateRetention } = await import("./retention.js");
460
+ return evaluateRetention(dataDir, { olderThanDays });
461
+ }
462
+ /** One archive by id, or a named failure. Callers that need the record before the bytes start here. */
463
+ export async function findArchive(dataDir, id) {
464
+ const record = (await listArchives(dataDir)).find((item) => item.id === id);
465
+ return record ?? fail("archive_not_found", `Archive not found: ${id}`, { archiveId: id });
466
+ }
467
+ /**
468
+ * Proves the phrase opens this archive, before the caller creates anything for it.
469
+ *
470
+ * Restoring streams straight to disk, so by the time a chunk fails there is
471
+ * already a partial file — and a partial file in a directory that had to be
472
+ * created is worse than no restore at all. Unwrapping the archive key needs
473
+ * only the envelope, costs one scrypt, and catches the failure people actually
474
+ * hit, so `restoreArchive` does it before it touches the filesystem.
475
+ */
476
+ export function assertPhraseOpens(config, record, rawPhrase) {
477
+ const phrase = canonicalPhrase(rawPhrase);
478
+ // A plain archive has no wrapped key, so the phrase proves identity the same
479
+ // way archiving does. This is deliberately still a refusal: the bytes are
480
+ // readable by anyone with the disk, but a restore under the wrong phrase is
481
+ // evidence of the wrong vault, and acting on it would write the wrong
482
+ // person's sessions somewhere they were asked for their own.
483
+ if (config.storageMode === "plain") {
484
+ if (!equalHex(config.recovery.phraseCheck, phraseCheck(phrase)))
485
+ fail("recovery_phrase_mismatch", "Recovery phrase does not match this vault", { archiveId: record.id });
486
+ return;
487
+ }
488
+ // v1 has no separable wrap to test: decryptLegacyArchive needs the ciphertext,
489
+ // which restoreRecordToFile reads for it a moment later.
490
+ if (!isV2(record))
491
+ return;
492
+ try {
493
+ zeroize(unwrapArchiveKey(record.envelope.wrappedKeys, record.envelope.suite, record.envelope.archiveId, { phrase }));
494
+ }
495
+ catch (error) {
496
+ fail("recovery_phrase_mismatch", `Unable to open archive ${record.id}: ${error instanceof Error ? error.message : "decryption failed"}`, { archiveId: record.id });
497
+ }
498
+ }
499
+ /**
500
+ * Says what actually went wrong when an archive would not open.
501
+ *
502
+ * Every failure here used to be reported as a recovery-phrase mismatch, which
503
+ * was wrong for most of them and actively harmful for one: restoring writes the
504
+ * decrypted bytes to a file, so a full disk surfaced as `ENOSPC`, fell through
505
+ * to the decompression branch, and was announced as a wrong phrase. The people
506
+ * this tool is for are precisely the ones whose disks are full, so the single
507
+ * most likely restore failure told them their vault was lost. Only a key that
508
+ * no recipient can unwrap is evidence about the phrase; a disk that will not
509
+ * take bytes and an archive that fails its own hashes are separate facts.
510
+ */
511
+ function openFailure(archiveId, error) {
512
+ if (!(error instanceof Error))
513
+ return new VaultlineError("internal", `Unable to open archive ${archiveId}`, { archiveId });
514
+ const code = error.code;
515
+ const detail = { archiveId };
516
+ switch (code) {
517
+ case "destination_write_failed":
518
+ return new VaultlineError("destination_unwritable", error.message, detail);
519
+ case "chunk_auth_failed":
520
+ case "chunk_hash_mismatch":
521
+ case "envelope_malformed":
522
+ return new VaultlineError("ciphertext_integrity_failed", `Archive ${archiveId} is damaged: ${error.message}`, detail);
523
+ case "plaintext_hash_mismatch":
524
+ case "plaintext_length_mismatch":
525
+ return new VaultlineError("plaintext_integrity_failed", `Archive ${archiveId} did not reproduce the bytes it recorded: ${error.message}`, detail);
526
+ default:
527
+ return new VaultlineError("recovery_phrase_mismatch", `Unable to open archive ${archiveId}: ${error.message}`, detail);
528
+ }
529
+ }
530
+ /**
531
+ * Restores one archive straight to a path, holding one chunk at a time.
532
+ *
533
+ * `destination` must be a path the caller is willing to see half-written: the
534
+ * whole-archive hashes are only proved once the last chunk has gone past, so a
535
+ * rejection can leave bytes behind. `restoreArchive` renames a temporary into
536
+ * place for exactly this reason.
537
+ */
538
+ export async function restoreRecordToFile(dataDir, record, rawPhrase, destination,
539
+ /** Injectable so the offloaded-archive path can be proved without a bucket. */
540
+ options = {}) {
541
+ const phrase = canonicalPhrase(rawPhrase);
542
+ const config = await readConfig(dataDir);
543
+ // A delta archive's object holds only what an append added; the bytes before
544
+ // it live in its base chain. Routed through the one chain-aware helper so
545
+ // every caller of this function — the CLI and dashboard restore included —
546
+ // gets the whole transcript back, not a tail pretending to be one.
547
+ if (deltaOf(record)) {
548
+ const { bytes } = await restoreDeltaChainToFile(dataDir, record, phrase, destination, options);
549
+ return { bytes };
550
+ }
551
+ if (config.storageMode === "plain")
552
+ return restorePlainRecordToFile(dataDir, config, record, phrase, destination, options);
553
+ if (!isV2(record)) {
554
+ // v1 predates chunking: there is one AEAD frame over the whole archive, so
555
+ // there is nothing to stream and nothing to gain by pretending otherwise.
556
+ const { plaintext } = await decryptRecord(dataDir, record.id, phrase);
557
+ await writeFile(destination, plaintext, { mode: 0o600 });
558
+ return { bytes: plaintext.length };
559
+ }
560
+ // An archive whose local copy was offloaded lives in the bucket now, so it is
561
+ // fetched to a temporary file first and removed again afterwards — the bytes
562
+ // are on this disk only for as long as the restore takes, which is what keeps
563
+ // the space saving real. An archive that was never offloaded resolves to its
564
+ // own path and releases to nothing.
565
+ const { materialiseArchive } = await import("./offload.js");
566
+ const source = await materialiseArchive(dataDir, record, options.client);
567
+ try {
568
+ const { sha256: stored } = await hashFileRange(source.path);
569
+ if (!equalHex(stored, record.cipher.ciphertextSha256))
570
+ fail("ciphertext_integrity_failed", "Ciphertext integrity check failed before decryption", { archiveId: record.id });
571
+ let opened;
572
+ try {
573
+ opened = await openArchiveToFile(record.envelope, source.path, destination, { phrase });
574
+ }
575
+ catch (error) {
576
+ throw openFailure(record.id, error);
577
+ }
578
+ if (!equalHex(opened.sha256, record.source.sha256))
579
+ fail("plaintext_integrity_failed", "Plaintext integrity check failed after decryption", { archiveId: record.id });
580
+ return { bytes: opened.bytes };
581
+ }
582
+ finally {
583
+ await source.release();
584
+ }
585
+ }
586
+ /**
587
+ * The records that reproduce a delta archive's transcript, oldest first:
588
+ * one complete base archive, then every delta in append order, ending with
589
+ * `target` itself. A record that is not a delta resolves to just itself.
590
+ *
591
+ * Pure and synchronous so callers that only want to *show* a chain — a
592
+ * dashboard, `sealkeep list` — can resolve it without touching ciphertext.
593
+ * Every link is checked against the next one's claim: a missing base or a
594
+ * base whose recorded length is not what the delta sealed from is refused
595
+ * here, before any restore starts writing.
596
+ */
597
+ export function resolveDeltaChain(archives, target) {
598
+ const byId = new Map(archives.map((record) => [record.id, record]));
599
+ const chain = [target];
600
+ const seen = new Set([target.id]);
601
+ let cursor = target;
602
+ for (let delta = deltaOf(cursor); delta; delta = deltaOf(cursor)) {
603
+ const base = byId.get(delta.baseArchiveId)
604
+ ?? fail("archive_not_found", `Archive ${cursor.id} is a delta of ${delta.baseArchiveId}, which is missing — without the base, the transcript cannot be reassembled`, { archiveId: delta.baseArchiveId, deltaId: cursor.id });
605
+ if (seen.has(base.id))
606
+ fail("ciphertext_integrity_failed", `Delta chain for ${target.id} loops at ${base.id}`, { archiveId: target.id });
607
+ if (base.source.bytes !== delta.baseBytes) {
608
+ fail("ciphertext_integrity_failed", `Delta ${cursor.id} sealed from ${delta.baseBytes} base bytes, but archive ${base.id} covers ${base.source.bytes}`, { archiveId: cursor.id, baseArchiveId: base.id });
609
+ }
610
+ chain.unshift(base);
611
+ seen.add(base.id);
612
+ cursor = base;
613
+ }
614
+ return chain;
615
+ }
616
+ /**
617
+ * Restores a delta archive by reassembling its chain: the base archive first,
618
+ * then each delta's appended range, concatenated in order into `destination`.
619
+ *
620
+ * Like `restoreRecordToFile`, the destination is written as the chain streams
621
+ * past, so callers restore through a temporary path and rename into place.
622
+ * Each link proves itself before its bytes land — stored-object hash before
623
+ * decryption, the envelope's own range hash inside `openArchiveToFile` — and
624
+ * the finished file is then proved *whole* against the newest record's
625
+ * full-transcript hash. A chain that cannot reproduce the exact bytes it
626
+ * promised is an error, never a nearly-right transcript.
627
+ */
628
+ export async function restoreDeltaChainToFile(dataDir, record, rawPhrase, destination, options = {}) {
629
+ const phrase = canonicalPhrase(rawPhrase);
630
+ const chain = resolveDeltaChain(await listArchives(dataDir), record);
631
+ const [baseRecord, ...deltas] = chain;
632
+ // The base is a complete, standalone archive; the single-record path already
633
+ // knows how to restore every kind of it (v2, v1, offloaded) and starts the
634
+ // destination file.
635
+ await restoreRecordToFile(dataDir, baseRecord, phrase, destination, options);
636
+ const { materialiseArchive } = await import("./offload.js");
637
+ for (const link of deltas) {
638
+ if (!isV2(link))
639
+ fail("ciphertext_integrity_failed", `Delta ${link.id} is not a v2 archive and cannot hold a sealed range`, { archiveId: link.id });
640
+ const delta = deltaOf(link);
641
+ const scratch = join(dirname(destination), `.${basename(destination)}.${randomUUID()}.delta`);
642
+ const source = await materialiseArchive(dataDir, link, options.client);
643
+ try {
644
+ const { sha256: stored } = await hashFileRange(source.path);
645
+ if (!equalHex(stored, link.cipher.ciphertextSha256))
646
+ fail("ciphertext_integrity_failed", "Ciphertext integrity check failed before decryption", { archiveId: link.id });
647
+ let opened;
648
+ try {
649
+ opened = await openArchiveToFile(link.envelope, source.path, scratch, { phrase });
650
+ }
651
+ catch (error) {
652
+ throw openFailure(link.id, error);
653
+ }
654
+ if (opened.bytes !== link.source.bytes - delta.baseBytes) {
655
+ fail("plaintext_integrity_failed", `Delta ${link.id} reproduced ${opened.bytes} bytes where its record promises ${link.source.bytes - delta.baseBytes}`, { archiveId: link.id });
656
+ }
657
+ await pipeline(createReadStream(scratch), createWriteStream(destination, { flags: "a", mode: 0o600 }));
658
+ }
659
+ finally {
660
+ await rm(scratch, { force: true });
661
+ await source.release();
662
+ }
663
+ }
664
+ // The reassembled whole must be the transcript the newest record answered
665
+ // for — proved against the disk, not inferred from the links passing.
666
+ const whole = await hashFileRange(destination);
667
+ if (whole.bytes !== record.source.bytes || !equalHex(whole.sha256, record.source.sha256)) {
668
+ fail("plaintext_integrity_failed", `Archive ${record.id} did not reproduce the bytes it recorded after reassembling its ${chain.length}-link chain`, { archiveId: record.id });
669
+ }
670
+ return { bytes: whole.bytes, chain: chain.map((link) => link.id) };
671
+ }
672
+ /**
673
+ * The plain-vault restore: gunzip, with the sealed path's checks kept.
674
+ *
675
+ * Same shape as the sealed branch on purpose — materialise (so offloaded plain
676
+ * archives come back from the bucket the same way), prove the stored object's
677
+ * hash before touching it, prove the plaintext hash after — because "not
678
+ * encrypted" must never come to mean "not verified".
679
+ */
680
+ async function restorePlainRecordToFile(dataDir, config, record, phrase, destination, options) {
681
+ assertPhraseOpens(config, record, phrase);
682
+ // Plain mode postdates v1, so a v1 record in a plain vault is a record that
683
+ // was copied in from somewhere else; refuse rather than guess at its bytes.
684
+ if (!isV2(record))
685
+ fail("ciphertext_integrity_failed", `Archive ${record.id} is not a plain-mode archive`, { archiveId: record.id });
686
+ const { materialiseArchive } = await import("./offload.js");
687
+ const source = await materialiseArchive(dataDir, record, options.client);
688
+ try {
689
+ const { sha256: stored } = await hashFileRange(source.path);
690
+ if (!equalHex(stored, record.cipher.ciphertextSha256))
691
+ fail("ciphertext_integrity_failed", "Stored object integrity check failed before restore", { archiveId: record.id });
692
+ const plaintextHash = createHash("sha256");
693
+ let bytes = 0;
694
+ try {
695
+ await pipeline(createReadStream(source.path), createGunzip(), async function* (chunks) {
696
+ for await (const piece of chunks) {
697
+ plaintextHash.update(piece);
698
+ bytes += piece.length;
699
+ yield piece;
700
+ }
701
+ }, createWriteStream(destination, { mode: 0o600 }));
702
+ }
703
+ catch (error) {
704
+ // The taxonomy openFailure defends for the sealed path, kept here: a full
705
+ // disk is about the destination, not the archive, and must not be
706
+ // reported as a damaged archive.
707
+ const code = error?.code;
708
+ if (error instanceof Error && typeof code === "string" && /^E[A-Z]+$/.test(code)) {
709
+ fail("destination_unwritable", code === "ENOSPC" ? `Ran out of space writing to ${dirname(destination)}. The archive is intact — free some space and restore again.` : `Could not write to ${dirname(destination)} (${code}). The archive is intact.`, { archiveId: record.id });
710
+ }
711
+ fail("ciphertext_integrity_failed", `Archive ${record.id} is damaged: ${error instanceof Error ? error.message : "gunzip failed"}`, { archiveId: record.id });
712
+ }
713
+ if (!equalHex(plaintextHash.digest("hex"), record.source.sha256))
714
+ fail("plaintext_integrity_failed", "Plaintext integrity check failed after restore", { archiveId: record.id });
715
+ return { bytes };
716
+ }
717
+ finally {
718
+ await source.release();
719
+ }
720
+ }
721
+ /**
722
+ * Verifies then decrypts one archive in memory. Ciphertext integrity is checked
723
+ * before the key touches it, and plaintext integrity after.
724
+ *
725
+ * This holds the whole transcript at once; `restoreRecordToFile` is the
726
+ * constant-memory path and is what restoring uses. Callers that genuinely need
727
+ * the bytes in hand — indexing, the v1 migration — still come through here.
728
+ */
729
+ /**
730
+ * The sealed bytes of a record, wherever they live. A local blob reads
731
+ * straight from disk; an OFFLOADED archive — sealed copy in the cloud, local
732
+ * blob reclaimed — is fetched hash-checked and held only for the read. Found
733
+ * by the live value drill: `sealkeep index build` died with ENOENT on the
734
+ * first offloaded legacy archive it met, because this read assumed the blob.
735
+ */
736
+ async function readSealedObject(dataDir, record) {
737
+ const { materialiseArchive } = await import("./offload.js");
738
+ const materialised = await materialiseArchive(dataDir, record);
739
+ try {
740
+ return await readFile(materialised.path);
741
+ }
742
+ finally {
743
+ await materialised.release();
744
+ }
745
+ }
746
+ export async function decryptRecord(dataDir, id, rawPhrase) {
747
+ const phrase = canonicalPhrase(rawPhrase);
748
+ const config = await readConfig(dataDir);
749
+ const record = await findArchive(dataDir, id);
750
+ // A delta archive decrypts as its whole transcript: base chain first, then
751
+ // each sealed append, concatenated and proved against the record's
752
+ // full-transcript hash. Without this, search and the v1 migration would
753
+ // decrypt a tail and correctly refuse it as not matching the record.
754
+ if (deltaOf(record) && config.storageMode !== "plain") {
755
+ const chain = resolveDeltaChain(await listArchives(dataDir), record);
756
+ const pieces = [(await decryptRecord(dataDir, chain[0].id, phrase)).plaintext];
757
+ for (const link of chain.slice(1)) {
758
+ if (!isV2(link))
759
+ fail("ciphertext_integrity_failed", `Delta ${link.id} is not a v2 archive and cannot hold a sealed range`, { archiveId: link.id });
760
+ const sealedRange = await readSealedObject(dataDir, link);
761
+ if (!equalHex(sha256(sealedRange), link.cipher.ciphertextSha256))
762
+ fail("ciphertext_integrity_failed", "Ciphertext integrity check failed before decryption", { archiveId: link.id });
763
+ try {
764
+ pieces.push(openEnvelope(link.envelope, sealedRange, { phrase }));
765
+ }
766
+ catch (error) {
767
+ fail("recovery_phrase_mismatch", `Unable to open archive ${link.id}: ${error instanceof Error ? error.message : "decryption failed"}`, { archiveId: link.id });
768
+ }
769
+ }
770
+ const assembled = Buffer.concat(pieces);
771
+ if (!equalHex(sha256(assembled), record.source.sha256))
772
+ fail("plaintext_integrity_failed", "Plaintext integrity check failed after decryption", { archiveId: id });
773
+ return { record, plaintext: assembled };
774
+ }
775
+ const ciphertext = await readSealedObject(dataDir, record);
776
+ if (!equalHex(sha256(ciphertext), record.cipher.ciphertextSha256))
777
+ fail("ciphertext_integrity_failed", "Ciphertext integrity check failed before decryption", { archiveId: id });
778
+ // In a plain vault the stored object is a gzip, so "decrypt" is gunzip — but
779
+ // the phrase and hash contracts hold either way: callers like search hand
780
+ // this the vault's phrase and rely on a wrong one being refused.
781
+ if (config.storageMode === "plain") {
782
+ assertPhraseOpens(config, record, phrase);
783
+ let unzipped;
784
+ try {
785
+ unzipped = gunzipSync(ciphertext);
786
+ }
787
+ catch (error) {
788
+ return fail("ciphertext_integrity_failed", `Archive ${id} is damaged: ${error instanceof Error ? error.message : "gunzip failed"}`, { archiveId: id });
789
+ }
790
+ if (!equalHex(sha256(unzipped), record.source.sha256))
791
+ fail("plaintext_integrity_failed", "Plaintext integrity check failed after decompression", { archiveId: id });
792
+ return { record, plaintext: unzipped };
793
+ }
794
+ let plaintext;
795
+ try {
796
+ plaintext = isV2(record)
797
+ ? openEnvelope(record.envelope, ciphertext, { phrase })
798
+ : decryptLegacyArchive(ciphertext, { ...record.cipher, wrappedKey: record.wrappedKey }, phrase);
799
+ }
800
+ catch (error) {
801
+ fail("recovery_phrase_mismatch", `Unable to open archive ${id}: ${error instanceof Error ? error.message : "decryption failed"}`, { archiveId: id });
802
+ }
803
+ if (!equalHex(sha256(plaintext), record.source.sha256))
804
+ fail("plaintext_integrity_failed", "Plaintext integrity check failed after decryption", { archiveId: id });
805
+ return { record, plaintext: plaintext };
806
+ }
807
+ /** Kept for callers that name their own destination. Restore policy lives in `src/restore.ts`. */
808
+ export async function recoverArchive(dataDir, id, destination, phrase) {
809
+ const { restoreArchive } = await import("./restore.js");
810
+ const outcome = await restoreArchive(dataDir, id, phrase, { destination, overwrite: "refuse" });
811
+ return { record: outcome.record, output: outcome.output };
812
+ }