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,68 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { fail } from "./errors.js";
3
+ import { recordAudit } from "./audit.js";
4
+ import { canonicalPhrase } from "./mnemonic.js";
5
+ import { isV2 } from "./types.js";
6
+ import { listArchives, readConfig } from "./vault.js";
7
+ import { frameObject, unframeObject } from "./cloud.js";
8
+ import { openArchiveToFile, rewrapArchive } from "../packages/vaultline-crypto/src/index.js";
9
+ import { materialiseArchive } from "./offload.js";
10
+ import { randomInt } from "node:crypto";
11
+ import { WORDS } from "./mnemonic.js";
12
+ const PASSCODE_WORDS = 6;
13
+ export function generateSharePasscode() {
14
+ return Array.from({ length: PASSCODE_WORDS }, () => WORDS[randomInt(WORDS.length)]).join(" ");
15
+ }
16
+ export async function createShareBundle(dataDir, archiveId, bundlePath, options = { vaultPhrase: "" }) {
17
+ const record = (await listArchives(dataDir)).find((item) => item.id === archiveId);
18
+ if (!record)
19
+ fail("archive_not_found", `Archive not found: ${archiveId}`, { archiveId });
20
+ if (!isV2(record))
21
+ fail("invalid_argument", `Archive ${archiveId} uses format v1. Run: sealkeep migrate`, { archiveId });
22
+ const config = await readConfig(dataDir);
23
+ if (config.storageMode === "plain")
24
+ fail("invalid_argument", "A plain vault's archives are ordinary readable files — send the file itself; a bundle would only pretend to add protection.");
25
+ const passcode = options.passcode ?? generateSharePasscode();
26
+ // Replace, never add: the bundle's one wrap is the passcode's. The vault
27
+ // phrase opening a bundle would mean every shared file doubles as an oracle
28
+ // for the vault.
29
+ const shared = rewrapArchive(record.envelope, { phrase: canonicalPhrase(options.vaultPhrase) }, [{ kind: "phrase", phrase: canonicalPhrase(passcode) }], { mode: "replace" });
30
+ // The ciphertext may live locally or be offloaded to the bucket — either
31
+ // way the bundle carries the same bytes the archive is made of.
32
+ const materialised = await materialiseArchive(dataDir, record);
33
+ try {
34
+ const ciphertext = await readFile(materialised.path);
35
+ const bundle = frameObject(shared, ciphertext);
36
+ await writeFile(bundlePath, bundle, { mode: 0o600 });
37
+ await recordAudit(dataDir, "archive.share", "allowed", { archiveId, bytes: bundle.length });
38
+ return { bundlePath, passcode, bytes: bundle.length, archiveId };
39
+ }
40
+ finally {
41
+ await materialised.release();
42
+ }
43
+ }
44
+ /**
45
+ * Opens a bundle anywhere — no vault, no account, no config. The passcode is
46
+ * the whole key, which is exactly the promise made to the person receiving it.
47
+ */
48
+ export async function openShareBundle(bundlePath, destination, passcode) {
49
+ const blob = await readFile(bundlePath);
50
+ const { envelope, ciphertext } = unframeObject(blob);
51
+ if (!envelope)
52
+ fail("invalid_argument", `${bundlePath} is not a Sealkeep share bundle.`);
53
+ // openArchiveToFile reads the ciphertext from a path; the bundle's payload
54
+ // is written beside the destination momentarily, then removed.
55
+ const { mkdtemp, rm, writeFile: write } = await import("node:fs/promises");
56
+ const { tmpdir } = await import("node:os");
57
+ const { join } = await import("node:path");
58
+ const scratch = await mkdtemp(join(tmpdir(), "vaultline-share-"));
59
+ const payload = join(scratch, "payload");
60
+ try {
61
+ await write(payload, ciphertext, { mode: 0o600 });
62
+ const opened = await openArchiveToFile(envelope, payload, destination, { phrase: canonicalPhrase(passcode) });
63
+ return { bytes: opened.bytes };
64
+ }
65
+ finally {
66
+ await rm(scratch, { recursive: true, force: true });
67
+ }
68
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The spool: journals for in-flight streamed seals.
3
+ *
4
+ * A streamed seal writes ciphertext straight into a provider session and keeps
5
+ * nothing locally, so a crash mid-object used to mean starting the whole
6
+ * upload over. A spool journal records everything a *later process* needs to
7
+ * finish the object instead: which chunks the provider already holds, where
8
+ * the chunk numbering and the whole-object hashes stood, and the provider's
9
+ * own session state (an S3 uploadId and its parts, a GCS session URI).
10
+ *
11
+ * The journal never holds plaintext key material. The archive key is wrapped
12
+ * with the same recipients machinery envelopes use — the same array shape, the
13
+ * same unwrap — so resuming demands the vault phrase or a device key exactly
14
+ * as restoring does, and a stolen journal is worth no more than a stolen
15
+ * envelope. The file is still 0600: wrapped keys plus provider session state
16
+ * are nobody else's business.
17
+ *
18
+ * Every write is temp-then-rename, because a resume that reads a torn journal
19
+ * would re-seal from the wrong chunk index and corrupt the object it was
20
+ * trying to save. Rename is atomic on one filesystem, so a concurrent reader
21
+ * sees the old journal or the new one, never a mixture.
22
+ */
23
+ import { type ChunkHeader, type Suite, type Unlock, type WrappedKey } from "../packages/vaultline-crypto/src/index.js";
24
+ export declare const SPOOL_VERSION = 1;
25
+ export type SpoolJournal = {
26
+ version: typeof SPOOL_VERSION;
27
+ archiveId: string;
28
+ suite: Suite;
29
+ provider: string;
30
+ bucket: string;
31
+ objectKey: string;
32
+ source: {
33
+ path: string;
34
+ totalBytes: number;
35
+ chunkBytes: number;
36
+ };
37
+ /** The archive key, wrapped for the vault's recipients — the same array shape envelopes carry. Never the raw key. */
38
+ wrappedKeys: WrappedKey[];
39
+ noncePrefixB64: string;
40
+ /** Chunks FULLY handed to the provider; resume re-seals from here. */
41
+ nextChunkIndex: number;
42
+ /** Hex SHA-256 of source bytes `[0, nextChunkIndex * chunkBytes)`, so a resume can prove the source has not changed under the finished chunks. */
43
+ consumedPrefixSha256: string;
44
+ /** Headers for chunks already sent, in order — the front half of the final envelope's chunk list. */
45
+ chunkHeaders: ChunkHeader[];
46
+ /** `StreamingSha256.exportState()` strings for the whole-object plaintext and stored hashes. */
47
+ hashState: {
48
+ plaintext: string;
49
+ stored: string;
50
+ };
51
+ /** Opaque JSON the provider layer owns: S3 uploadId and parts, or a GCS session URI. */
52
+ providerState: unknown;
53
+ createdAt: string;
54
+ updatedAt: string;
55
+ };
56
+ /** What a caller supplies; `version` and the timestamps are stamped here so they cannot be wrong. */
57
+ export type SpoolDraft = Omit<SpoolJournal, "version" | "createdAt" | "updatedAt">;
58
+ /** Identity and origin are fixed at creation; everything else may move as chunks land. */
59
+ export type SpoolPatch = Partial<Omit<SpoolJournal, "version" | "archiveId" | "createdAt" | "updatedAt">>;
60
+ export declare function createSpool(dataDir: string, draft: SpoolDraft, options?: {
61
+ now?: number;
62
+ }): Promise<SpoolJournal>;
63
+ export declare function loadSpool(dataDir: string, archiveId: string): Promise<SpoolJournal>;
64
+ export declare function updateSpool(dataDir: string, archiveId: string, patch: SpoolPatch, options?: {
65
+ now?: number;
66
+ }): Promise<SpoolJournal>;
67
+ /**
68
+ * The newest journal for a source file, or null. Newest by `updatedAt` because
69
+ * that tracks actual upload progress; `createdAt` breaks ties. Unreadable or
70
+ * foreign files in the spool directory are skipped, not fatal — one stray temp
71
+ * or hand-dropped file must not block resuming a good upload.
72
+ */
73
+ export declare function findSpoolForSource(dataDir: string, sourcePath: string): Promise<SpoolJournal | null>;
74
+ /**
75
+ * Removes a journal for good. No overwrite pass: the file holds only wrapped
76
+ * keys and provider session state, both worthless without a vault secret. The
77
+ * unlink itself must be sure, though — `force` only forgives a journal that is
78
+ * already gone (shred is a cleanup path and runs twice sometimes); any other
79
+ * failure surfaces, because a journal that lingers would later present a dead
80
+ * provider session as resumable.
81
+ */
82
+ /**
83
+ * One resume per journal, across processes. Two processes continuing the same
84
+ * journal would drive the same multipart/session — racing part numbers, and
85
+ * (worse) widening the nonce-reuse window if the source changes between them.
86
+ * The lock is an O_EXCL sentinel beside the journal: cheap, crash-tolerant
87
+ * (a holder that died leaves a stale file, taken over after 30 minutes), and
88
+ * refused with a sentence rather than queued.
89
+ */
90
+ export declare function acquireSpoolLock(dataDir: string, archiveId: string, options?: {
91
+ now?: number;
92
+ }): Promise<{
93
+ release: () => Promise<void>;
94
+ }>;
95
+ export declare function shredSpool(dataDir: string, archiveId: string): Promise<void>;
96
+ /** Opens the journal's wrapped archive key. The caller owns the returned bytes and zeroizes them. */
97
+ export declare function unwrapSpoolKey(journal: SpoolJournal, unlock: Unlock): Buffer;
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The spool: journals for in-flight streamed seals.
3
+ *
4
+ * A streamed seal writes ciphertext straight into a provider session and keeps
5
+ * nothing locally, so a crash mid-object used to mean starting the whole
6
+ * upload over. A spool journal records everything a *later process* needs to
7
+ * finish the object instead: which chunks the provider already holds, where
8
+ * the chunk numbering and the whole-object hashes stood, and the provider's
9
+ * own session state (an S3 uploadId and its parts, a GCS session URI).
10
+ *
11
+ * The journal never holds plaintext key material. The archive key is wrapped
12
+ * with the same recipients machinery envelopes use — the same array shape, the
13
+ * same unwrap — so resuming demands the vault phrase or a device key exactly
14
+ * as restoring does, and a stolen journal is worth no more than a stolen
15
+ * envelope. The file is still 0600: wrapped keys plus provider session state
16
+ * are nobody else's business.
17
+ *
18
+ * Every write is temp-then-rename, because a resume that reads a torn journal
19
+ * would re-seal from the wrong chunk index and corrupt the object it was
20
+ * trying to save. Rename is atomic on one filesystem, so a concurrent reader
21
+ * sees the old journal or the new one, never a mixture.
22
+ */
23
+ import { mkdir, open, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
24
+ import { join, resolve } from "node:path";
25
+ import { randomUUID } from "node:crypto";
26
+ import { CryptoError, KEY_BYTES, unwrapArchiveKey, zeroize } from "../packages/vaultline-crypto/src/index.js";
27
+ import { fail } from "./errors.js";
28
+ export const SPOOL_VERSION = 1;
29
+ const spoolDir = (dataDir) => join(dataDir, "spool");
30
+ const journalPath = (dataDir, archiveId) => join(spoolDir(dataDir), `${archiveId}.json`);
31
+ /**
32
+ * The single writer every journal byte goes through: full contents to a
33
+ * temporary name, then rename onto the journal path. 0600 from birth — the
34
+ * temp file is the journal for a moment, so it gets the journal's mode.
35
+ */
36
+ async function writeJournal(path, journal) {
37
+ const temp = `${path}.${randomUUID()}.tmp`;
38
+ await writeFile(temp, JSON.stringify(journal, null, 2) + "\n", { mode: 0o600 });
39
+ await rename(temp, path);
40
+ }
41
+ /**
42
+ * Structural gate before anything trusts a journal. Deliberately checks the
43
+ * fields a resume would act on, not every byte: a journal that passes here can
44
+ * still fail its unwrap or its hash-state import, and those failures carry
45
+ * their own, better messages.
46
+ */
47
+ function parseJournal(raw, path) {
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(raw);
51
+ }
52
+ catch {
53
+ return fail("invalid_argument", `The spool journal at ${path} is not valid JSON, so it cannot drive a resume. Shred it and start the upload over.`, { path });
54
+ }
55
+ const journal = parsed;
56
+ if (journal?.version !== SPOOL_VERSION) {
57
+ fail("config_unsupported_version", `Spool journal version ${String(parsed?.version)} is not supported by this build (expected ${SPOOL_VERSION})`, { path });
58
+ }
59
+ const shaped = typeof journal.archiveId === "string" && journal.archiveId.length > 0
60
+ && typeof journal.suite === "string"
61
+ && typeof journal.source?.path === "string"
62
+ && Array.isArray(journal.wrappedKeys) && journal.wrappedKeys.length > 0
63
+ && typeof journal.noncePrefixB64 === "string"
64
+ && Number.isInteger(journal.nextChunkIndex) && journal.nextChunkIndex >= 0
65
+ && typeof journal.consumedPrefixSha256 === "string"
66
+ && Array.isArray(journal.chunkHeaders)
67
+ && typeof journal.hashState?.plaintext === "string"
68
+ && typeof journal.hashState?.stored === "string";
69
+ if (!shaped)
70
+ fail("invalid_argument", `The spool journal at ${path} is missing fields a resume depends on; refusing to act on it. Shred it and start the upload over.`, { path });
71
+ return journal;
72
+ }
73
+ export async function createSpool(dataDir, draft, options = {}) {
74
+ const stamp = new Date(options.now ?? Date.now()).toISOString();
75
+ const journal = { ...draft, version: SPOOL_VERSION, createdAt: stamp, updatedAt: stamp };
76
+ await mkdir(spoolDir(dataDir), { recursive: true });
77
+ await writeJournal(journalPath(dataDir, journal.archiveId), journal);
78
+ return journal;
79
+ }
80
+ export async function loadSpool(dataDir, archiveId) {
81
+ const path = journalPath(dataDir, archiveId);
82
+ let raw;
83
+ try {
84
+ raw = await readFile(path, "utf8");
85
+ }
86
+ catch {
87
+ return fail("archive_not_found", `No in-flight upload journal for archive ${archiveId}. The upload either finished or was never started on this machine.`, { archiveId });
88
+ }
89
+ return parseJournal(raw, path);
90
+ }
91
+ /**
92
+ * Atomic read-modify-write. The merge is shallow on purpose: a patch replaces
93
+ * whole fields (`chunkHeaders`, `hashState`, `providerState`) rather than
94
+ * deep-merging into them, so what lands on disk is exactly what the caller
95
+ * computed and not a splice of two generations.
96
+ */
97
+ /**
98
+ * One writer at a time per journal, in-process. Two writers race a streamed
99
+ * seal by design — the sink journals each chunk header at emission while the
100
+ * provider's onState journals confirmation — and unserialised read-modify-write
101
+ * let the slower reader clobber the faster writer's fields (a header written a
102
+ * millisecond earlier vanished under an onState that had read before it). The
103
+ * chain makes every update read what the previous update wrote.
104
+ */
105
+ const updateChains = new Map();
106
+ export async function updateSpool(dataDir, archiveId, patch, options = {}) {
107
+ const key = `${resolve(dataDir)}::${archiveId}`;
108
+ const run = async () => {
109
+ const current = await loadSpool(dataDir, archiveId);
110
+ const next = {
111
+ ...current, ...patch,
112
+ // Pinned regardless of what a loosely typed caller put in the patch:
113
+ // identity and origin never move, and updatedAt is this write's own stamp.
114
+ version: SPOOL_VERSION, archiveId: current.archiveId, createdAt: current.createdAt,
115
+ updatedAt: new Date(options.now ?? Date.now()).toISOString()
116
+ };
117
+ await writeJournal(journalPath(dataDir, archiveId), next);
118
+ return next;
119
+ };
120
+ const chained = (updateChains.get(key) ?? Promise.resolve()).then(run, run);
121
+ // The chain must survive a failed link (a shredded journal mid-sequence),
122
+ // or every later update would re-throw the same stale error.
123
+ updateChains.set(key, chained.catch(() => undefined));
124
+ return chained;
125
+ }
126
+ /**
127
+ * The newest journal for a source file, or null. Newest by `updatedAt` because
128
+ * that tracks actual upload progress; `createdAt` breaks ties. Unreadable or
129
+ * foreign files in the spool directory are skipped, not fatal — one stray temp
130
+ * or hand-dropped file must not block resuming a good upload.
131
+ */
132
+ export async function findSpoolForSource(dataDir, sourcePath) {
133
+ const absolute = resolve(sourcePath);
134
+ let files;
135
+ try {
136
+ files = await readdir(spoolDir(dataDir));
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ let newest = null;
142
+ for (const file of files) {
143
+ if (!file.endsWith(".json"))
144
+ continue;
145
+ let journal;
146
+ try {
147
+ journal = parseJournal(await readFile(join(spoolDir(dataDir), file), "utf8"), join(spoolDir(dataDir), file));
148
+ }
149
+ catch {
150
+ continue;
151
+ }
152
+ if (resolve(journal.source.path) !== absolute)
153
+ continue;
154
+ // ISO-8601 UTC timestamps order lexicographically, same as listArchives.
155
+ if (!newest || journal.updatedAt > newest.updatedAt || (journal.updatedAt === newest.updatedAt && journal.createdAt > newest.createdAt))
156
+ newest = journal;
157
+ }
158
+ return newest;
159
+ }
160
+ /**
161
+ * Removes a journal for good. No overwrite pass: the file holds only wrapped
162
+ * keys and provider session state, both worthless without a vault secret. The
163
+ * unlink itself must be sure, though — `force` only forgives a journal that is
164
+ * already gone (shred is a cleanup path and runs twice sometimes); any other
165
+ * failure surfaces, because a journal that lingers would later present a dead
166
+ * provider session as resumable.
167
+ */
168
+ /**
169
+ * One resume per journal, across processes. Two processes continuing the same
170
+ * journal would drive the same multipart/session — racing part numbers, and
171
+ * (worse) widening the nonce-reuse window if the source changes between them.
172
+ * The lock is an O_EXCL sentinel beside the journal: cheap, crash-tolerant
173
+ * (a holder that died leaves a stale file, taken over after 30 minutes), and
174
+ * refused with a sentence rather than queued.
175
+ */
176
+ export async function acquireSpoolLock(dataDir, archiveId, options = {}) {
177
+ const dir = join(dataDir, "spool");
178
+ await mkdir(dir, { recursive: true, mode: 0o700 });
179
+ const path = join(dir, `${archiveId}.lock`);
180
+ const now = options.now ?? Date.now();
181
+ for (let attempt = 0; attempt < 2; attempt += 1) {
182
+ try {
183
+ const handle = await open(path, "wx", 0o600);
184
+ await handle.writeFile(JSON.stringify({ pid: process.pid, at: new Date(now).toISOString() }));
185
+ await handle.close();
186
+ return { release: async () => { await rm(path, { force: true }); } };
187
+ }
188
+ catch (error) {
189
+ if (error.code !== "EEXIST")
190
+ throw error;
191
+ const holder = await stat(path).catch(() => null);
192
+ if (holder && now - holder.mtimeMs > 30 * 60_000) {
193
+ // The holder died half an hour ago; its upload is not moving. Take over.
194
+ await rm(path, { force: true });
195
+ continue;
196
+ }
197
+ fail("invalid_argument", "Another process is already working on this upload. Let it finish, or if it crashed, try again in half an hour (its claim expires on its own).", { archiveId });
198
+ }
199
+ }
200
+ fail("internal", "Could not claim the upload lock.", { archiveId });
201
+ }
202
+ export async function shredSpool(dataDir, archiveId) {
203
+ await rm(journalPath(dataDir, archiveId), { force: true });
204
+ }
205
+ /** Opens the journal's wrapped archive key. The caller owns the returned bytes and zeroizes them. */
206
+ export function unwrapSpoolKey(journal, unlock) {
207
+ const archiveKey = unwrapArchiveKey(journal.wrappedKeys, journal.suite, journal.archiveId, unlock);
208
+ if (archiveKey.length !== KEY_BYTES) {
209
+ zeroize(archiveKey);
210
+ throw new CryptoError("key_malformed", "Unwrapped archive key has the wrong length");
211
+ }
212
+ return archiveKey;
213
+ }
@@ -0,0 +1,17 @@
1
+ import { type StartChoice } from "./start.js";
2
+ /**
3
+ * Interactive onboarding.
4
+ *
5
+ * Arrow keys and enter, the way people already expect a CLI to behave. It
6
+ * degrades on purpose: without a TTY there is nothing to arrow around, so the
7
+ * caller falls back to flags rather than hanging waiting for a keypress that
8
+ * cannot arrive.
9
+ */
10
+ export declare const interactive: (stream?: NodeJS.ReadStream) => boolean;
11
+ /** A one-of-N picker. Returns the chosen index. */
12
+ export declare function select(title: string, items: {
13
+ label: string;
14
+ hint?: string;
15
+ }[], out?: NodeJS.WriteStream, input?: NodeJS.ReadStream): Promise<number>;
16
+ /** Walks the whole first-run choice and returns what `start` needs. */
17
+ export declare function onboard(): Promise<StartChoice>;
@@ -0,0 +1,113 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { emitKeypressEvents } from "node:readline";
3
+ import { modeOptions } from "./start.js";
4
+ import { bold, dim, blue, green } from "./ui.js";
5
+ /**
6
+ * Interactive onboarding.
7
+ *
8
+ * Arrow keys and enter, the way people already expect a CLI to behave. It
9
+ * degrades on purpose: without a TTY there is nothing to arrow around, so the
10
+ * caller falls back to flags rather than hanging waiting for a keypress that
11
+ * cannot arrive.
12
+ */
13
+ export const interactive = (stream = process.stdin) => Boolean(stream.isTTY);
14
+ /** A one-of-N picker. Returns the chosen index. */
15
+ export async function select(title, items, out = process.stdout, input = process.stdin) {
16
+ let index = 0;
17
+ const height = items.length + 2;
18
+ const draw = (first) => {
19
+ if (!first)
20
+ out.write(`\x1b[${height}A`);
21
+ out.write(`\r\x1b[J${bold(title)}\n\n`);
22
+ items.forEach((item, position) => {
23
+ const active = position === index;
24
+ const marker = active ? blue("❯") : " ";
25
+ const label = active ? bold(item.label) : item.label;
26
+ out.write(`${marker} ${label}${item.hint ? ` ${dim(item.hint)}` : ""}\n`);
27
+ });
28
+ };
29
+ emitKeypressEvents(input);
30
+ const wasRaw = input.isRaw === true;
31
+ if (input.isTTY)
32
+ input.setRawMode(true);
33
+ input.resume();
34
+ draw(true);
35
+ try {
36
+ return await new Promise((resolve, reject) => {
37
+ const onKey = (_, key) => {
38
+ if (key?.ctrl && key.name === "c") {
39
+ cleanup();
40
+ reject(new Error("cancelled"));
41
+ return;
42
+ }
43
+ if (key?.name === "up" || key?.name === "k") {
44
+ index = (index - 1 + items.length) % items.length;
45
+ draw(false);
46
+ return;
47
+ }
48
+ if (key?.name === "down" || key?.name === "j") {
49
+ index = (index + 1) % items.length;
50
+ draw(false);
51
+ return;
52
+ }
53
+ if (key?.name === "return" || key?.name === "enter") {
54
+ cleanup();
55
+ resolve(index);
56
+ }
57
+ };
58
+ const cleanup = () => { input.off("keypress", onKey); if (input.isTTY)
59
+ input.setRawMode(wasRaw); input.pause(); out.write("\n"); };
60
+ input.on("keypress", onKey);
61
+ });
62
+ }
63
+ catch (error) {
64
+ if (input.isTTY)
65
+ input.setRawMode(wasRaw);
66
+ throw error;
67
+ }
68
+ }
69
+ async function ask(question, fallback = "") {
70
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
71
+ try {
72
+ const answer = (await rl.question(`${question}${fallback ? dim(` (${fallback})`) : ""}: `)).trim();
73
+ return answer || fallback;
74
+ }
75
+ finally {
76
+ rl.close();
77
+ }
78
+ }
79
+ /** Walks the whole first-run choice and returns what `start` needs. */
80
+ export async function onboard() {
81
+ const options = modeOptions();
82
+ const chosen = await select("How should your archives be stored?", options.map((option) => ({
83
+ label: option.title,
84
+ hint: option.mode === "free" ? "you own the bucket" : "we own the bucket, from $6/mo for 100 GB"
85
+ })));
86
+ const mode = options[chosen].mode;
87
+ process.stdout.write(`\n${green("✓")} ${bold(options[chosen].title)}\n`);
88
+ for (const line of options[chosen].weProvide)
89
+ process.stdout.write(` ${dim("·")} ${dim(line)}\n`);
90
+ process.stdout.write("\n");
91
+ if (mode === "paid") {
92
+ return {
93
+ mode: "paid",
94
+ email: await ask("Email"),
95
+ label: await ask("Name for this machine", "this machine"),
96
+ password: ""
97
+ };
98
+ }
99
+ const providerIndex = await select("Where is your bucket?", [
100
+ { label: "Google Cloud Storage", hint: "gcs" },
101
+ { label: "Amazon S3", hint: "s3" },
102
+ { label: "Cloudflare R2", hint: "r2, no egress fees" }
103
+ ]);
104
+ const provider = ["gcs", "s3", "r2"][providerIndex];
105
+ return {
106
+ mode: "free", provider,
107
+ bucket: await ask("Bucket name"),
108
+ prefix: await ask("Prefix", "vaultline"),
109
+ project: provider === "gcs" ? await ask("Google Cloud project") : undefined,
110
+ region: provider === "s3" ? await ask("Region", "us-east-1") : undefined,
111
+ accountId: provider === "r2" ? await ask("Cloudflare account id") : undefined
112
+ };
113
+ }
@@ -0,0 +1,75 @@
1
+ import { type CloudAccount } from "./cloud.js";
2
+ import { type SetupPlan } from "./storage-setup.js";
3
+ import type { ProviderKind } from "./control-plane.js";
4
+ /**
5
+ * First run.
6
+ *
7
+ * One command decides everything a person has to know: which tier they are on,
8
+ * where their bytes go, and the phrase that is the only way back into their
9
+ * vault. Both tiers encrypt on this machine. The tier only changes who runs the
10
+ * bucket.
11
+ */
12
+ export type Mode = "free" | "paid";
13
+ export type FreeChoice = {
14
+ mode: "free";
15
+ provider: ProviderKind;
16
+ bucket: string;
17
+ prefix: string;
18
+ region?: string;
19
+ accountId?: string;
20
+ project?: string;
21
+ };
22
+ export type PaidChoice = {
23
+ mode: "paid";
24
+ email: string;
25
+ password: string;
26
+ label: string;
27
+ };
28
+ export type StartChoice = FreeChoice | PaidChoice;
29
+ export type StartResult = {
30
+ mode: Mode;
31
+ vaultId: string;
32
+ /** Shown once. Never written to disk by us, never sent anywhere. */
33
+ phrase: string;
34
+ recoveryKit: string;
35
+ /** Free tier only: the bucket and credential steps to follow. */
36
+ storageSetup?: SetupPlan;
37
+ /** Paid tier only. */
38
+ account?: CloudAccount;
39
+ /** Paid tier only: sealed with the phrase, safe to hand to the server. */
40
+ escrow?: {
41
+ envelope: unknown;
42
+ ciphertext: Buffer;
43
+ };
44
+ nextSteps: string[];
45
+ };
46
+ /** An X25519 keypair for this machine. The private half never leaves it unsealed. */
47
+ export declare function generateDeviceKeys(): {
48
+ publicKeyBase64: string;
49
+ privateKeyBase64: string;
50
+ };
51
+ /**
52
+ * Free tier: the vault is local and the bucket is theirs.
53
+ *
54
+ * We do not ask for cloud credentials and we cannot see their data. The reward
55
+ * for that is that they do the bucket setup — so we generate the exact console
56
+ * links, commands, and least-privilege policy rather than leaving them to guess.
57
+ */
58
+ export declare function startFree(dataDir: string, choice: FreeChoice): Promise<StartResult>;
59
+ /**
60
+ * Paid tier: they configure nothing.
61
+ *
62
+ * We hold the bucket and the bill; they hold the only key that opens the data.
63
+ * The escrow blob makes a lost machine survivable without making us able to
64
+ * read anything — it is sealed with the phrase before it leaves here.
65
+ */
66
+ export declare function startPaid(dataDir: string, choice: PaidChoice): Promise<StartResult>;
67
+ export declare function start(dataDir: string, choice: StartChoice): Promise<StartResult>;
68
+ /** What the first-run UI offers, before anything is created. */
69
+ export declare function modeOptions(): {
70
+ mode: Mode;
71
+ title: string;
72
+ summary: string;
73
+ youProvide: string[];
74
+ weProvide: string[];
75
+ }[];