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,37 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { createControlPlaneServer } from "./control-plane/server.js";
5
+ import { defaultDataDir } from "./vault.js";
6
+ import { freePort } from "./net.js";
7
+ /**
8
+ * Runs the control plane.
9
+ *
10
+ * Authentication is on by default. `VAULTLINE_CONTROL_PLANE_DEV=1` turns it off for
11
+ * local interface work and the banner says so loudly, because an unauthenticated
12
+ * instance reachable from a network would let anyone enumerate account metadata.
13
+ */
14
+ const development = process.env.VAULTLINE_CONTROL_PLANE_DEV === "1";
15
+ const storePath = process.env.VAULTLINE_CONTROL_PLANE_STORE ?? join(defaultDataDir(), "control-plane", "state.json");
16
+ const tls = process.env.VAULTLINE_TLS_CERT && process.env.VAULTLINE_TLS_KEY
17
+ ? { cert: readFileSync(process.env.VAULTLINE_TLS_CERT), key: readFileSync(process.env.VAULTLINE_TLS_KEY) }
18
+ : undefined;
19
+ const server = createControlPlaneServer({
20
+ storePath: development ? undefined : storePath,
21
+ requireAuth: !development,
22
+ tls,
23
+ onLog: (line) => console.log(JSON.stringify(line))
24
+ });
25
+ async function listen() {
26
+ const port = await freePort(Number(process.env.PORT ?? "8787"));
27
+ const host = process.env.HOST ?? "127.0.0.1";
28
+ server.once("error", (error) => { console.error(`Unable to start control-plane on port ${port}: ${error.message}`); process.exitCode = 1; });
29
+ server.listen(port, host, () => {
30
+ console.log(`Sealkeep control plane on ${tls ? "https" : "http"}://${host}:${port}`);
31
+ if (development)
32
+ console.log("MODE: development — device authentication is DISABLED and state is in memory. Do not expose this.");
33
+ else
34
+ console.log(`Durable state: ${storePath}${tls ? "" : "\nWARNING: no TLS configured. Set VAULTLINE_TLS_CERT and VAULTLINE_TLS_KEY before accepting non-loopback traffic."}`);
35
+ });
36
+ }
37
+ void listen();
@@ -0,0 +1,10 @@
1
+ import { createControlPlaneServer as createServer, type ControlPlaneOptions } from "./control-plane/server.js";
2
+ /**
3
+ * Development interface: the same control plane with device signatures disabled and
4
+ * state kept in memory. It exists so local surfaces can be built against the real
5
+ * contract. Never deploy it — a deployment must use `createControlPlaneServer`
6
+ * from `src/control-plane/server.ts` with authentication left on.
7
+ */
8
+ export declare function createDevelopmentControlPlane(options?: Omit<ControlPlaneOptions, "requireAuth">): import("http").Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>;
9
+ export { createServer as createControlPlaneServer };
10
+ export type { ControlPlaneOptions };
@@ -0,0 +1,11 @@
1
+ import { createControlPlaneServer as createServer } from "./control-plane/server.js";
2
+ /**
3
+ * Development interface: the same control plane with device signatures disabled and
4
+ * state kept in memory. It exists so local surfaces can be built against the real
5
+ * contract. Never deploy it — a deployment must use `createControlPlaneServer`
6
+ * from `src/control-plane/server.ts` with authentication left on.
7
+ */
8
+ export function createDevelopmentControlPlane(options = {}) {
9
+ return createServer({ ...options, requireAuth: false });
10
+ }
11
+ export { createServer as createControlPlaneServer };
@@ -0,0 +1,78 @@
1
+ export type ProviderKind = "vaultline" | "s3" | "r2" | "b2" | "gcs" | "gdrive";
2
+ export type ProviderConfig = {
3
+ provider: ProviderKind;
4
+ bucket: string;
5
+ region?: string;
6
+ prefix: string;
7
+ };
8
+ export type LeaseStatus = "pending-signer" | "active";
9
+ export type UploadLease = {
10
+ archiveId: string;
11
+ provider: ProviderKind;
12
+ objectKey: string;
13
+ expiresAt: string;
14
+ /** `CREDENTIALS` delegates by scoped credential instead of by signed URL: the machine signs its own streaming requests. No signer produces it yet — see docs/managed-streaming-contract.md. */
15
+ method: "PUT" | "MULTIPART" | "RESUMABLE" | "CREDENTIALS";
16
+ uploadUrl: string;
17
+ requiredHeaders: Record<string, string>;
18
+ /**
19
+ * Present only when `method` is `CREDENTIALS`: a short-lived S3-compatible
20
+ * key scoped to this account's prefix, which the client feeds to its own
21
+ * `S3UploadClient` for multipart streaming with resume. Types only for now —
22
+ * the contract the control plane must implement is documented in
23
+ * docs/managed-streaming-contract.md.
24
+ */
25
+ vendedCredentials?: {
26
+ endpoint: string;
27
+ region: string;
28
+ accessKeyId: string;
29
+ secretAccessKey: string;
30
+ sessionToken?: string;
31
+ expiresAt: string;
32
+ };
33
+ /** `pending-signer` until credential-backed signing exists. It is never `active` in this build. */
34
+ status: LeaseStatus;
35
+ uploadable: boolean;
36
+ };
37
+ export type LeaseInput = {
38
+ archiveId?: string;
39
+ ciphertextSha256: string;
40
+ bytes: number;
41
+ now?: number;
42
+ ttlMs?: number;
43
+ };
44
+ export declare const DEFAULT_LEASE_TTL_MS: number;
45
+ export interface StorageLeaseProvider {
46
+ validate(config: ProviderConfig): void;
47
+ createUploadLease(config: ProviderConfig, input: LeaseInput): UploadLease;
48
+ }
49
+ export declare const providers: Record<ProviderKind, StorageLeaseProvider>;
50
+ export declare function leaseExpired(lease: UploadLease, now?: number): boolean;
51
+ /**
52
+ * The single gate every upload path must pass. Expiry is checked first so a
53
+ * replayed lease is refused as expired rather than as an unsigned lease.
54
+ */
55
+ export declare function assertLeaseUsable(lease: UploadLease, now?: number): void;
56
+ /** Feature flag for provider work in progress. Off by default and read per call so tests and operators can flip it. */
57
+ export declare function signerEnabled(env?: NodeJS.ProcessEnv): boolean;
58
+ /**
59
+ * The contract a real S3/R2/GCS client will implement. Credentials belong to the
60
+ * signer service, never to this process, so no implementation ships in this build.
61
+ */
62
+ export interface ProviderUploadClient {
63
+ readonly kind: ProviderKind;
64
+ upload(lease: UploadLease, ciphertext: Buffer): Promise<{
65
+ remoteChecksum: string;
66
+ bytes: number;
67
+ }>;
68
+ head(lease: UploadLease): Promise<{
69
+ exists: boolean;
70
+ bytes: number;
71
+ checksum?: string;
72
+ }>;
73
+ }
74
+ /** Registration is refused unless the operator explicitly enabled signer work. */
75
+ export declare function registerUploadClient(client: ProviderUploadClient, env?: NodeJS.ProcessEnv): void;
76
+ export declare function uploadClientFor(kind: ProviderKind, env?: NodeJS.ProcessEnv): ProviderUploadClient;
77
+ /** Test and operator hook so a flag flip cannot leak state between runs. */
78
+ export declare function clearUploadClients(): void;
@@ -0,0 +1,61 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { fail } from "./errors.js";
3
+ export const DEFAULT_LEASE_TTL_MS = 15 * 60_000;
4
+ class PlannedProvider {
5
+ provider;
6
+ method;
7
+ constructor(provider, method) {
8
+ this.provider = provider;
9
+ this.method = method;
10
+ }
11
+ validate(config) { if (!config.bucket || !config.prefix)
12
+ fail("invalid_argument", "bucket and prefix are required"); }
13
+ createUploadLease(config, input) {
14
+ this.validate(config);
15
+ const archiveId = input.archiveId ?? randomUUID();
16
+ const issuedAt = input.now ?? Date.now();
17
+ // Deliberately non-functional until real credential-backed signers are configured.
18
+ return {
19
+ archiveId, provider: this.provider,
20
+ objectKey: `${config.prefix.replace(/\/$/, "")}/${archiveId}.vlarchive`,
21
+ expiresAt: new Date(issuedAt + (input.ttlMs ?? DEFAULT_LEASE_TTL_MS)).toISOString(),
22
+ method: this.method,
23
+ uploadUrl: `vaultline+pending://${this.provider}/${config.bucket}/${archiveId}`,
24
+ requiredHeaders: { "content-type": "application/vnd.vaultline.ciphertext", "x-vaultline-ciphertext-sha256": input.ciphertextSha256, "content-length": String(input.bytes) },
25
+ status: "pending-signer", uploadable: false
26
+ };
27
+ }
28
+ }
29
+ export const providers = {
30
+ vaultline: new PlannedProvider("vaultline", "MULTIPART"), s3: new PlannedProvider("s3", "MULTIPART"), r2: new PlannedProvider("r2", "MULTIPART"), b2: new PlannedProvider("b2", "MULTIPART"), gcs: new PlannedProvider("gcs", "RESUMABLE"), gdrive: new PlannedProvider("gdrive", "RESUMABLE")
31
+ };
32
+ export function leaseExpired(lease, now = Date.now()) {
33
+ return !(Date.parse(lease.expiresAt) > now);
34
+ }
35
+ /**
36
+ * The single gate every upload path must pass. Expiry is checked first so a
37
+ * replayed lease is refused as expired rather than as an unsigned lease.
38
+ */
39
+ export function assertLeaseUsable(lease, now = Date.now()) {
40
+ if (leaseExpired(lease, now))
41
+ fail("lease_expired", `Upload lease for ${lease.archiveId} expired at ${lease.expiresAt}`, { archiveId: lease.archiveId, expiresAt: lease.expiresAt });
42
+ if (!lease.uploadable || lease.status !== "active")
43
+ fail("signer_not_configured", "This lease cannot upload: no credential-backed signer is configured", { provider: lease.provider, archiveId: lease.archiveId });
44
+ }
45
+ /** Feature flag for provider work in progress. Off by default and read per call so tests and operators can flip it. */
46
+ export function signerEnabled(env = process.env) {
47
+ return env.VAULTLINE_ENABLE_SIGNER === "1";
48
+ }
49
+ const uploadClients = new Map();
50
+ /** Registration is refused unless the operator explicitly enabled signer work. */
51
+ export function registerUploadClient(client, env = process.env) {
52
+ if (!signerEnabled(env))
53
+ fail("signer_not_configured", "Set VAULTLINE_ENABLE_SIGNER=1 to register a provider upload client", { provider: client.kind });
54
+ uploadClients.set(client.kind, client);
55
+ }
56
+ export function uploadClientFor(kind, env = process.env) {
57
+ const client = signerEnabled(env) ? uploadClients.get(kind) : undefined;
58
+ return client ?? fail("signer_not_configured", `No upload client is available for ${kind}. Sealkeep cannot upload until a reviewed signer with isolated credentials exists.`, { provider: kind, signerEnabled: signerEnabled(env) });
59
+ }
60
+ /** Test and operator hook so a flag flip cannot leak state between runs. */
61
+ export function clearUploadClients() { uploadClients.clear(); }
@@ -0,0 +1,56 @@
1
+ export declare function sha256(input: Buffer): string;
2
+ /**
3
+ * @deprecated Defect #49: v1's stored phrase check was one unsalted SHA-256 of
4
+ * the phrase -- 0.74us to evaluate, versus 67.8ms for the envelope's scrypt.
5
+ * Five guesses recovered a user-chosen phrase straight from config.json.
6
+ * Kept so `upgradeLegacyPhraseCheck` can still recognise and re-derive from an
7
+ * old vault's stored value; nothing should compare a live phrase against this
8
+ * directly any more -- use `phraseCheck` or `matchesPhraseCheck`.
9
+ */
10
+ export declare function legacyPhraseCheck(phrase: string): string;
11
+ /**
12
+ * Confirms a typed phrase before any archive is touched. Defect #49: replaces
13
+ * a single cheap SHA-256 with the same scrypt cost `recoveryKey` charges the
14
+ * envelope, chained on top of `legacyPhraseCheck` rather than the raw phrase.
15
+ * Chaining on the digest (not the phrase) is what lets `upgradeLegacyPhraseCheck`
16
+ * promote an old vault's stored value using nothing but that value -- no
17
+ * phrase required, see there.
18
+ */
19
+ export declare function phraseCheck(phrase: string): string;
20
+ /** True for a config still holding a bare v1 digest (32 bytes); false once `phraseCheck` has tagged it (33). */
21
+ export declare function isLegacyPhraseCheck(stored: string): boolean;
22
+ /**
23
+ * Promotes a v1 vault's stored digest to the v2 (cost-matched) format in
24
+ * place. Takes the OLD digest, not the phrase -- see `phraseCheck` -- which is
25
+ * what lets `readConfig` call this unconditionally on every legacy config it
26
+ * reads: no vault is stranded waiting on its owner to run a migration command,
27
+ * or even to unlock it first.
28
+ */
29
+ export declare function upgradeLegacyPhraseCheck(stored: string): string;
30
+ /** Verifies a typed phrase against a stored check, whichever format it is in. */
31
+ export declare function matchesPhraseCheck(stored: string, phrase: string): boolean;
32
+ export declare function equalHex(left: string, right: string): boolean;
33
+ /** @deprecated Format v1. Retained so existing archives stay readable and migratable. */
34
+ export declare function encryptLegacyArchive(plaintext: Buffer, phrase: string): {
35
+ ciphertext: Buffer<ArrayBuffer>;
36
+ nonce: string;
37
+ authTag: string;
38
+ wrappedKey: {
39
+ algorithm: "scrypt-aes-256-gcm";
40
+ salt: string;
41
+ nonce: string;
42
+ authTag: string;
43
+ ciphertext: string;
44
+ };
45
+ };
46
+ /** @deprecated Format v1 reader. New archives use `vaultline-crypto`. */
47
+ export declare function decryptLegacyArchive(ciphertext: Buffer, envelope: {
48
+ nonce: string;
49
+ authTag: string;
50
+ wrappedKey: {
51
+ salt: string;
52
+ nonce: string;
53
+ authTag: string;
54
+ ciphertext: string;
55
+ };
56
+ }, phrase: string): Buffer;
@@ -0,0 +1,132 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync, createHash, timingSafeEqual } from "node:crypto";
2
+ const KEY_BYTES = 32;
3
+ const NONCE_BYTES = 12;
4
+ const TAG_BYTES = 16;
5
+ /** Cost parameters shared by every phrase-derived key in this file: ~68ms and ~32MB per guess. */
6
+ const SCRYPT_PARAMS = { N: 1 << 15, r: 8, p: 1, maxmem: 128 * 1024 * 1024 };
7
+ export function sha256(input) {
8
+ return createHash("sha256").update(input).digest("hex");
9
+ }
10
+ function recoveryKey(phrase, salt) {
11
+ return scryptSync(phrase.normalize("NFKD"), salt, KEY_BYTES, SCRYPT_PARAMS);
12
+ }
13
+ /**
14
+ * @deprecated Defect #49: v1's stored phrase check was one unsalted SHA-256 of
15
+ * the phrase -- 0.74us to evaluate, versus 67.8ms for the envelope's scrypt.
16
+ * Five guesses recovered a user-chosen phrase straight from config.json.
17
+ * Kept so `upgradeLegacyPhraseCheck` can still recognise and re-derive from an
18
+ * old vault's stored value; nothing should compare a live phrase against this
19
+ * directly any more -- use `phraseCheck` or `matchesPhraseCheck`.
20
+ */
21
+ export function legacyPhraseCheck(phrase) {
22
+ return sha256(Buffer.from(`vaultline-recovery-check:v1:${phrase.normalize("NFKD")}`));
23
+ }
24
+ // Fixed rather than random. `vault.ts`'s archiveFile (out of scope for defect
25
+ // #49 -- it is changing concurrently) compares a typed phrase against
26
+ // `phraseCheck(phrase)` with no config object in hand, so there is nowhere to
27
+ // plumb a per-vault salt through. A shared pepper cannot stop one attacker
28
+ // amortising a table across every vault, but scrypt's memory cost still prices
29
+ // building that table the same as attacking every vault individually would --
30
+ // which is the actual gap this closes: config.json being *cheaper* to attack
31
+ // than the envelope sitting right next to it, not being unsalted.
32
+ const PHRASE_CHECK_PEPPER = Buffer.from("vaultline-recovery-check:v2:pepper");
33
+ // Tags a stored value as v2 so `isLegacyPhraseCheck` can tell it apart from a
34
+ // bare v1 digest without needing a version field of its own (see
35
+ // `upgradeLegacyPhraseCheck`, which has no version field to read either).
36
+ const PHRASE_CHECK_TAG = "02";
37
+ /**
38
+ * In-process memo, keyed by the v1 digest rather than the phrase, so the map
39
+ * never holds the secret itself.
40
+ *
41
+ * The scrypt cost is the whole point of this check against an attacker holding
42
+ * config.json — but archiveFile verifies on *every* archive, and paying it per
43
+ * file doubled the cost of archiving: measured 70.5ms to 134.9ms per archive,
44
+ * which is 26 seconds against 49 across the 365 sessions on this machine, and
45
+ * a cost the daemon would pay forever. An attacker is not inside this process,
46
+ * so caching here costs them nothing; a legitimate caller archiving a thousand
47
+ * files pays scrypt once. Bounded in practice by how many distinct phrases one
48
+ * process handles, which is one.
49
+ */
50
+ const strengthened = new Map();
51
+ function strengthen(legacyDigestHex) {
52
+ const cached = strengthened.get(legacyDigestHex);
53
+ if (cached !== undefined)
54
+ return cached;
55
+ const derived = scryptSync(Buffer.from(legacyDigestHex, "hex"), PHRASE_CHECK_PEPPER, KEY_BYTES, SCRYPT_PARAMS);
56
+ const tagged = PHRASE_CHECK_TAG + derived.toString("hex");
57
+ strengthened.set(legacyDigestHex, tagged);
58
+ return tagged;
59
+ }
60
+ /**
61
+ * Confirms a typed phrase before any archive is touched. Defect #49: replaces
62
+ * a single cheap SHA-256 with the same scrypt cost `recoveryKey` charges the
63
+ * envelope, chained on top of `legacyPhraseCheck` rather than the raw phrase.
64
+ * Chaining on the digest (not the phrase) is what lets `upgradeLegacyPhraseCheck`
65
+ * promote an old vault's stored value using nothing but that value -- no
66
+ * phrase required, see there.
67
+ */
68
+ export function phraseCheck(phrase) {
69
+ return strengthen(legacyPhraseCheck(phrase));
70
+ }
71
+ /** True for a config still holding a bare v1 digest (32 bytes); false once `phraseCheck` has tagged it (33). */
72
+ export function isLegacyPhraseCheck(stored) {
73
+ return Buffer.from(stored, "hex").length === KEY_BYTES;
74
+ }
75
+ /**
76
+ * Promotes a v1 vault's stored digest to the v2 (cost-matched) format in
77
+ * place. Takes the OLD digest, not the phrase -- see `phraseCheck` -- which is
78
+ * what lets `readConfig` call this unconditionally on every legacy config it
79
+ * reads: no vault is stranded waiting on its owner to run a migration command,
80
+ * or even to unlock it first.
81
+ */
82
+ export function upgradeLegacyPhraseCheck(stored) {
83
+ return strengthen(stored);
84
+ }
85
+ /** Verifies a typed phrase against a stored check, whichever format it is in. */
86
+ export function matchesPhraseCheck(stored, phrase) {
87
+ return isLegacyPhraseCheck(stored) ? equalHex(stored, legacyPhraseCheck(phrase)) : equalHex(stored, phraseCheck(phrase));
88
+ }
89
+ export function equalHex(left, right) {
90
+ const a = Buffer.from(left, "hex");
91
+ const b = Buffer.from(right, "hex");
92
+ return a.length === b.length && timingSafeEqual(a, b);
93
+ }
94
+ /** @deprecated Format v1. Retained so existing archives stay readable and migratable. */
95
+ export function encryptLegacyArchive(plaintext, phrase) {
96
+ const archiveKey = randomBytes(KEY_BYTES);
97
+ const nonce = randomBytes(NONCE_BYTES);
98
+ const cipher = createCipheriv("aes-256-gcm", archiveKey, nonce, { authTagLength: TAG_BYTES });
99
+ cipher.setAAD(Buffer.from("vaultline-archive:v1"));
100
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
101
+ const authTag = cipher.getAuthTag();
102
+ const salt = randomBytes(16);
103
+ const wrapNonce = randomBytes(NONCE_BYTES);
104
+ const wrappingKey = recoveryKey(phrase, salt);
105
+ const wrapper = createCipheriv("aes-256-gcm", wrappingKey, wrapNonce, { authTagLength: TAG_BYTES });
106
+ wrapper.setAAD(Buffer.from("vaultline-wrapped-key:v1"));
107
+ const wrapped = Buffer.concat([wrapper.update(archiveKey), wrapper.final()]);
108
+ return {
109
+ ciphertext,
110
+ nonce: nonce.toString("base64url"),
111
+ authTag: authTag.toString("base64url"),
112
+ wrappedKey: {
113
+ algorithm: "scrypt-aes-256-gcm",
114
+ salt: salt.toString("base64url"),
115
+ nonce: wrapNonce.toString("base64url"),
116
+ authTag: wrapper.getAuthTag().toString("base64url"),
117
+ ciphertext: wrapped.toString("base64url")
118
+ }
119
+ };
120
+ }
121
+ /** @deprecated Format v1 reader. New archives use `vaultline-crypto`. */
122
+ export function decryptLegacyArchive(ciphertext, envelope, phrase) {
123
+ const wrappingKey = recoveryKey(phrase, Buffer.from(envelope.wrappedKey.salt, "base64url"));
124
+ const unwrap = createDecipheriv("aes-256-gcm", wrappingKey, Buffer.from(envelope.wrappedKey.nonce, "base64url"), { authTagLength: TAG_BYTES });
125
+ unwrap.setAAD(Buffer.from("vaultline-wrapped-key:v1"));
126
+ unwrap.setAuthTag(Buffer.from(envelope.wrappedKey.authTag, "base64url"));
127
+ const archiveKey = Buffer.concat([unwrap.update(Buffer.from(envelope.wrappedKey.ciphertext, "base64url")), unwrap.final()]);
128
+ const decipher = createDecipheriv("aes-256-gcm", archiveKey, Buffer.from(envelope.nonce, "base64url"), { authTagLength: TAG_BYTES });
129
+ decipher.setAAD(Buffer.from("vaultline-archive:v1"));
130
+ decipher.setAuthTag(Buffer.from(envelope.authTag, "base64url"));
131
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
132
+ }
@@ -0,0 +1,52 @@
1
+ import { ArchiveQueue } from "./queue.js";
2
+ import { type NotifyOptions } from "./notify.js";
3
+ export type DaemonOptions = {
4
+ phrase: string;
5
+ intervalMs?: number;
6
+ home?: string;
7
+ watch?: boolean;
8
+ upload?: boolean;
9
+ reclaim?: boolean;
10
+ queue?: ArchiveQueue;
11
+ /** Pause archiving when the disk has less free space than this. Reclamation still runs. */
12
+ diskPressureFreeBytes?: number;
13
+ /** Passed to the per-job check; see DrainOptions.reserveBytes. */
14
+ reserveBytes?: number;
15
+ /** How often to re-walk the transcript roots as a safety net. */
16
+ rescanEveryMs?: number;
17
+ onTick?: (result: TickResult) => void;
18
+ /** Desktop notifications. Omit to follow the environment; false to stay silent. */
19
+ notifications?: boolean;
20
+ notify?: NotifyOptions;
21
+ now?: () => number;
22
+ };
23
+ export type TickResult = {
24
+ at: string;
25
+ paused: boolean;
26
+ archived: number;
27
+ failed: number;
28
+ uploaded: number;
29
+ uploadErrors: number;
30
+ reclaimed: number;
31
+ freeBytes?: number;
32
+ notes: string[];
33
+ };
34
+ export type Daemon = {
35
+ tick(): Promise<TickResult>;
36
+ pause(): void;
37
+ resume(): void;
38
+ status(): Promise<{
39
+ paused: boolean;
40
+ queue: Awaited<ReturnType<ArchiveQueue["stats"]>>;
41
+ watching: string[];
42
+ }>;
43
+ close(): Promise<void>;
44
+ };
45
+ /**
46
+ * The long-running local agent: watch, archive, upload, then reclaim.
47
+ *
48
+ * Each stage is independent and failure-isolated, so a provider outage cannot stop
49
+ * archiving and a retention misconfiguration cannot stop uploads. `tick()` is
50
+ * exposed directly so behaviour can be tested without waiting on timers.
51
+ */
52
+ export declare function startDaemon(dataDir: string, options: DaemonOptions): Promise<Daemon>;
@@ -0,0 +1,142 @@
1
+ import { freeBytes, resolveReserveBytes } from "./disk.js";
2
+ import { ArchiveQueue } from "./queue.js";
3
+ import { applyRetention, retentionSettings } from "./retention.js";
4
+ import { readConfig } from "./vault.js";
5
+ import { drainQueue } from "./worker.js";
6
+ import { uploadAndOffloadPending } from "./heartbeat.js";
7
+ import { signerEnabled } from "./control-plane.js";
8
+ import { startTranscriptWatcher } from "./watcher.js";
9
+ import { isVaultlineError } from "./errors.js";
10
+ import { writeHeartbeat } from "./heartbeat.js";
11
+ import { notify, NotificationGate, summarize } from "./notify.js";
12
+ // One definition, shared with the worker's per-job check in src/disk.ts. Two
13
+ // copies of "how much room is there" is how the two came to disagree about
14
+ // whether there was any.
15
+ /**
16
+ * The long-running local agent: watch, archive, upload, then reclaim.
17
+ *
18
+ * Each stage is independent and failure-isolated, so a provider outage cannot stop
19
+ * archiving and a retention misconfiguration cannot stop uploads. `tick()` is
20
+ * exposed directly so behaviour can be tested without waiting on timers.
21
+ */
22
+ export async function startDaemon(dataDir, options) {
23
+ const queue = options.queue ?? new ArchiveQueue(dataDir);
24
+ const intervalMs = options.intervalMs ?? 30_000;
25
+ const now = options.now ?? Date.now;
26
+ const startedAt = new Date(now()).toISOString();
27
+ const totals = { archived: 0, uploaded: 0, reclaimed: 0, failed: 0 };
28
+ const gate = new NotificationGate();
29
+ const rescanEveryMs = options.rescanEveryMs ?? 60_000;
30
+ let lastScanAt = 0;
31
+ let paused = false;
32
+ let watcher;
33
+ if (options.watch !== false) {
34
+ watcher = await startTranscriptWatcher(dataDir, { home: options.home, queue });
35
+ await watcher.scan();
36
+ lastScanAt = now();
37
+ }
38
+ async function tick() {
39
+ const notes = [];
40
+ const free = await freeBytes(dataDir);
41
+ const result = { at: new Date(now()).toISOString(), paused, archived: 0, failed: 0, uploaded: 0, uploadErrors: 0, reclaimed: 0, freeBytes: free, notes };
42
+ let freedBytes = 0;
43
+ if (paused) {
44
+ notes.push("daemon is paused; no work was done");
45
+ await beat(result);
46
+ options.onTick?.(result);
47
+ return result;
48
+ }
49
+ // File-system watchers drop events: FSEvents coalesces, inotify runs out of
50
+ // watches, and a watcher can die with its directory. Re-walking the roots on a
51
+ // slow cadence means a missed event costs latency, never a lost session. The
52
+ // walk is cheap because content-addressed ids make re-queuing a no-op.
53
+ if (watcher && now() - lastScanAt >= rescanEveryMs) {
54
+ const found = await watcher.scan().catch(() => 0);
55
+ lastScanAt = now();
56
+ if (found)
57
+ notes.push(`rescanned transcript roots (${found} seen)`);
58
+ }
59
+ // This used to read `options.diskPressureFreeBytes` with no default, and
60
+ // nothing anywhere set it — so the guard was dead code and the installed
61
+ // service ran with no floor at all, on machines chosen for being short of
62
+ // space. It now defaults, and `drainQueue` additionally refuses any single
63
+ // job it has no room to finish, because one floor cannot know that the next
64
+ // transcript is 4 GB.
65
+ // One knob where there could be two: an operator who set a reserve meant it
66
+ // for both checks, and a tick-level floor that ignored it would refuse
67
+ // everything while the per-job check was happy to proceed.
68
+ const floor = options.diskPressureFreeBytes ?? options.reserveBytes ?? await resolveReserveBytes(dataDir);
69
+ if (free !== undefined && free < floor) {
70
+ notes.push(`disk is low (${(free / 1024 ** 3).toFixed(1)} GB free, holding ${(floor / 1024 ** 3).toFixed(1)} GB back); reclaiming instead of archiving`);
71
+ }
72
+ else {
73
+ const processed = await drainQueue(dataDir, options.phrase, { queue, reserveBytes: options.reserveBytes });
74
+ result.archived = processed.filter((job) => job.status === "done").length;
75
+ result.failed = processed.filter((job) => job.status !== "done" && job.lastError?.code !== "insufficient_disk_space").length;
76
+ const waiting = processed.filter((job) => job.lastError?.code === "insufficient_disk_space").length;
77
+ if (waiting)
78
+ notes.push(`${waiting} session${waiting === 1 ? "" : "s"} left queued: not enough free space to seal ${waiting === 1 ? "it" : "them"} safely`);
79
+ }
80
+ if (options.upload !== false && signerEnabled()) {
81
+ try {
82
+ // Upload, verify, then offload: a verified push whose blob stays on
83
+ // this disk has not freed anything, and this daemon exists to free it.
84
+ const uploads = await uploadAndOffloadPending(dataDir);
85
+ result.uploaded = uploads.uploaded.length;
86
+ result.uploadErrors = uploads.failed.length;
87
+ if (uploads.freedBytes > 0)
88
+ notes.push(`freed ${(uploads.freedBytes / 1073741824).toFixed(1)} GB of sealed copies this disk no longer needs`);
89
+ }
90
+ catch (error) {
91
+ result.uploadErrors += 1;
92
+ notes.push(`upload pass failed: ${isVaultlineError(error) ? error.code : "unexpected error"}`);
93
+ }
94
+ }
95
+ else if (options.upload !== false) {
96
+ notes.push("uploads are disabled: no signer is enabled");
97
+ }
98
+ if (options.reclaim) {
99
+ const config = await readConfig(dataDir);
100
+ const settings = retentionSettings(config);
101
+ if (settings.policy === "archive-and-reclaim" || settings.policy === "manual-approval") {
102
+ const applied = await applyRetention(dataDir, { confirm: true, home: options.home });
103
+ result.reclaimed = applied.mode === "apply" ? applied.reclaimed.length : 0;
104
+ freedBytes = applied.mode === "apply" ? applied.freedBytes : 0;
105
+ }
106
+ else {
107
+ notes.push(`retention policy ${settings.policy} never reclaims`);
108
+ }
109
+ }
110
+ totals.archived += result.archived;
111
+ totals.uploaded += result.uploaded;
112
+ totals.reclaimed += result.reclaimed;
113
+ totals.failed += result.failed;
114
+ await beat(result);
115
+ // One notification at most per round, and only for something worth interrupting for.
116
+ const summary = summarize({ archived: result.archived, uploaded: result.uploaded, reclaimed: result.reclaimed, failed: result.failed, freedBytes });
117
+ if (summary && gate.allow(now(), result.failed > 0 || result.reclaimed > 0)) {
118
+ await notify(summary, { ...options.notify, enabled: options.notifications ?? options.notify?.enabled });
119
+ }
120
+ options.onTick?.(result);
121
+ return result;
122
+ }
123
+ /** Records the sign of life every other surface reads. */
124
+ async function beat(result) {
125
+ const state = {
126
+ version: 1, pid: process.pid, startedAt, lastTickAt: result.at, intervalMs,
127
+ totals: { ...totals },
128
+ last: { archived: result.archived, uploaded: result.uploaded, reclaimed: result.reclaimed, failed: result.failed, notes: result.notes },
129
+ paused, watching: watcher?.roots ?? []
130
+ };
131
+ await writeHeartbeat(dataDir, state).catch(() => undefined);
132
+ }
133
+ const timer = setInterval(() => { void tick().catch(() => undefined); }, intervalMs);
134
+ timer.unref?.();
135
+ return {
136
+ tick,
137
+ pause: () => { paused = true; },
138
+ resume: () => { paused = false; },
139
+ status: async () => ({ paused, queue: await queue.stats(), watching: watcher?.roots ?? [] }),
140
+ close: async () => { clearInterval(timer); await watcher?.close(); }
141
+ };
142
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // `npm run dashboard` is a thin alias for the local API, which serves the dashboard
3
+ // from the same origin. Keeping one server means one authentication path and no
4
+ // second copy of the business logic.
5
+ import { defaultDataDir } from "./vault.js";
6
+ import { createLocalApiServer, localApiToken, localApiTokenPath } from "./local-api.js";
7
+ import { freePort } from "./net.js";
8
+ const dataDir = process.env.VAULTLINE_DATA_DIR ?? defaultDataDir();
9
+ async function listen() {
10
+ const token = await localApiToken(dataDir);
11
+ const server = createLocalApiServer(dataDir, token);
12
+ const port = await freePort(Number(process.env.PORT ?? "4173"));
13
+ server.once("error", (error) => { console.error(`Unable to start dashboard on port ${port}: ${error.message}`); process.exitCode = 1; });
14
+ server.listen(port, "127.0.0.1", () => {
15
+ // The token travels in the URL fragment, which the browser never sends to a server.
16
+ console.log(`Sealkeep dashboard: http://127.0.0.1:${port}/#token=${token}`);
17
+ console.log(`Token file (do not share): ${localApiTokenPath(dataDir)}`);
18
+ });
19
+ }
20
+ void listen();