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