sealkeep 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +201 -0
- package/CHANGELOG.md +218 -0
- package/CONTROL_PLANE.md +86 -0
- package/LICENSE +34 -0
- package/README.md +249 -0
- package/THIRD_PARTY.md +22 -0
- package/THREAT_MODEL.md +107 -0
- package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
- package/dist/packages/vaultline-crypto/src/aead.js +24 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
- package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
- package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
- package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
- package/dist/packages/vaultline-crypto/src/format.js +43 -0
- package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
- package/dist/packages/vaultline-crypto/src/index.js +5 -0
- package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
- package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
- package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
- package/dist/packages/vaultline-crypto/src/stream.js +477 -0
- package/dist/site/index.html +1542 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +22 -0
- package/dist/src/activity.js +52 -0
- package/dist/src/adapters.d.ts +212 -0
- package/dist/src/adapters.js +533 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +41 -0
- package/dist/src/autopilot.d.ts +77 -0
- package/dist/src/autopilot.js +148 -0
- package/dist/src/bip39-wordlist.d.ts +15 -0
- package/dist/src/bip39-wordlist.js +272 -0
- package/dist/src/branding.d.ts +31 -0
- package/dist/src/branding.js +31 -0
- package/dist/src/chunk-store.d.ts +142 -0
- package/dist/src/chunk-store.js +502 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +2035 -0
- package/dist/src/cloud.d.ts +434 -0
- package/dist/src/cloud.js +851 -0
- package/dist/src/control-plane/auth.d.ts +62 -0
- package/dist/src/control-plane/auth.js +123 -0
- package/dist/src/control-plane/server.d.ts +31 -0
- package/dist/src/control-plane/server.js +263 -0
- package/dist/src/control-plane/store.d.ts +101 -0
- package/dist/src/control-plane/store.js +82 -0
- package/dist/src/control-plane-cli.d.ts +2 -0
- package/dist/src/control-plane-cli.js +37 -0
- package/dist/src/control-plane-server.d.ts +10 -0
- package/dist/src/control-plane-server.js +11 -0
- package/dist/src/control-plane.d.ts +78 -0
- package/dist/src/control-plane.js +61 -0
- package/dist/src/crypto.d.ts +56 -0
- package/dist/src/crypto.js +132 -0
- package/dist/src/daemon.d.ts +52 -0
- package/dist/src/daemon.js +142 -0
- package/dist/src/dashboard-cli.d.ts +2 -0
- package/dist/src/dashboard-cli.js +20 -0
- package/dist/src/disk.d.ts +110 -0
- package/dist/src/disk.js +169 -0
- package/dist/src/doctor.d.ts +11 -0
- package/dist/src/doctor.js +198 -0
- package/dist/src/enroll.d.ts +27 -0
- package/dist/src/enroll.js +136 -0
- package/dist/src/errors.d.ts +26 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/heartbeat.d.ts +89 -0
- package/dist/src/heartbeat.js +120 -0
- package/dist/src/index-sync.d.ts +53 -0
- package/dist/src/index-sync.js +147 -0
- package/dist/src/leakscan.d.ts +48 -0
- package/dist/src/leakscan.js +222 -0
- package/dist/src/local-api.d.ts +132 -0
- package/dist/src/local-api.js +1757 -0
- package/dist/src/managed-chunks.d.ts +55 -0
- package/dist/src/managed-chunks.js +108 -0
- package/dist/src/mcp-install.d.ts +52 -0
- package/dist/src/mcp-install.js +140 -0
- package/dist/src/mcp.d.ts +1 -0
- package/dist/src/mcp.js +59 -0
- package/dist/src/migrate.d.ts +35 -0
- package/dist/src/migrate.js +88 -0
- package/dist/src/mnemonic.d.ts +60 -0
- package/dist/src/mnemonic.js +134 -0
- package/dist/src/net.d.ts +2 -0
- package/dist/src/net.js +16 -0
- package/dist/src/notify.d.ts +46 -0
- package/dist/src/notify.js +84 -0
- package/dist/src/offload.d.ts +117 -0
- package/dist/src/offload.js +331 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +44 -0
- package/dist/src/packages.d.ts +126 -0
- package/dist/src/packages.js +114 -0
- package/dist/src/passkey.d.ts +26 -0
- package/dist/src/passkey.js +54 -0
- package/dist/src/password-lock.d.ts +19 -0
- package/dist/src/password-lock.js +156 -0
- package/dist/src/paths.d.ts +9 -0
- package/dist/src/paths.js +24 -0
- package/dist/src/providers/gcs.d.ts +133 -0
- package/dist/src/providers/gcs.js +235 -0
- package/dist/src/providers/gdrive.d.ts +156 -0
- package/dist/src/providers/gdrive.js +335 -0
- package/dist/src/providers/index.d.ts +45 -0
- package/dist/src/providers/index.js +74 -0
- package/dist/src/providers/s3.d.ts +174 -0
- package/dist/src/providers/s3.js +345 -0
- package/dist/src/providers/sigv4.d.ts +78 -0
- package/dist/src/providers/sigv4.js +112 -0
- package/dist/src/queue.d.ts +185 -0
- package/dist/src/queue.js +286 -0
- package/dist/src/recovery.d.ts +40 -0
- package/dist/src/recovery.js +132 -0
- package/dist/src/rehydrate.d.ts +43 -0
- package/dist/src/rehydrate.js +66 -0
- package/dist/src/restore.d.ts +34 -0
- package/dist/src/restore.js +80 -0
- package/dist/src/retention.d.ts +251 -0
- package/dist/src/retention.js +446 -0
- package/dist/src/rotate.d.ts +47 -0
- package/dist/src/rotate.js +95 -0
- package/dist/src/search.d.ts +147 -0
- package/dist/src/search.js +677 -0
- package/dist/src/secrets.d.ts +86 -0
- package/dist/src/secrets.js +220 -0
- package/dist/src/service.d.ts +73 -0
- package/dist/src/service.js +197 -0
- package/dist/src/share.d.ts +34 -0
- package/dist/src/share.js +68 -0
- package/dist/src/spool.d.ts +97 -0
- package/dist/src/spool.js +213 -0
- package/dist/src/start-tui.d.ts +17 -0
- package/dist/src/start-tui.js +113 -0
- package/dist/src/start.d.ts +75 -0
- package/dist/src/start.js +101 -0
- package/dist/src/storage-setup.d.ts +49 -0
- package/dist/src/storage-setup.js +222 -0
- package/dist/src/storage-targets.d.ts +40 -0
- package/dist/src/storage-targets.js +147 -0
- package/dist/src/stream-to-cloud.d.ts +76 -0
- package/dist/src/stream-to-cloud.js +820 -0
- package/dist/src/sync-rules.d.ts +85 -0
- package/dist/src/sync-rules.js +125 -0
- package/dist/src/trash.d.ts +15 -0
- package/dist/src/trash.js +63 -0
- package/dist/src/tui.d.ts +18 -0
- package/dist/src/tui.js +179 -0
- package/dist/src/types.d.ts +191 -0
- package/dist/src/types.js +3 -0
- package/dist/src/ui-server.d.ts +187 -0
- package/dist/src/ui-server.js +293 -0
- package/dist/src/ui.d.ts +41 -0
- package/dist/src/ui.js +102 -0
- package/dist/src/update.d.ts +30 -0
- package/dist/src/update.js +56 -0
- package/dist/src/upload.d.ts +46 -0
- package/dist/src/upload.js +80 -0
- package/dist/src/vault.d.ts +208 -0
- package/dist/src/vault.js +812 -0
- package/dist/src/watcher.d.ts +34 -0
- package/dist/src/watcher.js +121 -0
- package/dist/src/worker.d.ts +52 -0
- package/dist/src/worker.js +190 -0
- package/package.json +65 -0
- package/web/app.js +1372 -0
- package/web/index.html +476 -0
- package/web/rail.js +308 -0
- package/web/retention.html +17 -0
- package/web/rules-view.js +249 -0
- package/web/sessions-view.js +448 -0
- package/web/sessions.html +17 -0
- package/web/setup-api.js +181 -0
- package/web/setup-logic.js +394 -0
- package/web/setup.html +419 -0
- package/web/setup.js +697 -0
- package/web/style.css +990 -0
|
@@ -0,0 +1,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
|
+
}
|