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,26 @@
1
+ /**
2
+ * Stable machine-readable failure classes. Human-facing messages may change
3
+ * between releases; these codes are a contract for the CLI, local API, and MCP.
4
+ * Details are operational only: never place plaintext, phrases, or keys here.
5
+ */
6
+ export type VaultlineErrorCode = "invalid_argument" | "vault_not_initialized" | "vault_already_initialized" | "config_unsupported_version" | "recovery_phrase_missing" | "recovery_phrase_mismatch" | "archive_not_found" | "source_unreadable" | "destination_exists" | "destination_unwritable" | "path_not_permitted" | "native_restore_unavailable" | "ciphertext_integrity_failed" | "plaintext_integrity_failed" | "hook_payload_invalid" | "queue_job_not_found" | "queue_lease_invalid" | "queue_lease_expired" | "queue_job_exhausted" | "storage_not_configured" | "provider_unsupported" | "signer_not_configured" | "lease_expired" | "unauthorized" | "forbidden" | "internal";
7
+ export declare class VaultlineError extends Error {
8
+ readonly code: VaultlineErrorCode;
9
+ readonly details: Record<string, unknown>;
10
+ constructor(code: VaultlineErrorCode, message: string, details?: Record<string, unknown>);
11
+ toJSON(): {
12
+ error: {
13
+ code: VaultlineErrorCode;
14
+ message: string;
15
+ };
16
+ };
17
+ }
18
+ export declare function fail(code: VaultlineErrorCode, message: string, details?: Record<string, unknown>): never;
19
+ export declare function isVaultlineError(value: unknown): value is VaultlineError;
20
+ /** Safe wire/log shape. Unknown failures are never echoed with a stack. */
21
+ export declare function errorPayload(error: unknown): {
22
+ error: {
23
+ code: VaultlineErrorCode;
24
+ message: string;
25
+ };
26
+ };
@@ -0,0 +1,23 @@
1
+ export class VaultlineError extends Error {
2
+ code;
3
+ details;
4
+ constructor(code, message, details = {}) {
5
+ super(message);
6
+ this.name = "VaultlineError";
7
+ this.code = code;
8
+ this.details = details;
9
+ }
10
+ toJSON() { return { error: { code: this.code, message: this.message, ...this.details } }; }
11
+ }
12
+ export function fail(code, message, details) {
13
+ throw new VaultlineError(code, message, details);
14
+ }
15
+ export function isVaultlineError(value) {
16
+ return value instanceof VaultlineError;
17
+ }
18
+ /** Safe wire/log shape. Unknown failures are never echoed with a stack. */
19
+ export function errorPayload(error) {
20
+ if (isVaultlineError(error))
21
+ return error.toJSON();
22
+ return { error: { code: "internal", message: error instanceof Error ? error.message : "Unexpected error" } };
23
+ }
@@ -0,0 +1,89 @@
1
+ import type { UploadOptions, UploadOutcome } from "./upload.js";
2
+ import type { offloadArchives } from "./offload.js";
3
+ /**
4
+ * The background service's sign of life.
5
+ *
6
+ * A service you cannot see is one you cannot trust, especially one that moves your
7
+ * files. This is the single fact every surface reads to answer "is it running, and
8
+ * what did it just do" — the CLI, the local API, the dashboard, and any menu-bar
9
+ * shell built later all read this one file rather than inventing their own idea of
10
+ * liveness.
11
+ */
12
+ export type Heartbeat = {
13
+ version: 1;
14
+ pid: number;
15
+ startedAt: string;
16
+ lastTickAt: string;
17
+ /** Wall-clock gap the service aims for, so a reader can judge whether it is late. */
18
+ intervalMs: number;
19
+ totals: {
20
+ archived: number;
21
+ uploaded: number;
22
+ reclaimed: number;
23
+ failed: number;
24
+ };
25
+ last: {
26
+ archived: number;
27
+ uploaded: number;
28
+ reclaimed: number;
29
+ failed: number;
30
+ notes: string[];
31
+ };
32
+ paused: boolean;
33
+ watching: string[];
34
+ };
35
+ export declare const heartbeatPath: (dataDir: string) => string;
36
+ export declare function writeHeartbeat(dataDir: string, beat: Heartbeat): Promise<void>;
37
+ export declare function readHeartbeat(dataDir: string): Promise<Heartbeat | null>;
38
+ export type Liveness = {
39
+ state: "running" | "late" | "stopped" | "never-started";
40
+ /** Human-facing sentence. Same wording everywhere it appears. */
41
+ detail: string;
42
+ lastTickAt?: string;
43
+ secondsSinceTick?: number;
44
+ paused?: boolean;
45
+ totals?: Heartbeat["totals"];
46
+ };
47
+ /** True when a process with this id exists and we may signal it. */
48
+ export declare function processAlive(pid: number): boolean;
49
+ /**
50
+ * Judges liveness from the heartbeat alone, so it works across processes and
51
+ * reboots. A tick more than three intervals old counts as late rather than running:
52
+ * better to say "something is wrong" than to imply everything is fine.
53
+ */
54
+ export declare function liveness(beat: Heartbeat | null, now?: number, alive?: typeof processAlive): Liveness;
55
+ export type UploadOffloadOutcome = {
56
+ uploaded: UploadOutcome[];
57
+ failed: {
58
+ archiveId: string;
59
+ error: string;
60
+ }[];
61
+ skipped: number;
62
+ /** Archives whose local sealed blob was removed because the verify just landed. */
63
+ offloaded: string[];
64
+ /** Local bytes given back by offloading verified copies as the pass went. */
65
+ freedBytes: number;
66
+ };
67
+ /**
68
+ * The daemon's upload pass, finished properly: upload, verify, then offload.
69
+ *
70
+ * `uploadPending` alone leaves a sealed local copy of every archive it just
71
+ * proved the bucket holds — on the machines this product is for, that is the
72
+ * disk staying exactly as full as before. The dashboard's push already does
73
+ * the whole job (see `runUploads` in local-api.ts): the moment an archive's
74
+ * verify lands, its local blob is a duplicate, and offloading it right then
75
+ * lets a full disk breathe with each verify instead of never.
76
+ *
77
+ * Offload failure is tolerated per archive: the upload is still verified and
78
+ * recorded, and the next offload sweep gets another chance. `freshlyVerified`
79
+ * is what keeps this cheap — the verify happened milliseconds ago, so the
80
+ * usual fetch-and-hash re-proof would double the egress of every push.
81
+ *
82
+ * Integration note: src/daemon.ts's tick still calls bare `uploadPending`;
83
+ * switching it to this function is the remaining wiring (daemon.ts is outside
84
+ * this change's file list).
85
+ */
86
+ export declare function uploadAndOffloadPending(dataDir: string, options?: UploadOptions & {
87
+ offload?: typeof offloadArchives;
88
+ }): Promise<UploadOffloadOutcome>;
89
+ export declare function describeGap(seconds: number): string;
@@ -0,0 +1,120 @@
1
+ import { readFile, rename, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+ export const heartbeatPath = (dataDir) => join(dataDir, "daemon-state.json");
5
+ export async function writeHeartbeat(dataDir, beat) {
6
+ const path = heartbeatPath(dataDir);
7
+ const temp = `${path}.${randomUUID()}.tmp`;
8
+ await writeFile(temp, JSON.stringify(beat, null, 2) + "\n", { mode: 0o600 });
9
+ await rename(temp, path);
10
+ }
11
+ export async function readHeartbeat(dataDir) {
12
+ try {
13
+ return JSON.parse(await readFile(heartbeatPath(dataDir), "utf8"));
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ /** True when a process with this id exists and we may signal it. */
20
+ export function processAlive(pid) {
21
+ try {
22
+ process.kill(pid, 0);
23
+ return true;
24
+ }
25
+ catch (error) {
26
+ return error.code === "EPERM";
27
+ }
28
+ }
29
+ /**
30
+ * Judges liveness from the heartbeat alone, so it works across processes and
31
+ * reboots. A tick more than three intervals old counts as late rather than running:
32
+ * better to say "something is wrong" than to imply everything is fine.
33
+ */
34
+ export function liveness(beat, now = Date.now(), alive = processAlive) {
35
+ // Phrased as a fact rather than an instruction, because this string is read
36
+ // in a browser as often as in a terminal, and "never started" is not a
37
+ // problem to fix — archiving by hand from the Sessions page is a complete way
38
+ // to use this. The command is offered as the alternative it is.
39
+ if (!beat)
40
+ return { state: "never-started", detail: "Automatic archiving is off — Sealkeep only archives what you choose. Turn it on in Settings." };
41
+ const secondsSinceTick = Math.max(0, Math.round((now - Date.parse(beat.lastTickAt)) / 1000));
42
+ const late = now - Date.parse(beat.lastTickAt) > Math.max(beat.intervalMs * 3, 90_000);
43
+ const running = alive(beat.pid);
44
+ if (!running) {
45
+ return { state: "stopped", detail: `Not running. It last checked ${describeGap(secondsSinceTick)}.`, lastTickAt: beat.lastTickAt, secondsSinceTick, paused: beat.paused, totals: beat.totals };
46
+ }
47
+ if (late) {
48
+ return { state: "late", detail: `Running, but its last check was ${describeGap(secondsSinceTick)} — longer than expected.`, lastTickAt: beat.lastTickAt, secondsSinceTick, paused: beat.paused, totals: beat.totals };
49
+ }
50
+ return {
51
+ state: "running",
52
+ detail: beat.paused ? "Running but paused; no work is being done." : `Running. Last checked ${describeGap(secondsSinceTick)}.`,
53
+ lastTickAt: beat.lastTickAt, secondsSinceTick, paused: beat.paused, totals: beat.totals
54
+ };
55
+ }
56
+ /**
57
+ * The daemon's upload pass, finished properly: upload, verify, then offload.
58
+ *
59
+ * `uploadPending` alone leaves a sealed local copy of every archive it just
60
+ * proved the bucket holds — on the machines this product is for, that is the
61
+ * disk staying exactly as full as before. The dashboard's push already does
62
+ * the whole job (see `runUploads` in local-api.ts): the moment an archive's
63
+ * verify lands, its local blob is a duplicate, and offloading it right then
64
+ * lets a full disk breathe with each verify instead of never.
65
+ *
66
+ * Offload failure is tolerated per archive: the upload is still verified and
67
+ * recorded, and the next offload sweep gets another chance. `freshlyVerified`
68
+ * is what keeps this cheap — the verify happened milliseconds ago, so the
69
+ * usual fetch-and-hash re-proof would double the egress of every push.
70
+ *
71
+ * Integration note: src/daemon.ts's tick still calls bare `uploadPending`;
72
+ * switching it to this function is the remaining wiring (daemon.ts is outside
73
+ * this change's file list).
74
+ */
75
+ export async function uploadAndOffloadPending(dataDir, options = {}) {
76
+ // Imported lazily, like the paths local-api takes: this module is read by
77
+ // every surface that asks "is the service alive", none of which should pay
78
+ // for the provider stack.
79
+ const [{ uploadArchive }, { listArchives }, { isV2 }] = await Promise.all([
80
+ import("./upload.js"), import("./vault.js"), import("./types.js")
81
+ ]);
82
+ const { offload: injectedOffload, ...uploadOptions } = options;
83
+ const offload = injectedOffload ?? (await import("./offload.js")).offloadArchives;
84
+ const records = await listArchives(dataDir);
85
+ const uploaded = [];
86
+ const failed = [];
87
+ const offloaded = [];
88
+ let freedBytes = 0;
89
+ for (const record of records) {
90
+ if (isV2(record) && record.remote?.verifiedAt)
91
+ continue;
92
+ try {
93
+ uploaded.push(await uploadArchive(dataDir, record.id, uploadOptions));
94
+ }
95
+ catch (error) {
96
+ failed.push({ archiveId: record.id, error: error instanceof Error ? error.message : "upload failed" });
97
+ continue;
98
+ }
99
+ try {
100
+ const outcome = await offload(dataDir, { confirm: true, only: new Set([record.id]), freshlyVerified: new Set([record.id]) });
101
+ if (outcome.mode === "apply") {
102
+ freedBytes += outcome.freedBytes;
103
+ offloaded.push(...outcome.offloaded.map((item) => item.id));
104
+ }
105
+ }
106
+ catch { /* offload is a bonus on this pass; the sweep gets another chance */ }
107
+ }
108
+ return { uploaded, failed, skipped: records.length - uploaded.length - failed.length, offloaded, freedBytes };
109
+ }
110
+ export function describeGap(seconds) {
111
+ if (seconds < 5)
112
+ return "just now";
113
+ if (seconds < 90)
114
+ return `${seconds}s ago`;
115
+ const minutes = Math.round(seconds / 60);
116
+ if (minutes < 90)
117
+ return `${minutes}m ago`;
118
+ const hours = Math.round(minutes / 60);
119
+ return hours < 48 ? `${hours}h ago` : `${Math.round(hours / 24)}d ago`;
120
+ }
@@ -0,0 +1,53 @@
1
+ import type { Envelope } from "../packages/vaultline-crypto/src/index.js";
2
+ import { type ChunkStoreClient } from "./chunk-store.js";
3
+ /**
4
+ * The content index travels, sealed — Paul's question answered structurally.
5
+ *
6
+ * The index is what makes search a ranged fetch instead of a bulk restore,
7
+ * and it is built locally because building it requires the phrase. Locally
8
+ * built used to mean locally trapped: a second machine — or a teammate on the
9
+ * same storage — had nothing to search until it downloaded and decrypted the
10
+ * whole history to rebuild its own. So the index now syncs like everything
11
+ * else does: as ciphertext. Two small objects in the storage —
12
+ *
13
+ * …/index.vlmeta the index's ENVELOPE (tiny; carries builtAt, so
14
+ * "is theirs newer than mine" costs one small GET)
15
+ * …/index.vlindex the sealed index itself
16
+ *
17
+ * — and the recovery phrase, which every machine of the vault holds, opens
18
+ * them. The provider learns that an index exists and when it changed;
19
+ * never a token of it.
20
+ *
21
+ * Free / Pro Lite (own bucket): the two objects live under the configured
22
+ * prefix, in a segment that follows the vault's naming mode (hashed vaults
23
+ * hash it). Pro (managed): the control plane vends signed PUT/GET for a
24
+ * per-account index slot — same signer, known lengths — via cloud.ts.
25
+ */
26
+ export type RemoteIndexStore = {
27
+ /** The remote envelope, or null when no index was ever pushed. */
28
+ meta(): Promise<Envelope | null>;
29
+ pull(): Promise<Buffer>;
30
+ push(envelope: Envelope, ciphertext: Buffer): Promise<void>;
31
+ };
32
+ /** The store for an own-bucket vault, over the provider's plain object verbs. */
33
+ export declare function byoIndexStore(dataDir: string, phrase: string, injected?: ChunkStoreClient, targetOverride?: {
34
+ provider: "s3" | "r2" | "b2" | "gcs" | "gdrive" | "vaultline";
35
+ bucket: string;
36
+ prefix: string;
37
+ region?: string;
38
+ }): Promise<RemoteIndexStore>;
39
+ /** The store for a managed vault, through the control plane's signed index slot. */
40
+ export declare function managedIndexStore(dataDir: string): Promise<RemoteIndexStore>;
41
+ /** Picks the right store for this vault, or null when there is nowhere to sync. */
42
+ export declare function remoteIndexStoreFor(dataDir: string, phrase: string, injected?: ChunkStoreClient): Promise<RemoteIndexStore | null>;
43
+ /**
44
+ * Pulls the remote index when it is newer than what this machine holds.
45
+ * Freshness is the envelope's own createdAt — one tiny GET decides, and the
46
+ * blob only moves when it would change something.
47
+ */
48
+ export declare function pullIndexIfNewer(dataDir: string, store: RemoteIndexStore, localBuiltAt: string | null, save: (envelope: Envelope, ciphertext: Buffer) => Promise<void>): Promise<{
49
+ pulled: boolean;
50
+ remoteBuiltAt: string | null;
51
+ }>;
52
+ /** Reads the local sealed index's envelope, for freshness comparison. */
53
+ export declare function localIndexBuiltAt(envelopePath: string): Promise<string | null>;
@@ -0,0 +1,147 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fail } from "./errors.js";
3
+ import { recordAudit } from "./audit.js";
4
+ import { readConfig } from "./vault.js";
5
+ import { uploadClientFromStore } from "./providers/index.js";
6
+ import { folderNamingKey, storageScopeOf } from "./chunk-store.js";
7
+ import { createHmac } from "node:crypto";
8
+ const INDEX_SEGMENT = "vaultline-index";
9
+ function indexKeys(prefix, naming, namingKey) {
10
+ const clean = prefix.replace(/^\/+|\/+$/g, "");
11
+ const segment = naming === "hashed" && namingKey
12
+ ? createHmac("sha256", namingKey).update("segment:vaultline-index").digest("hex").slice(0, 16)
13
+ : INDEX_SEGMENT;
14
+ return { meta: `${clean}/${segment}/index.vlmeta`, blob: `${clean}/${segment}/index.vlindex` };
15
+ }
16
+ /** The store for an own-bucket vault, over the provider's plain object verbs. */
17
+ export async function byoIndexStore(dataDir, phrase, injected, targetOverride) {
18
+ const config = await readConfig(dataDir);
19
+ const remoteStorage = targetOverride ?? config.remoteStorage ?? fail("storage_not_configured", "No remote target configured, so the index has nowhere to sync");
20
+ if (remoteStorage.provider === "vaultline")
21
+ fail("invalid_argument", "Managed vaults sync their index through the control plane, not the object store");
22
+ const raw = injected ?? (await uploadClientFromStore(dataDir, config.vaultId, remoteStorage));
23
+ if (typeof raw.putObject !== "function" || typeof raw.getObject !== "function" || typeof raw.headObject !== "function") {
24
+ fail("invalid_argument", `The ${remoteStorage.provider} client cannot sync the index`);
25
+ }
26
+ const naming = config.remoteNaming ?? "hashed";
27
+ const keys = indexKeys(remoteStorage.prefix, naming, naming === "hashed" ? folderNamingKey(phrase, storageScopeOf(remoteStorage)) : null);
28
+ return {
29
+ meta: async () => {
30
+ const head = await raw.headObject(keys.meta);
31
+ if (!head.exists)
32
+ return null;
33
+ return JSON.parse((await raw.getObject(keys.meta)).toString("utf8"));
34
+ },
35
+ pull: () => raw.getObject(keys.blob),
36
+ push: async (envelope, ciphertext) => {
37
+ // Blob first, envelope second: a reader that sees the new envelope can
38
+ // always fetch the blob it describes; the reverse order has a window
39
+ // where the meta promises an index the bucket does not hold yet.
40
+ await raw.putObject(keys.blob, ciphertext);
41
+ await raw.putObject(keys.meta, Buffer.from(JSON.stringify(envelope), "utf8"));
42
+ }
43
+ };
44
+ }
45
+ /** The store for a managed vault, through the control plane's signed index slot. */
46
+ export async function managedIndexStore(dataDir) {
47
+ const cloud = await import("./cloud.js");
48
+ if (typeof cloud.pullIndexObject !== "function" || typeof cloud.pushIndexObject !== "function") {
49
+ fail("invalid_argument", "This build's control plane client cannot sync the index yet");
50
+ }
51
+ return {
52
+ meta: async () => {
53
+ const body = await cloud.pullIndexObject(dataDir, "meta");
54
+ return body ? JSON.parse(body.toString("utf8")) : null;
55
+ },
56
+ pull: async () => {
57
+ const body = await cloud.pullIndexObject(dataDir, "blob");
58
+ if (!body)
59
+ fail("archive_not_found", "No index has been pushed to managed storage yet");
60
+ return body;
61
+ },
62
+ push: async (envelope, ciphertext) => {
63
+ await cloud.pushIndexObject(dataDir, "blob", ciphertext);
64
+ await cloud.pushIndexObject(dataDir, "meta", Buffer.from(JSON.stringify(envelope), "utf8"));
65
+ }
66
+ };
67
+ }
68
+ /** Picks the right store for this vault, or null when there is nowhere to sync. */
69
+ export async function remoteIndexStoreFor(dataDir, phrase, injected) {
70
+ const config = await readConfig(dataDir);
71
+ // Multi-target vaults keep ONE phonebook, on the most durable target:
72
+ // managed when present, else the first bucket that can hold objects.
73
+ if (config.storageTargets && config.storageTargets.length > 0) {
74
+ const managed = config.storageTargets.find((target) => target.provider === "vaultline");
75
+ if (managed) {
76
+ try {
77
+ return await managedIndexStore(dataDir);
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ const byo = config.storageTargets.find((target) => target.provider !== "gdrive");
84
+ if (!byo)
85
+ return null;
86
+ try {
87
+ return await byoIndexStore(dataDir, phrase, injected, { provider: byo.provider, bucket: byo.bucket ?? "", prefix: byo.prefix ?? "vaultline", ...(byo.region ? { region: byo.region } : {}) });
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ if (!config.remoteStorage) {
94
+ // An enrolled managed vault has a cloud token and no remoteStorage entry —
95
+ // same rule as storedCopiesMode. Without the token there is nowhere to sync.
96
+ try {
97
+ const { cloudToken } = await import("./cloud.js");
98
+ await cloudToken(dataDir);
99
+ return await managedIndexStore(dataDir);
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ if (config.remoteStorage.provider === "vaultline") {
106
+ try {
107
+ return await managedIndexStore(dataDir);
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ try {
114
+ return await byoIndexStore(dataDir, phrase, injected);
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
120
+ /**
121
+ * Pulls the remote index when it is newer than what this machine holds.
122
+ * Freshness is the envelope's own createdAt — one tiny GET decides, and the
123
+ * blob only moves when it would change something.
124
+ */
125
+ export async function pullIndexIfNewer(dataDir, store, localBuiltAt, save) {
126
+ const envelope = await store.meta();
127
+ if (!envelope)
128
+ return { pulled: false, remoteBuiltAt: null };
129
+ const remoteBuiltAt = envelope.manifest?.createdAt ?? null;
130
+ if (!remoteBuiltAt || (localBuiltAt && Date.parse(remoteBuiltAt) <= Date.parse(localBuiltAt))) {
131
+ return { pulled: false, remoteBuiltAt };
132
+ }
133
+ const ciphertext = await store.pull();
134
+ await save(envelope, ciphertext);
135
+ await recordAudit(dataDir, "archive.restore", "allowed", { indexPulled: true, remoteBuiltAt }).catch(() => undefined);
136
+ return { pulled: true, remoteBuiltAt };
137
+ }
138
+ /** Reads the local sealed index's envelope, for freshness comparison. */
139
+ export async function localIndexBuiltAt(envelopePath) {
140
+ try {
141
+ const envelope = JSON.parse(await readFile(envelopePath, "utf8"));
142
+ return envelope.manifest?.createdAt ?? null;
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ }
@@ -0,0 +1,48 @@
1
+ export type Finding = {
2
+ kind: string;
3
+ /** 1-based, matching what editors and `sed -n` call a line. */
4
+ line: number;
5
+ /** 1-based offset of the secret itself, not of any surrounding assignment. */
6
+ column: number;
7
+ /** Masked — see maskSecret. Never the secret. */
8
+ preview: string;
9
+ severity: "high" | "medium";
10
+ };
11
+ export type FileFinding = Finding & {
12
+ file: string;
13
+ };
14
+ export type ScanReport = {
15
+ files: number;
16
+ findings: FileFinding[];
17
+ high: number;
18
+ medium: number;
19
+ /** 1 when any high finding exists, so `sealkeep scan … && seal` works as a gate. */
20
+ exitCode: 0 | 1;
21
+ };
22
+ /**
23
+ * First four characters, then only the length. A leak report that repeats the
24
+ * leak is a second leak: findings travel further than the transcript did —
25
+ * terminals, CI logs, screenshots, phase-2 metadata — so the preview carries
26
+ * just enough to locate the match and never enough to use it.
27
+ */
28
+ export declare function maskSecret(secret: string): string;
29
+ /** Character-frequency Shannon entropy, in bits per character. */
30
+ export declare function shannonEntropy(value: string): number;
31
+ /** Tests derive their vectors from these rather than restating the numbers. */
32
+ export declare const GENERIC_MIN_LENGTH = 24;
33
+ export declare const GENERIC_ENTROPY_THRESHOLD = 4.2;
34
+ export declare function scanText(text: string): Finding[];
35
+ /**
36
+ * Line-by-line over a read stream: memory is bounded by the longest single
37
+ * line, never by the file, so a multi-GB jsonl scans in the same footprint as
38
+ * a small one. An unreadable input rejects — a scanner that swallows a read
39
+ * error would be reporting "clean" about bytes it never saw.
40
+ */
41
+ export declare function scanFileStreaming(path: string, onFinding?: (finding: Finding) => void): Promise<Finding[]>;
42
+ /**
43
+ * A file is scanned as pointed at, whatever its name; a directory means every
44
+ * *.jsonl beneath it. Only high findings set the exit code — failing a script
45
+ * over a "medium" suspicion is exactly the cry-wolf behaviour the severity
46
+ * split exists to avoid.
47
+ */
48
+ export declare function scanPath(target: string, onFinding?: (finding: FileFinding) => void): Promise<ScanReport>;