sealkeep 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +201 -0
- package/CHANGELOG.md +218 -0
- package/CONTROL_PLANE.md +86 -0
- package/LICENSE +34 -0
- package/README.md +249 -0
- package/THIRD_PARTY.md +22 -0
- package/THREAT_MODEL.md +107 -0
- package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
- package/dist/packages/vaultline-crypto/src/aead.js +24 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
- package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
- package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
- package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
- package/dist/packages/vaultline-crypto/src/format.js +43 -0
- package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
- package/dist/packages/vaultline-crypto/src/index.js +5 -0
- package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
- package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
- package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
- package/dist/packages/vaultline-crypto/src/stream.js +477 -0
- package/dist/site/index.html +1542 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +22 -0
- package/dist/src/activity.js +52 -0
- package/dist/src/adapters.d.ts +212 -0
- package/dist/src/adapters.js +533 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +41 -0
- package/dist/src/autopilot.d.ts +77 -0
- package/dist/src/autopilot.js +148 -0
- package/dist/src/bip39-wordlist.d.ts +15 -0
- package/dist/src/bip39-wordlist.js +272 -0
- package/dist/src/branding.d.ts +31 -0
- package/dist/src/branding.js +31 -0
- package/dist/src/chunk-store.d.ts +142 -0
- package/dist/src/chunk-store.js +502 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +2035 -0
- package/dist/src/cloud.d.ts +434 -0
- package/dist/src/cloud.js +851 -0
- package/dist/src/control-plane/auth.d.ts +62 -0
- package/dist/src/control-plane/auth.js +123 -0
- package/dist/src/control-plane/server.d.ts +31 -0
- package/dist/src/control-plane/server.js +263 -0
- package/dist/src/control-plane/store.d.ts +101 -0
- package/dist/src/control-plane/store.js +82 -0
- package/dist/src/control-plane-cli.d.ts +2 -0
- package/dist/src/control-plane-cli.js +37 -0
- package/dist/src/control-plane-server.d.ts +10 -0
- package/dist/src/control-plane-server.js +11 -0
- package/dist/src/control-plane.d.ts +78 -0
- package/dist/src/control-plane.js +61 -0
- package/dist/src/crypto.d.ts +56 -0
- package/dist/src/crypto.js +132 -0
- package/dist/src/daemon.d.ts +52 -0
- package/dist/src/daemon.js +142 -0
- package/dist/src/dashboard-cli.d.ts +2 -0
- package/dist/src/dashboard-cli.js +20 -0
- package/dist/src/disk.d.ts +110 -0
- package/dist/src/disk.js +169 -0
- package/dist/src/doctor.d.ts +11 -0
- package/dist/src/doctor.js +198 -0
- package/dist/src/enroll.d.ts +27 -0
- package/dist/src/enroll.js +136 -0
- package/dist/src/errors.d.ts +26 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/heartbeat.d.ts +89 -0
- package/dist/src/heartbeat.js +120 -0
- package/dist/src/index-sync.d.ts +53 -0
- package/dist/src/index-sync.js +147 -0
- package/dist/src/leakscan.d.ts +48 -0
- package/dist/src/leakscan.js +222 -0
- package/dist/src/local-api.d.ts +132 -0
- package/dist/src/local-api.js +1757 -0
- package/dist/src/managed-chunks.d.ts +55 -0
- package/dist/src/managed-chunks.js +108 -0
- package/dist/src/mcp-install.d.ts +52 -0
- package/dist/src/mcp-install.js +140 -0
- package/dist/src/mcp.d.ts +1 -0
- package/dist/src/mcp.js +59 -0
- package/dist/src/migrate.d.ts +35 -0
- package/dist/src/migrate.js +88 -0
- package/dist/src/mnemonic.d.ts +60 -0
- package/dist/src/mnemonic.js +134 -0
- package/dist/src/net.d.ts +2 -0
- package/dist/src/net.js +16 -0
- package/dist/src/notify.d.ts +46 -0
- package/dist/src/notify.js +84 -0
- package/dist/src/offload.d.ts +117 -0
- package/dist/src/offload.js +331 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +44 -0
- package/dist/src/packages.d.ts +126 -0
- package/dist/src/packages.js +114 -0
- package/dist/src/passkey.d.ts +26 -0
- package/dist/src/passkey.js +54 -0
- package/dist/src/password-lock.d.ts +19 -0
- package/dist/src/password-lock.js +156 -0
- package/dist/src/paths.d.ts +9 -0
- package/dist/src/paths.js +24 -0
- package/dist/src/providers/gcs.d.ts +133 -0
- package/dist/src/providers/gcs.js +235 -0
- package/dist/src/providers/gdrive.d.ts +156 -0
- package/dist/src/providers/gdrive.js +335 -0
- package/dist/src/providers/index.d.ts +45 -0
- package/dist/src/providers/index.js +74 -0
- package/dist/src/providers/s3.d.ts +174 -0
- package/dist/src/providers/s3.js +345 -0
- package/dist/src/providers/sigv4.d.ts +78 -0
- package/dist/src/providers/sigv4.js +112 -0
- package/dist/src/queue.d.ts +185 -0
- package/dist/src/queue.js +286 -0
- package/dist/src/recovery.d.ts +40 -0
- package/dist/src/recovery.js +132 -0
- package/dist/src/rehydrate.d.ts +43 -0
- package/dist/src/rehydrate.js +66 -0
- package/dist/src/restore.d.ts +34 -0
- package/dist/src/restore.js +80 -0
- package/dist/src/retention.d.ts +251 -0
- package/dist/src/retention.js +446 -0
- package/dist/src/rotate.d.ts +47 -0
- package/dist/src/rotate.js +95 -0
- package/dist/src/search.d.ts +147 -0
- package/dist/src/search.js +677 -0
- package/dist/src/secrets.d.ts +86 -0
- package/dist/src/secrets.js +220 -0
- package/dist/src/service.d.ts +73 -0
- package/dist/src/service.js +197 -0
- package/dist/src/share.d.ts +34 -0
- package/dist/src/share.js +68 -0
- package/dist/src/spool.d.ts +97 -0
- package/dist/src/spool.js +213 -0
- package/dist/src/start-tui.d.ts +17 -0
- package/dist/src/start-tui.js +113 -0
- package/dist/src/start.d.ts +75 -0
- package/dist/src/start.js +101 -0
- package/dist/src/storage-setup.d.ts +49 -0
- package/dist/src/storage-setup.js +222 -0
- package/dist/src/storage-targets.d.ts +40 -0
- package/dist/src/storage-targets.js +147 -0
- package/dist/src/stream-to-cloud.d.ts +76 -0
- package/dist/src/stream-to-cloud.js +820 -0
- package/dist/src/sync-rules.d.ts +85 -0
- package/dist/src/sync-rules.js +125 -0
- package/dist/src/trash.d.ts +15 -0
- package/dist/src/trash.js +63 -0
- package/dist/src/tui.d.ts +18 -0
- package/dist/src/tui.js +179 -0
- package/dist/src/types.d.ts +191 -0
- package/dist/src/types.js +3 -0
- package/dist/src/ui-server.d.ts +187 -0
- package/dist/src/ui-server.js +293 -0
- package/dist/src/ui.d.ts +41 -0
- package/dist/src/ui.js +102 -0
- package/dist/src/update.d.ts +30 -0
- package/dist/src/update.js +56 -0
- package/dist/src/upload.d.ts +46 -0
- package/dist/src/upload.js +80 -0
- package/dist/src/vault.d.ts +208 -0
- package/dist/src/vault.js +812 -0
- package/dist/src/watcher.d.ts +34 -0
- package/dist/src/watcher.js +121 -0
- package/dist/src/worker.d.ts +52 -0
- package/dist/src/worker.js +190 -0
- package/package.json +65 -0
- package/web/app.js +1372 -0
- package/web/index.html +476 -0
- package/web/rail.js +308 -0
- package/web/retention.html +17 -0
- package/web/rules-view.js +249 -0
- package/web/sessions-view.js +448 -0
- package/web/sessions.html +17 -0
- package/web/setup-api.js +181 -0
- package/web/setup-logic.js +394 -0
- package/web/setup.html +419 -0
- package/web/setup.js +697 -0
- package/web/style.css +990 -0
|
@@ -0,0 +1,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
|
+
}[];
|