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,142 @@
|
|
|
1
|
+
import type { ArchiveRecordV2, RemoteObject } from "./types.js";
|
|
2
|
+
import { type ChunkHeader } from "../packages/vaultline-crypto/src/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Remote layout v2: a session's archive is a FOLDER of chunk objects, not one
|
|
5
|
+
* blob. Paul's shape, and the natural completion of the gzip-chunk format —
|
|
6
|
+
* every chunk was already independently sealed and independently decryptable;
|
|
7
|
+
* storing them as one object was the leftover assumption, and everything hard
|
|
8
|
+
* about streaming (multipart sessions, resumable protocols, unknown total
|
|
9
|
+
* length) existed only to serve it.
|
|
10
|
+
*
|
|
11
|
+
* <project>/<YYYY-MM-DD>/<session>.<id8>/
|
|
12
|
+
* chunk-000000 … chunk-NNNNNN one 4 MB sealed chunk per object
|
|
13
|
+
* envelope.vlmeta the wrapped-keys envelope
|
|
14
|
+
*
|
|
15
|
+
* What the folder buys:
|
|
16
|
+
* - A PUT per chunk needs no multipart anything: length known, checksum
|
|
17
|
+
* known, one signed request. (This is also what will let MANAGED storage
|
|
18
|
+
* stream with the signer it already has — a chunk URL is just a signed PUT.)
|
|
19
|
+
* - Resume is HEAD, not protocol: chunk names are deterministic, so the
|
|
20
|
+
* provider's truth is "does chunk-000017 exist and match its recorded
|
|
21
|
+
* hash" — asked object by object, no ListParts, no sessions.
|
|
22
|
+
* - Search fetches the chunk it wants by name.
|
|
23
|
+
* - The envelope sidecar makes the folder self-recovering: a bucket plus the
|
|
24
|
+
* recovery phrase reconstructs the session with no local records at all.
|
|
25
|
+
* - Deleting an archive is deleting its folder.
|
|
26
|
+
*
|
|
27
|
+
* On your own bucket the folder is a (hashed or readable) hierarchy; on
|
|
28
|
+
* managed storage it is the bare archive id and each object rides an ordinary
|
|
29
|
+
* upload lease as its own file (managed-chunks.ts) — the plane learns nothing
|
|
30
|
+
* it could not already count, project names included.
|
|
31
|
+
*
|
|
32
|
+
* THE RESUME INVARIANT is inherited unchanged from stream-to-cloud.ts: the
|
|
33
|
+
* same (archiveKey, noncePrefix, chunkIndex) triple must never encrypt two
|
|
34
|
+
* different plaintexts. Chunk headers are journaled at emission — before the
|
|
35
|
+
* chunk's PUT — so the provider can never hold a chunk the journal cannot
|
|
36
|
+
* name, and a resume re-seals and verifies every already-present chunk
|
|
37
|
+
* against its recorded hash before anything new is encrypted. Any mismatch
|
|
38
|
+
* abandons the folder and restarts under a fresh key.
|
|
39
|
+
*/
|
|
40
|
+
export type ChunkStoreClient = {
|
|
41
|
+
putObject(key: string, body: Buffer): Promise<{
|
|
42
|
+
checksum: string;
|
|
43
|
+
}>;
|
|
44
|
+
headObject(key: string): Promise<{
|
|
45
|
+
exists: boolean;
|
|
46
|
+
bytes: number;
|
|
47
|
+
checksum?: string;
|
|
48
|
+
}>;
|
|
49
|
+
getObject(key: string): Promise<Buffer>;
|
|
50
|
+
deleteObject(key: string): Promise<void>;
|
|
51
|
+
};
|
|
52
|
+
export declare const chunkObjectName: (index: number) => string;
|
|
53
|
+
export declare const ENVELOPE_OBJECT = "envelope.vlmeta";
|
|
54
|
+
export type RemoteNaming = "readable" | "hashed";
|
|
55
|
+
/**
|
|
56
|
+
* The key the hashed naming mode HMACs segments under, derived from the
|
|
57
|
+
* recovery phrase and pinned to the vault.
|
|
58
|
+
*
|
|
59
|
+
* Paul's insight, made safe: hashing the segments keeps the folder STRUCTURE
|
|
60
|
+
* (same project → same folder, same day → same shelf, grouping and dedupe all
|
|
61
|
+
* work) while the names say nothing. But a bare hash says everything to
|
|
62
|
+
* anyone willing to guess: project names are dictionary words and dates are
|
|
63
|
+
* a 365-entry alphabet per year — sha256("2026-08-17") reverses instantly.
|
|
64
|
+
* So the segments are HMAC-SHA256 under a phrase-derived key: without the
|
|
65
|
+
* phrase the folder names are noise; with it, any of the vault's machines
|
|
66
|
+
* derives the same key and the same grouping.
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* Scoped to the STORAGE, not the vault instance: two machines signed into the
|
|
70
|
+
* same bucket with the same phrase are one namespace, and vaultId — which is
|
|
71
|
+
* minted per machine — would scatter them into folders neither can predict.
|
|
72
|
+
* The test that caught this had machine B hash the index segment differently
|
|
73
|
+
* from machine A and find nothing where A had pushed everything.
|
|
74
|
+
*/
|
|
75
|
+
export declare function folderNamingKey(phrase: string, storageScope: string): Buffer;
|
|
76
|
+
/** The one canonical spelling of a storage's namespace scope. */
|
|
77
|
+
export declare function storageScopeOf(remoteStorage: {
|
|
78
|
+
provider: string;
|
|
79
|
+
bucket: string;
|
|
80
|
+
}): string;
|
|
81
|
+
/**
|
|
82
|
+
* The folder for one archive: project / day / session, each segment either
|
|
83
|
+
* readable (your own bucket, browsable in the provider console) or an HMAC
|
|
84
|
+
* (structure without meaning — what managed storage will use). The archive-id
|
|
85
|
+
* suffix keeps two same-named sessions, and two snapshots of one session,
|
|
86
|
+
* from colliding.
|
|
87
|
+
*/
|
|
88
|
+
export declare function chunkFolderFor(input: {
|
|
89
|
+
configuredPrefix: string;
|
|
90
|
+
project: string | null;
|
|
91
|
+
createdAt: string;
|
|
92
|
+
sourcePath: string;
|
|
93
|
+
archiveId: string;
|
|
94
|
+
naming?: RemoteNaming;
|
|
95
|
+
namingKey?: Buffer;
|
|
96
|
+
}): string;
|
|
97
|
+
export type ChunkSealOutcome = {
|
|
98
|
+
record: ArchiveRecordV2;
|
|
99
|
+
folder: string;
|
|
100
|
+
storedBytes: number;
|
|
101
|
+
chunkCount: number;
|
|
102
|
+
/** Chunks that were already in the bucket and were verified instead of re-sent. */
|
|
103
|
+
reusedChunks: number;
|
|
104
|
+
heldAtMostBytes: number;
|
|
105
|
+
/** Whether the archive's tokens were merged into the content index at seal time. */
|
|
106
|
+
indexed: boolean;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Seals a source straight into a chunk folder: read a slice, seal it, PUT it
|
|
110
|
+
* as its own object, journal it, forget it. Peak local cost is one chunk.
|
|
111
|
+
* A journal for the source resumes the same folder — already-present chunks
|
|
112
|
+
* are re-sealed locally and verified against both the journal's recorded hash
|
|
113
|
+
* AND the provider's stored checksum, then skipped.
|
|
114
|
+
*/
|
|
115
|
+
export declare function sealToChunkFolder(dataDir: string, sourcePath: string, rawPhrase: string, agent: string, options?: {
|
|
116
|
+
client?: ChunkStoreClient;
|
|
117
|
+
project?: string | null;
|
|
118
|
+
resume?: boolean;
|
|
119
|
+
now?: number;
|
|
120
|
+
targetId?: string;
|
|
121
|
+
}): Promise<ChunkSealOutcome>;
|
|
122
|
+
/**
|
|
123
|
+
* Seals the readable identity for the sidecar. Its own random nonce — chunk
|
|
124
|
+
* nonces are (prefix, index) and this must never collide with any of them —
|
|
125
|
+
* and an AAD binding it to this archive, so a sidecar cannot be swapped
|
|
126
|
+
* between folders without the opener noticing.
|
|
127
|
+
*/
|
|
128
|
+
export type FolderIdentity = {
|
|
129
|
+
v: 1;
|
|
130
|
+
project: string | null;
|
|
131
|
+
sourcePath: string;
|
|
132
|
+
agent: string;
|
|
133
|
+
createdAt: string;
|
|
134
|
+
};
|
|
135
|
+
/** Opens a sidecar's identity with the unwrapped archive key. */
|
|
136
|
+
export declare function openIdentity(archiveKey: Buffer, archiveId: string, blob: Buffer): FolderIdentity;
|
|
137
|
+
/** Fetches a chunked remote back into one ciphertext buffer, hash-checked per chunk. */
|
|
138
|
+
export declare function fetchChunkFolder(client: ChunkStoreClient, remote: RemoteObject, expected: {
|
|
139
|
+
chunks: ChunkHeader[];
|
|
140
|
+
}): Promise<Buffer>;
|
|
141
|
+
/** Deletes a chunked remote: every chunk object plus the envelope sidecar. */
|
|
142
|
+
export declare function deleteChunkFolder(client: ChunkStoreClient, remote: RemoteObject): Promise<void>;
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { createHash, createHmac, hkdfSync, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { fail } from "./errors.js";
|
|
5
|
+
import { recordAudit } from "./audit.js";
|
|
6
|
+
import { canonicalPhrase } from "./mnemonic.js";
|
|
7
|
+
import { ADAPTER_VERSION, configuredRecipients, readConfig, writeRecord } from "./vault.js";
|
|
8
|
+
import { uploadClientFromStore } from "./providers/index.js";
|
|
9
|
+
import { acquireSpoolLock, createSpool, findSpoolForSource, shredSpool, unwrapSpoolKey, updateSpool } from "./spool.js";
|
|
10
|
+
import { DEFAULT_CHUNK_BYTES, KEY_BYTES, StreamingSha256, sealChunksToSink, wrapAll, zeroize } from "../packages/vaultline-crypto/src/index.js";
|
|
11
|
+
import { frameObject } from "./cloud.js";
|
|
12
|
+
import { aeadCipher, aeadDecipher } from "../packages/vaultline-crypto/src/aead.js";
|
|
13
|
+
const SEAL_SUITE = "chacha20-poly1305";
|
|
14
|
+
const sha256hex = (input) => createHash("sha256").update(input).digest("hex");
|
|
15
|
+
export const chunkObjectName = (index) => `chunk-${String(index).padStart(6, "0")}`;
|
|
16
|
+
export const ENVELOPE_OBJECT = "envelope.vlmeta";
|
|
17
|
+
/** One path segment a person can read in a bucket console, made key-safe. */
|
|
18
|
+
function slug(raw, fallback) {
|
|
19
|
+
const cleaned = raw.trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "").slice(0, 60);
|
|
20
|
+
return cleaned || fallback;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The key the hashed naming mode HMACs segments under, derived from the
|
|
24
|
+
* recovery phrase and pinned to the vault.
|
|
25
|
+
*
|
|
26
|
+
* Paul's insight, made safe: hashing the segments keeps the folder STRUCTURE
|
|
27
|
+
* (same project → same folder, same day → same shelf, grouping and dedupe all
|
|
28
|
+
* work) while the names say nothing. But a bare hash says everything to
|
|
29
|
+
* anyone willing to guess: project names are dictionary words and dates are
|
|
30
|
+
* a 365-entry alphabet per year — sha256("2026-08-17") reverses instantly.
|
|
31
|
+
* So the segments are HMAC-SHA256 under a phrase-derived key: without the
|
|
32
|
+
* phrase the folder names are noise; with it, any of the vault's machines
|
|
33
|
+
* derives the same key and the same grouping.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Scoped to the STORAGE, not the vault instance: two machines signed into the
|
|
37
|
+
* same bucket with the same phrase are one namespace, and vaultId — which is
|
|
38
|
+
* minted per machine — would scatter them into folders neither can predict.
|
|
39
|
+
* The test that caught this had machine B hash the index segment differently
|
|
40
|
+
* from machine A and find nothing where A had pushed everything.
|
|
41
|
+
*/
|
|
42
|
+
export function folderNamingKey(phrase, storageScope) {
|
|
43
|
+
return Buffer.from(hkdfSync("sha256", Buffer.from(phrase, "utf8"), Buffer.from(`vaultline-folder-names:${storageScope}`, "utf8"), "folder-naming-v1", 32));
|
|
44
|
+
}
|
|
45
|
+
/** The one canonical spelling of a storage's namespace scope. */
|
|
46
|
+
export function storageScopeOf(remoteStorage) {
|
|
47
|
+
return `${remoteStorage.provider}://${remoteStorage.bucket}`;
|
|
48
|
+
}
|
|
49
|
+
function hashedSegment(key, kind, value) {
|
|
50
|
+
return createHmac("sha256", key).update(`${kind}:${value}`).digest("hex").slice(0, 16);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The folder for one archive: project / day / session, each segment either
|
|
54
|
+
* readable (your own bucket, browsable in the provider console) or an HMAC
|
|
55
|
+
* (structure without meaning — what managed storage will use). The archive-id
|
|
56
|
+
* suffix keeps two same-named sessions, and two snapshots of one session,
|
|
57
|
+
* from colliding.
|
|
58
|
+
*/
|
|
59
|
+
export function chunkFolderFor(input) {
|
|
60
|
+
const prefix = input.configuredPrefix.replace(/^\/+|\/+$/g, "");
|
|
61
|
+
const day = input.createdAt.slice(0, 10);
|
|
62
|
+
const sessionName = input.sourcePath.split("/").pop() ?? "";
|
|
63
|
+
if (input.naming === "hashed") {
|
|
64
|
+
const key = input.namingKey ?? fail("invalid_argument", "Hashed folder naming needs the vault's naming key");
|
|
65
|
+
return `${prefix}/${hashedSegment(key, "project", input.project ?? "")}/${hashedSegment(key, "date", day)}/${hashedSegment(key, "session", sessionName)}.${input.archiveId.slice(0, 8)}`;
|
|
66
|
+
}
|
|
67
|
+
return `${prefix}/${slug(input.project ?? "", "no-project")}/${day}/${slug(sessionName, "session")}.${input.archiveId.slice(0, 8)}`;
|
|
68
|
+
}
|
|
69
|
+
async function clientFor(dataDir, target) {
|
|
70
|
+
const config = await readConfig(dataDir);
|
|
71
|
+
// Managed-ness follows the same rule as everything else (storedCopiesMode):
|
|
72
|
+
// an own bucket wins when configured; otherwise a vault signed into the
|
|
73
|
+
// cloud account IS managed — enrolled vaults carry the token, not a
|
|
74
|
+
// remoteStorage entry. The live drill on a real vault caught the gap:
|
|
75
|
+
// gating on config.remoteStorage alone meant no production managed vault
|
|
76
|
+
// could ever stream. A routed TARGET (multi-target vaults) overrides both.
|
|
77
|
+
const remoteStorage = (target ? { provider: target.provider, bucket: target.bucket ?? "vaultline-managed", prefix: target.prefix ?? "vaultline", region: target.region } : undefined)
|
|
78
|
+
?? config.remoteStorage
|
|
79
|
+
?? await (async () => {
|
|
80
|
+
const { cloudToken } = await import("./cloud.js");
|
|
81
|
+
try {
|
|
82
|
+
await cloudToken(dataDir);
|
|
83
|
+
return { provider: "vaultline", bucket: "vaultline-managed", prefix: "" };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return fail("storage_not_configured", "No remote target configured. Run: sealkeep storage configure ... (or sign in to Sealkeep Cloud)");
|
|
87
|
+
}
|
|
88
|
+
})();
|
|
89
|
+
if (remoteStorage.provider === "vaultline") {
|
|
90
|
+
// Managed storage needs no chunk support: the client already chunks, and
|
|
91
|
+
// each sealed chunk is an ordinary known-length file delivered through the
|
|
92
|
+
// ordinary upload lease (see managed-chunks.ts). The folder is the archive
|
|
93
|
+
// id — opaque to the plane, no project or date anywhere — and the readable
|
|
94
|
+
// hierarchy lives in the sealed sidecar, exactly as on an own bucket.
|
|
95
|
+
const { managedChunkClient } = await import("./managed-chunks.js");
|
|
96
|
+
return { client: managedChunkClient(dataDir), remoteStorage: { provider: "vaultline", bucket: "vaultline-managed", prefix: "" } };
|
|
97
|
+
}
|
|
98
|
+
const raw = await uploadClientFromStore(dataDir, config.vaultId, remoteStorage);
|
|
99
|
+
const client = raw;
|
|
100
|
+
if (typeof client.putObject !== "function" || typeof client.headObject !== "function" || typeof client.getObject !== "function" || typeof client.deleteObject !== "function") {
|
|
101
|
+
fail("invalid_argument", `The ${remoteStorage.provider} client cannot store chunk folders`);
|
|
102
|
+
}
|
|
103
|
+
return { client: client, remoteStorage };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Seals a source straight into a chunk folder: read a slice, seal it, PUT it
|
|
107
|
+
* as its own object, journal it, forget it. Peak local cost is one chunk.
|
|
108
|
+
* A journal for the source resumes the same folder — already-present chunks
|
|
109
|
+
* are re-sealed locally and verified against both the journal's recorded hash
|
|
110
|
+
* AND the provider's stored checksum, then skipped.
|
|
111
|
+
*/
|
|
112
|
+
export async function sealToChunkFolder(dataDir, sourcePath, rawPhrase, agent, options = {}) {
|
|
113
|
+
const config = await readConfig(dataDir);
|
|
114
|
+
if (config.storageMode === "plain") {
|
|
115
|
+
fail("invalid_argument", "This vault stores archives as plain readable files; chunking them into a bucket would upload your transcripts unencrypted. Use a sealed vault for cloud archiving.");
|
|
116
|
+
}
|
|
117
|
+
// Routing: which of the vault's targets takes this seal. Project pins and
|
|
118
|
+
// byte limits are the rules; chunk streaming excludes Drive (single-object
|
|
119
|
+
// by design). Injected clients (tests) bypass routing entirely.
|
|
120
|
+
let routedTarget;
|
|
121
|
+
if (!options.client) {
|
|
122
|
+
const preSize = await stat(resolve(sourcePath)).then((info) => info.size).catch(() => 0);
|
|
123
|
+
const { chooseTarget } = await import("./storage-targets.js");
|
|
124
|
+
routedTarget = await chooseTarget(dataDir, { project: options.project ?? null, bytes: preSize, requireChunkCapable: true, targetId: options.targetId });
|
|
125
|
+
}
|
|
126
|
+
const bundled = options.client ? null : await clientFor(dataDir, routedTarget);
|
|
127
|
+
const remoteStorage = bundled?.remoteStorage ?? config.remoteStorage ?? { provider: "vaultline", bucket: "vaultline-managed", prefix: "" };
|
|
128
|
+
const client = options.client ?? bundled.client;
|
|
129
|
+
const absolute = resolve(sourcePath);
|
|
130
|
+
const source = await stat(absolute).catch(() => null);
|
|
131
|
+
if (!source || !source.isFile())
|
|
132
|
+
fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
|
|
133
|
+
const phrase = canonicalPhrase(rawPhrase);
|
|
134
|
+
const totalBytes = source.size;
|
|
135
|
+
const chunkBytes = DEFAULT_CHUNK_BYTES;
|
|
136
|
+
const totalChunks = Math.max(1, Math.ceil(totalBytes / chunkBytes));
|
|
137
|
+
// Journal triage, chunk-layout edition. Same drain-the-stack shape as the
|
|
138
|
+
// streaming path: resume the newest matching journal or abandon it with the
|
|
139
|
+
// reason and look again.
|
|
140
|
+
for (;;) {
|
|
141
|
+
const journal = await findSpoolForSource(dataDir, absolute);
|
|
142
|
+
if (!journal)
|
|
143
|
+
break;
|
|
144
|
+
if (journal.providerState !== null && !isChunkFolderState(journal.providerState)) {
|
|
145
|
+
// A single-object streaming journal for this source belongs to the other
|
|
146
|
+
// pipeline; leave it alone and let that pipeline (or --fresh) decide.
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
const abandon = async (reason) => {
|
|
150
|
+
await abandonFolderJournal(dataDir, client, journal, reason);
|
|
151
|
+
};
|
|
152
|
+
if (options.resume === false) {
|
|
153
|
+
await abandon("a fresh start was requested (--fresh)");
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (journal.provider !== remoteStorage.provider || journal.bucket !== remoteStorage.bucket || journal.source.totalBytes !== totalBytes) {
|
|
157
|
+
await abandon("the source file or the storage target is no longer the one the journal describes");
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const lock = await acquireSpoolLock(dataDir, journal.archiveId, { now: options.now });
|
|
161
|
+
try {
|
|
162
|
+
return await resumeChunkFolder({ dataDir, client, remoteStorage, targetId: routedTarget?.id, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, now: options.now }, journal);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (!(error instanceof Error) || !/cannot resume/i.test(error.message))
|
|
166
|
+
throw error;
|
|
167
|
+
await abandon(error.message);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
await lock.release();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Fresh seal.
|
|
175
|
+
const archiveId = randomUUID();
|
|
176
|
+
const createdAt = new Date(options.now ?? Date.now()).toISOString();
|
|
177
|
+
// Sealed vault ⇒ unreadable bucket, everywhere. Sealing the CONTENT while
|
|
178
|
+
// labelling it project/date/session.jsonl would hand every storage provider
|
|
179
|
+
// the story of the work; the readable hierarchy is the dashboard's job,
|
|
180
|
+
// built from local records — and from the encrypted identity in each
|
|
181
|
+
// folder's sidecar when only the bucket survives. `remoteNaming:
|
|
182
|
+
// "readable"` in config.json is the explicit opt-out for people who want
|
|
183
|
+
// their own bucket browsable and accept what that says.
|
|
184
|
+
// Managed vaults skip the naming machinery entirely: the folder is the bare
|
|
185
|
+
// archive id, so the plane's references (`<id>.chunk-000000`) carry nothing
|
|
186
|
+
// but the grouping it could always see. Own buckets keep the hashed (or
|
|
187
|
+
// opted-in readable) project/date/session hierarchy.
|
|
188
|
+
const naming = config.remoteNaming ?? "hashed";
|
|
189
|
+
const folder = remoteStorage.provider === "vaultline" ? archiveId : chunkFolderFor({
|
|
190
|
+
configuredPrefix: remoteStorage.prefix, project: options.project ?? null, createdAt, sourcePath: absolute, archiveId,
|
|
191
|
+
naming, ...(naming === "hashed" ? { namingKey: folderNamingKey(phrase, storageScopeOf(remoteStorage)) } : {})
|
|
192
|
+
});
|
|
193
|
+
const recipients = configuredRecipients(config, phrase);
|
|
194
|
+
const archiveKey = randomBytes(KEY_BYTES);
|
|
195
|
+
const noncePrefix = randomBytes(4);
|
|
196
|
+
const lock = await acquireSpoolLock(dataDir, archiveId, { now: options.now });
|
|
197
|
+
try {
|
|
198
|
+
await createSpool(dataDir, {
|
|
199
|
+
archiveId, suite: SEAL_SUITE, provider: remoteStorage.provider, bucket: remoteStorage.bucket, objectKey: folder,
|
|
200
|
+
source: { path: absolute, totalBytes, chunkBytes },
|
|
201
|
+
wrappedKeys: wrapAll(archiveKey, recipients, SEAL_SUITE, archiveId),
|
|
202
|
+
noncePrefixB64: noncePrefix.toString("base64"),
|
|
203
|
+
nextChunkIndex: 0,
|
|
204
|
+
consumedPrefixSha256: createHash("sha256").digest("hex"),
|
|
205
|
+
chunkHeaders: [],
|
|
206
|
+
hashState: { plaintext: "", stored: "" },
|
|
207
|
+
providerState: { kind: "chunk-folder", folder, uploaded: 0 }
|
|
208
|
+
}, { now: options.now });
|
|
209
|
+
return await runChunkSeal({ dataDir, client, remoteStorage, targetId: routedTarget?.id, absolute, totalBytes, chunkBytes, totalChunks, phrase, agent, now: options.now }, { archiveId, createdAt, folder, archiveKey, noncePrefix, project: options.project ?? null, startAt: 0, verifiedHeaders: [], reusedChunks: 0 });
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
zeroize(archiveKey);
|
|
213
|
+
await lock.release();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function isChunkFolderState(state) {
|
|
217
|
+
return typeof state === "object" && state !== null && state.kind === "chunk-folder";
|
|
218
|
+
}
|
|
219
|
+
async function abandonFolderJournal(dataDir, client, journal, reason) {
|
|
220
|
+
// Best-effort folder cleanup: every chunk the journal ever named, plus the
|
|
221
|
+
// sidecar. Deterministic names make the sweep exact — no listing needed.
|
|
222
|
+
for (let index = 0; index < journal.chunkHeaders.length; index += 1) {
|
|
223
|
+
await client.deleteObject(`${journal.objectKey}/${chunkObjectName(index)}`).catch(() => undefined);
|
|
224
|
+
}
|
|
225
|
+
await client.deleteObject(`${journal.objectKey}/${ENVELOPE_OBJECT}`).catch(() => undefined);
|
|
226
|
+
await recordAudit(dataDir, "upload.verify", "denied", { archiveId: journal.archiveId, objectKey: journal.objectKey, resumeAbandoned: reason });
|
|
227
|
+
await shredSpool(dataDir, journal.archiveId);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The one seal loop both fresh and resumed paths run. `startAt` chunks are
|
|
231
|
+
* ALREADY verified present in the folder (the resume proved them); the seal
|
|
232
|
+
* re-produces them CPU-only to advance the whole-object hashes, verifies each
|
|
233
|
+
* against the journal, and PUTs only what the bucket lacks.
|
|
234
|
+
*/
|
|
235
|
+
async function runChunkSeal(ctx, args) {
|
|
236
|
+
const config = await readConfig(ctx.dataDir);
|
|
237
|
+
const recipients = configuredRecipients(config, ctx.phrase);
|
|
238
|
+
const { tokenize, indexableToken, tokenCapFor } = await import("./search.js");
|
|
239
|
+
const perChunkTokenCap = Math.max(4000, Math.floor(tokenCapFor(ctx.totalBytes) / 4));
|
|
240
|
+
const hashers = { plaintext: new StreamingSha256(), stored: new StreamingSha256() };
|
|
241
|
+
const headers = [];
|
|
242
|
+
let storedBytes = 0;
|
|
243
|
+
let heldAtMostBytes = 0;
|
|
244
|
+
// Uploads ride a small pool: the seal loop stays sequential (it is the CPU
|
|
245
|
+
// and the nonce discipline), but a sealed chunk does not wait for the wire
|
|
246
|
+
// before the next one seals. Four in flight bounds peak memory at four
|
|
247
|
+
// chunks and roughly quarters the wall clock of a lease-per-chunk upload.
|
|
248
|
+
const UPLOAD_CONCURRENCY = 4;
|
|
249
|
+
const pending = new Map();
|
|
250
|
+
const completedUploads = new Set();
|
|
251
|
+
let watermark = args.startAt;
|
|
252
|
+
let uploadError = null;
|
|
253
|
+
let inFlightBytes = 0;
|
|
254
|
+
// Journal writes are serialized: two concurrent read-modify-write updates
|
|
255
|
+
// of the spool would lose one of them.
|
|
256
|
+
let spoolChain = Promise.resolve();
|
|
257
|
+
const journal = (patch) => {
|
|
258
|
+
const next = spoolChain.then(async () => { await updateSpool(ctx.dataDir, args.archiveId, patch); });
|
|
259
|
+
spoolChain = next.catch(() => undefined);
|
|
260
|
+
return next;
|
|
261
|
+
};
|
|
262
|
+
// Seal-time indexing: the seal holds every plaintext slice anyway, so the
|
|
263
|
+
// tokens are collected here for free — the alternative was uploading the
|
|
264
|
+
// archive and then downloading it back just to read it again.
|
|
265
|
+
const tokensByChunk = new Map();
|
|
266
|
+
const sink = {
|
|
267
|
+
write: async (stored) => {
|
|
268
|
+
const index = headers.length;
|
|
269
|
+
const header = {
|
|
270
|
+
index,
|
|
271
|
+
plaintextBytes: stored.length - 16,
|
|
272
|
+
rawBytes: Math.max(0, Math.min(ctx.chunkBytes, ctx.totalBytes - index * ctx.chunkBytes)),
|
|
273
|
+
storedBytes: stored.length,
|
|
274
|
+
sha256: sha256hex(stored),
|
|
275
|
+
nonce: "", // filled from the seal's own envelope below; the journal needs only the hash
|
|
276
|
+
final: index === ctx.totalChunks - 1
|
|
277
|
+
};
|
|
278
|
+
// The stored-bytes hash belongs to sealChunksToSink: it updates the
|
|
279
|
+
// injected hasher once per chunk. Updating it here as well was the bug
|
|
280
|
+
// that had every chunk-folder record carry a double-hashed
|
|
281
|
+
// ciphertextSha256 — sealing verified, and every later RESTORE refused
|
|
282
|
+
// the perfectly good bytes as "not the archive that was stored there".
|
|
283
|
+
storedBytes += stored.length;
|
|
284
|
+
if (index < args.startAt) {
|
|
285
|
+
// Already in the bucket, already verified: this re-seal exists only to
|
|
286
|
+
// advance the hashes. Refusing on any drift is what keeps the resume
|
|
287
|
+
// on the safe side of the nonce invariant.
|
|
288
|
+
if (header.sha256 !== args.verifiedHeaders[index].sha256) {
|
|
289
|
+
fail("invalid_argument", `Cannot resume the interrupted upload of ${args.folder}: re-sealing chunk ${index} did not reproduce the bytes the bucket already holds. The archive starts over from byte zero with a fresh key.`, { archiveId: args.archiveId });
|
|
290
|
+
}
|
|
291
|
+
headers.push(args.verifiedHeaders[index]);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// Emission-first: the journal names the chunk BEFORE its PUT can land,
|
|
295
|
+
// so the bucket can never hold a chunk the journal cannot vouch for.
|
|
296
|
+
headers.push(header);
|
|
297
|
+
await journal({ chunkHeaders: [...headers] });
|
|
298
|
+
if (uploadError)
|
|
299
|
+
throw uploadError;
|
|
300
|
+
while (pending.size >= UPLOAD_CONCURRENCY) {
|
|
301
|
+
await Promise.race(pending.values());
|
|
302
|
+
if (uploadError)
|
|
303
|
+
throw uploadError;
|
|
304
|
+
}
|
|
305
|
+
inFlightBytes += stored.length;
|
|
306
|
+
heldAtMostBytes = Math.max(heldAtMostBytes, inFlightBytes);
|
|
307
|
+
const task = (async () => {
|
|
308
|
+
await ctx.client.putObject(`${args.folder}/${chunkObjectName(index)}`, stored);
|
|
309
|
+
completedUploads.add(index);
|
|
310
|
+
while (completedUploads.has(watermark))
|
|
311
|
+
watermark += 1;
|
|
312
|
+
await journal({
|
|
313
|
+
nextChunkIndex: watermark,
|
|
314
|
+
consumedPrefixSha256: hashers.plaintext.digestHex(),
|
|
315
|
+
providerState: { kind: "chunk-folder", folder: args.folder, uploaded: watermark }
|
|
316
|
+
});
|
|
317
|
+
})().catch((error) => { uploadError = uploadError ?? error; }).finally(() => {
|
|
318
|
+
inFlightBytes -= stored.length;
|
|
319
|
+
pending.delete(index);
|
|
320
|
+
});
|
|
321
|
+
pending.set(index, task);
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
const sealed = await sealChunksToSink({ path: ctx.absolute, end: ctx.totalBytes }, sink, {
|
|
325
|
+
recipients, archiveId: args.archiveId, adapter: { agent: ctx.agent, version: ADAPTER_VERSION },
|
|
326
|
+
suite: SEAL_SUITE, chunkBytes: ctx.chunkBytes, archiveKey: args.archiveKey, noncePrefix: args.noncePrefix,
|
|
327
|
+
hashers, createdAt: args.createdAt,
|
|
328
|
+
onPlaintext: (chunkIndex, slice) => {
|
|
329
|
+
let bucket = tokensByChunk.get(chunkIndex);
|
|
330
|
+
if (!bucket) {
|
|
331
|
+
bucket = new Set();
|
|
332
|
+
tokensByChunk.set(chunkIndex, bucket);
|
|
333
|
+
}
|
|
334
|
+
// Filtered and budgeted at collection: junk (hex digests, base64
|
|
335
|
+
// runs) never takes a slot, and the budget scales with the archive —
|
|
336
|
+
// the flat 4000 starved everything past chunk zero of a 1.7 GB seal.
|
|
337
|
+
for (const token of tokenize(slice.toString("utf8"))) {
|
|
338
|
+
if (bucket.size >= perChunkTokenCap)
|
|
339
|
+
break;
|
|
340
|
+
if (!indexableToken(token))
|
|
341
|
+
continue;
|
|
342
|
+
bucket.add(token);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
// Drain the pool before anything downstream trusts the folder.
|
|
347
|
+
while (pending.size > 0)
|
|
348
|
+
await Promise.race(pending.values());
|
|
349
|
+
await spoolChain;
|
|
350
|
+
if (uploadError)
|
|
351
|
+
throw uploadError;
|
|
352
|
+
// The sidecar: the envelope framed with a sealed identity blob. Folder
|
|
353
|
+
// names are opaque on purpose, so the names live HERE — project, source
|
|
354
|
+
// path, date — encrypted under the archive key. A machine holding only the
|
|
355
|
+
// bucket and the phrase lists the folders, opens each sidecar, and gets the
|
|
356
|
+
// readable hierarchy back; a provider holding the bucket alone gets noise.
|
|
357
|
+
const envelope = sealed.envelope;
|
|
358
|
+
const identity = sealIdentity(args.archiveKey, args.archiveId, {
|
|
359
|
+
v: 1, project: args.project, sourcePath: ctx.absolute, agent: ctx.agent, createdAt: args.createdAt
|
|
360
|
+
});
|
|
361
|
+
await ctx.client.putObject(`${args.folder}/${ENVELOPE_OBJECT}`, frameObject(envelope, identity));
|
|
362
|
+
// Verification, per chunk, in the provider's own answers: every chunk
|
|
363
|
+
// present at its recorded size. Byte-level trust is the chunk hash in the
|
|
364
|
+
// envelope — restore proves it, AEAD enforces it.
|
|
365
|
+
const problems = [];
|
|
366
|
+
for (let index = 0; index < envelope.chunks.length; index += 1) {
|
|
367
|
+
const head = await ctx.client.headObject(`${args.folder}/${chunkObjectName(index)}`);
|
|
368
|
+
if (!head.exists)
|
|
369
|
+
problems.push(`chunk ${index} is not in the bucket`);
|
|
370
|
+
else if (head.bytes !== envelope.chunks[index].storedBytes)
|
|
371
|
+
problems.push(`chunk ${index} is ${head.bytes} bytes; ${envelope.chunks[index].storedBytes} were sent`);
|
|
372
|
+
if (problems.length >= 3)
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
if (problems.length > 0) {
|
|
376
|
+
await recordAudit(ctx.dataDir, "upload.verify", "denied", { archiveId: args.archiveId, objectKey: args.folder, problems: problems.length });
|
|
377
|
+
fail("ciphertext_integrity_failed", `Refusing to record the chunked archive as durable: ${problems.join("; ")}. The journal was kept; re-running resumes what is already there.`, { archiveId: args.archiveId, objectKey: args.folder });
|
|
378
|
+
}
|
|
379
|
+
const now = new Date(ctx.now ?? Date.now()).toISOString();
|
|
380
|
+
const remote = {
|
|
381
|
+
provider: ctx.remoteStorage.provider, bucket: ctx.remoteStorage.bucket, objectKey: args.folder,
|
|
382
|
+
bytes: sealed.storedBytes, checksum: hashers.stored.digestBase64(), verifiedAt: now,
|
|
383
|
+
layout: { kind: "chunks", chunkCount: envelope.chunks.length },
|
|
384
|
+
...(ctx.targetId ? { targetId: ctx.targetId } : {})
|
|
385
|
+
};
|
|
386
|
+
const record = {
|
|
387
|
+
version: 2, id: args.archiveId, createdAt: envelope.manifest.createdAt,
|
|
388
|
+
source: { path: ctx.absolute, agent: ctx.agent, bytes: ctx.totalBytes, sha256: sealed.originalSha256 },
|
|
389
|
+
cipher: { algorithm: envelope.suite, ciphertextSha256: sealed.ciphertextSha256, storedBytes: sealed.storedBytes, chunks: envelope.chunks.length },
|
|
390
|
+
envelope,
|
|
391
|
+
objectPath: join(config.storage.root, `${args.archiveId}.vlarchive`),
|
|
392
|
+
remote,
|
|
393
|
+
offloaded: { at: now, provider: ctx.remoteStorage.provider, bucket: ctx.remoteStorage.bucket, objectKey: args.folder }
|
|
394
|
+
};
|
|
395
|
+
await writeRecord(config.storage.root, record);
|
|
396
|
+
await recordAudit(ctx.dataDir, "archive.create", "allowed", { archiveId: args.archiveId, agent: ctx.agent, bytes: ctx.totalBytes, recipients: envelope.wrappedKeys.length, chunked: true, reused: args.reusedChunks });
|
|
397
|
+
await recordAudit(ctx.dataDir, "upload.verify", "allowed", { archiveId: args.archiveId, objectKey: args.folder, bytes: sealed.storedBytes, provider: ctx.remoteStorage.provider, chunked: true });
|
|
398
|
+
await shredSpool(ctx.dataDir, args.archiveId);
|
|
399
|
+
// The tokens collected during the seal become the archive's index entry —
|
|
400
|
+
// search knows this session the moment the seal returns, and no machine
|
|
401
|
+
// ever downloads it back just to read it. Advisory by design: an index
|
|
402
|
+
// hiccup must never turn a durable archive into a failure.
|
|
403
|
+
let indexed = false;
|
|
404
|
+
try {
|
|
405
|
+
const { indexArchiveTokens } = await import("./search.js");
|
|
406
|
+
await indexArchiveTokens(ctx.dataDir, ctx.phrase, record, tokensByChunk);
|
|
407
|
+
indexed = true;
|
|
408
|
+
}
|
|
409
|
+
catch { /* the next `index build` re-indexes this archive */ }
|
|
410
|
+
return { record, folder: args.folder, storedBytes: sealed.storedBytes, chunkCount: envelope.chunks.length, reusedChunks: args.reusedChunks, heldAtMostBytes, indexed };
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Resume: the provider's truth is asked chunk by chunk — HEAD chunk-000000
|
|
414
|
+
* upward until the first absence — and every present chunk must match its
|
|
415
|
+
* journaled header's size and hash (hash proven by the re-seal inside
|
|
416
|
+
* `runChunkSeal`; size and existence proven here, against the bucket).
|
|
417
|
+
*/
|
|
418
|
+
async function resumeChunkFolder(ctx, journal) {
|
|
419
|
+
const refuse = (why) => fail("invalid_argument", `Cannot resume the interrupted upload of ${journal.objectKey}: ${why}. The archive starts over from byte zero with a fresh key.`, { archiveId: journal.archiveId });
|
|
420
|
+
if (journal.source.chunkBytes !== ctx.chunkBytes)
|
|
421
|
+
return refuse("the chunk size changed between builds");
|
|
422
|
+
if (journal.chunkHeaders.length > ctx.totalChunks)
|
|
423
|
+
return refuse("the journal names more chunks than this archive has");
|
|
424
|
+
const noncePrefix = Buffer.from(journal.noncePrefixB64, "base64");
|
|
425
|
+
if (noncePrefix.length !== 4)
|
|
426
|
+
return refuse("its nonce prefix is malformed");
|
|
427
|
+
// The provider's truth: contiguous presence from zero. A gap ends the
|
|
428
|
+
// verified range — chunks past a gap are unreachable by a contiguous
|
|
429
|
+
// restore anyway, and re-PUTting a verified re-seal of chunk k is
|
|
430
|
+
// invariant-safe (identical bytes under the same nonce).
|
|
431
|
+
let present = 0;
|
|
432
|
+
const verifiedHeaders = [];
|
|
433
|
+
while (present < journal.chunkHeaders.length) {
|
|
434
|
+
const head = await ctx.client.headObject(`${journal.objectKey}/${chunkObjectName(present)}`);
|
|
435
|
+
if (!head.exists)
|
|
436
|
+
break;
|
|
437
|
+
if (head.bytes !== journal.chunkHeaders[present].storedBytes) {
|
|
438
|
+
return refuse(`the bucket's chunk ${present} is ${head.bytes} bytes but the journal recorded ${journal.chunkHeaders[present].storedBytes} — the folder holds something this vault did not put there`);
|
|
439
|
+
}
|
|
440
|
+
verifiedHeaders.push(journal.chunkHeaders[present]);
|
|
441
|
+
present += 1;
|
|
442
|
+
}
|
|
443
|
+
const archiveKey = unwrapSpoolKey(journal, { phrase: ctx.phrase });
|
|
444
|
+
try {
|
|
445
|
+
await recordAudit(ctx.dataDir, "upload.verify", "allowed", { archiveId: journal.archiveId, objectKey: journal.objectKey, resumedAtChunk: present });
|
|
446
|
+
return await runChunkSeal(ctx, {
|
|
447
|
+
archiveId: journal.archiveId, createdAt: journal.createdAt, folder: journal.objectKey,
|
|
448
|
+
archiveKey, noncePrefix, project: null, startAt: present, verifiedHeaders, reusedChunks: present
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
zeroize(archiveKey);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function sealIdentity(archiveKey, archiveId, identity) {
|
|
456
|
+
const nonce = randomBytes(12);
|
|
457
|
+
const cipher = aeadCipher(SEAL_SUITE, archiveKey, nonce);
|
|
458
|
+
cipher.setAAD(Buffer.from(`vaultline-folder-identity:${archiveId}`, "utf8"));
|
|
459
|
+
const body = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(identity), "utf8")), cipher.final(), cipher.getAuthTag()]);
|
|
460
|
+
return Buffer.concat([nonce, body]);
|
|
461
|
+
}
|
|
462
|
+
/** Opens a sidecar's identity with the unwrapped archive key. */
|
|
463
|
+
export function openIdentity(archiveKey, archiveId, blob) {
|
|
464
|
+
if (blob.length < 12 + 16)
|
|
465
|
+
fail("invalid_argument", "This sidecar carries no identity");
|
|
466
|
+
const nonce = blob.subarray(0, 12);
|
|
467
|
+
const body = blob.subarray(12);
|
|
468
|
+
const decipher = aeadDecipher(SEAL_SUITE, archiveKey, nonce);
|
|
469
|
+
decipher.setAAD(Buffer.from(`vaultline-folder-identity:${archiveId}`, "utf8"));
|
|
470
|
+
decipher.setAuthTag(body.subarray(body.length - 16));
|
|
471
|
+
try {
|
|
472
|
+
const plain = Buffer.concat([decipher.update(body.subarray(0, body.length - 16)), decipher.final()]);
|
|
473
|
+
return JSON.parse(plain.toString("utf8"));
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return fail("invalid_argument", "The sidecar's identity does not open for this archive — wrong key, or a sidecar moved between folders.");
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/** Fetches a chunked remote back into one ciphertext buffer, hash-checked per chunk. */
|
|
480
|
+
export async function fetchChunkFolder(client, remote, expected) {
|
|
481
|
+
if (remote.layout?.kind !== "chunks")
|
|
482
|
+
fail("invalid_argument", "This remote is not a chunk folder");
|
|
483
|
+
const parts = [];
|
|
484
|
+
for (let index = 0; index < remote.layout.chunkCount; index += 1) {
|
|
485
|
+
const body = await client.getObject(`${remote.objectKey}/${chunkObjectName(index)}`);
|
|
486
|
+
const header = expected.chunks[index];
|
|
487
|
+
if (header && sha256hex(body) !== header.sha256) {
|
|
488
|
+
fail("ciphertext_integrity_failed", `Chunk ${index} fetched from the bucket does not match its recorded hash`, { objectKey: remote.objectKey });
|
|
489
|
+
}
|
|
490
|
+
parts.push(body);
|
|
491
|
+
}
|
|
492
|
+
return Buffer.concat(parts);
|
|
493
|
+
}
|
|
494
|
+
/** Deletes a chunked remote: every chunk object plus the envelope sidecar. */
|
|
495
|
+
export async function deleteChunkFolder(client, remote) {
|
|
496
|
+
if (remote.layout?.kind !== "chunks")
|
|
497
|
+
fail("invalid_argument", "This remote is not a chunk folder");
|
|
498
|
+
for (let index = 0; index < remote.layout.chunkCount; index += 1) {
|
|
499
|
+
await client.deleteObject(`${remote.objectKey}/${chunkObjectName(index)}`);
|
|
500
|
+
}
|
|
501
|
+
await client.deleteObject(`${remote.objectKey}/${ENVELOPE_OBJECT}`);
|
|
502
|
+
}
|