sealkeep 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/ARCHITECTURE.md +201 -0
  2. package/CHANGELOG.md +218 -0
  3. package/CONTROL_PLANE.md +86 -0
  4. package/LICENSE +34 -0
  5. package/README.md +249 -0
  6. package/THIRD_PARTY.md +22 -0
  7. package/THREAT_MODEL.md +107 -0
  8. package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
  9. package/dist/packages/vaultline-crypto/src/aead.js +24 -0
  10. package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
  11. package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
  12. package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
  13. package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
  14. package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
  15. package/dist/packages/vaultline-crypto/src/format.js +43 -0
  16. package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
  17. package/dist/packages/vaultline-crypto/src/index.js +5 -0
  18. package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
  19. package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
  20. package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
  21. package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
  22. package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
  23. package/dist/packages/vaultline-crypto/src/stream.js +477 -0
  24. package/dist/site/index.html +1542 -0
  25. package/dist/site.zip +0 -0
  26. package/dist/src/activity.d.ts +22 -0
  27. package/dist/src/activity.js +52 -0
  28. package/dist/src/adapters.d.ts +212 -0
  29. package/dist/src/adapters.js +533 -0
  30. package/dist/src/audit.d.ts +24 -0
  31. package/dist/src/audit.js +41 -0
  32. package/dist/src/autopilot.d.ts +77 -0
  33. package/dist/src/autopilot.js +148 -0
  34. package/dist/src/bip39-wordlist.d.ts +15 -0
  35. package/dist/src/bip39-wordlist.js +272 -0
  36. package/dist/src/branding.d.ts +31 -0
  37. package/dist/src/branding.js +31 -0
  38. package/dist/src/chunk-store.d.ts +142 -0
  39. package/dist/src/chunk-store.js +502 -0
  40. package/dist/src/cli.d.ts +2 -0
  41. package/dist/src/cli.js +2035 -0
  42. package/dist/src/cloud.d.ts +434 -0
  43. package/dist/src/cloud.js +851 -0
  44. package/dist/src/control-plane/auth.d.ts +62 -0
  45. package/dist/src/control-plane/auth.js +123 -0
  46. package/dist/src/control-plane/server.d.ts +31 -0
  47. package/dist/src/control-plane/server.js +263 -0
  48. package/dist/src/control-plane/store.d.ts +101 -0
  49. package/dist/src/control-plane/store.js +82 -0
  50. package/dist/src/control-plane-cli.d.ts +2 -0
  51. package/dist/src/control-plane-cli.js +37 -0
  52. package/dist/src/control-plane-server.d.ts +10 -0
  53. package/dist/src/control-plane-server.js +11 -0
  54. package/dist/src/control-plane.d.ts +78 -0
  55. package/dist/src/control-plane.js +61 -0
  56. package/dist/src/crypto.d.ts +56 -0
  57. package/dist/src/crypto.js +132 -0
  58. package/dist/src/daemon.d.ts +52 -0
  59. package/dist/src/daemon.js +142 -0
  60. package/dist/src/dashboard-cli.d.ts +2 -0
  61. package/dist/src/dashboard-cli.js +20 -0
  62. package/dist/src/disk.d.ts +110 -0
  63. package/dist/src/disk.js +169 -0
  64. package/dist/src/doctor.d.ts +11 -0
  65. package/dist/src/doctor.js +198 -0
  66. package/dist/src/enroll.d.ts +27 -0
  67. package/dist/src/enroll.js +136 -0
  68. package/dist/src/errors.d.ts +26 -0
  69. package/dist/src/errors.js +23 -0
  70. package/dist/src/heartbeat.d.ts +89 -0
  71. package/dist/src/heartbeat.js +120 -0
  72. package/dist/src/index-sync.d.ts +53 -0
  73. package/dist/src/index-sync.js +147 -0
  74. package/dist/src/leakscan.d.ts +48 -0
  75. package/dist/src/leakscan.js +222 -0
  76. package/dist/src/local-api.d.ts +132 -0
  77. package/dist/src/local-api.js +1757 -0
  78. package/dist/src/managed-chunks.d.ts +55 -0
  79. package/dist/src/managed-chunks.js +108 -0
  80. package/dist/src/mcp-install.d.ts +52 -0
  81. package/dist/src/mcp-install.js +140 -0
  82. package/dist/src/mcp.d.ts +1 -0
  83. package/dist/src/mcp.js +59 -0
  84. package/dist/src/migrate.d.ts +35 -0
  85. package/dist/src/migrate.js +88 -0
  86. package/dist/src/mnemonic.d.ts +60 -0
  87. package/dist/src/mnemonic.js +134 -0
  88. package/dist/src/net.d.ts +2 -0
  89. package/dist/src/net.js +16 -0
  90. package/dist/src/notify.d.ts +46 -0
  91. package/dist/src/notify.js +84 -0
  92. package/dist/src/offload.d.ts +117 -0
  93. package/dist/src/offload.js +331 -0
  94. package/dist/src/onboarding.d.ts +10 -0
  95. package/dist/src/onboarding.js +44 -0
  96. package/dist/src/packages.d.ts +126 -0
  97. package/dist/src/packages.js +114 -0
  98. package/dist/src/passkey.d.ts +26 -0
  99. package/dist/src/passkey.js +54 -0
  100. package/dist/src/password-lock.d.ts +19 -0
  101. package/dist/src/password-lock.js +156 -0
  102. package/dist/src/paths.d.ts +9 -0
  103. package/dist/src/paths.js +24 -0
  104. package/dist/src/providers/gcs.d.ts +133 -0
  105. package/dist/src/providers/gcs.js +235 -0
  106. package/dist/src/providers/gdrive.d.ts +156 -0
  107. package/dist/src/providers/gdrive.js +335 -0
  108. package/dist/src/providers/index.d.ts +45 -0
  109. package/dist/src/providers/index.js +74 -0
  110. package/dist/src/providers/s3.d.ts +174 -0
  111. package/dist/src/providers/s3.js +345 -0
  112. package/dist/src/providers/sigv4.d.ts +78 -0
  113. package/dist/src/providers/sigv4.js +112 -0
  114. package/dist/src/queue.d.ts +185 -0
  115. package/dist/src/queue.js +286 -0
  116. package/dist/src/recovery.d.ts +40 -0
  117. package/dist/src/recovery.js +132 -0
  118. package/dist/src/rehydrate.d.ts +43 -0
  119. package/dist/src/rehydrate.js +66 -0
  120. package/dist/src/restore.d.ts +34 -0
  121. package/dist/src/restore.js +80 -0
  122. package/dist/src/retention.d.ts +251 -0
  123. package/dist/src/retention.js +446 -0
  124. package/dist/src/rotate.d.ts +47 -0
  125. package/dist/src/rotate.js +95 -0
  126. package/dist/src/search.d.ts +147 -0
  127. package/dist/src/search.js +677 -0
  128. package/dist/src/secrets.d.ts +86 -0
  129. package/dist/src/secrets.js +220 -0
  130. package/dist/src/service.d.ts +73 -0
  131. package/dist/src/service.js +197 -0
  132. package/dist/src/share.d.ts +34 -0
  133. package/dist/src/share.js +68 -0
  134. package/dist/src/spool.d.ts +97 -0
  135. package/dist/src/spool.js +213 -0
  136. package/dist/src/start-tui.d.ts +17 -0
  137. package/dist/src/start-tui.js +113 -0
  138. package/dist/src/start.d.ts +75 -0
  139. package/dist/src/start.js +101 -0
  140. package/dist/src/storage-setup.d.ts +49 -0
  141. package/dist/src/storage-setup.js +222 -0
  142. package/dist/src/storage-targets.d.ts +40 -0
  143. package/dist/src/storage-targets.js +147 -0
  144. package/dist/src/stream-to-cloud.d.ts +76 -0
  145. package/dist/src/stream-to-cloud.js +820 -0
  146. package/dist/src/sync-rules.d.ts +85 -0
  147. package/dist/src/sync-rules.js +125 -0
  148. package/dist/src/trash.d.ts +15 -0
  149. package/dist/src/trash.js +63 -0
  150. package/dist/src/tui.d.ts +18 -0
  151. package/dist/src/tui.js +179 -0
  152. package/dist/src/types.d.ts +191 -0
  153. package/dist/src/types.js +3 -0
  154. package/dist/src/ui-server.d.ts +187 -0
  155. package/dist/src/ui-server.js +293 -0
  156. package/dist/src/ui.d.ts +41 -0
  157. package/dist/src/ui.js +102 -0
  158. package/dist/src/update.d.ts +30 -0
  159. package/dist/src/update.js +56 -0
  160. package/dist/src/upload.d.ts +46 -0
  161. package/dist/src/upload.js +80 -0
  162. package/dist/src/vault.d.ts +208 -0
  163. package/dist/src/vault.js +812 -0
  164. package/dist/src/watcher.d.ts +34 -0
  165. package/dist/src/watcher.js +121 -0
  166. package/dist/src/worker.d.ts +52 -0
  167. package/dist/src/worker.js +190 -0
  168. package/package.json +65 -0
  169. package/web/app.js +1372 -0
  170. package/web/index.html +476 -0
  171. package/web/rail.js +308 -0
  172. package/web/retention.html +17 -0
  173. package/web/rules-view.js +249 -0
  174. package/web/sessions-view.js +448 -0
  175. package/web/sessions.html +17 -0
  176. package/web/setup-api.js +181 -0
  177. package/web/setup-logic.js +394 -0
  178. package/web/setup.html +419 -0
  179. package/web/setup.js +697 -0
  180. package/web/style.css +990 -0
@@ -0,0 +1,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
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};