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,54 @@
1
+ import { createPublicKey, hkdfSync, randomBytes } from "node:crypto";
2
+ import { fail } from "./errors.js";
3
+ import { x25519PrivateKeyFromRaw } from "../packages/vaultline-crypto/src/index.js";
4
+ /**
5
+ * Touch ID as a second way into a vault, built on the WebAuthn PRF extension.
6
+ *
7
+ * The recovery phrase is the thing people lose. It is written down once, months
8
+ * before it matters, and a vault with exactly one key is a vault that a bad
9
+ * afternoon can destroy. A passkey fixes that without weakening anything: it is
10
+ * an *additional* recipient on the envelope, so forgetting the phrase stops
11
+ * being fatal while the phrase remains the way back when the machine is gone.
12
+ *
13
+ * The mechanism is the PRF extension. An authenticator that supports it will,
14
+ * given a fixed salt, return the same 32 secret bytes every time the user
15
+ * presents their finger — and will not return them to any other origin, or to
16
+ * anyone who cannot pass the biometric. Those bytes are not used as a key
17
+ * directly; they are run through HKDF into an X25519 private key, whose public
18
+ * half is registered as an ordinary recipient. Nothing new appears in the
19
+ * envelope format, and a vault with a passkey is readable by a build that has
20
+ * never heard of one.
21
+ *
22
+ * What is deliberately *not* stored anywhere is the private key. It exists only
23
+ * for as long as it takes to unwrap, and it is re-derived from the finger each
24
+ * time. Losing the machine loses the passkey, which is exactly why the phrase
25
+ * still exists.
26
+ */
27
+ /** Domain separation, so the same authenticator used elsewhere cannot collide with this. */
28
+ const INFO = Buffer.from("vaultline-passkey-recipient:v1");
29
+ export const PRF_SECRET_BYTES = 32;
30
+ /** A fresh per-vault salt, evaluated by the authenticator to produce the secret. */
31
+ export function generatePrfSalt() {
32
+ return randomBytes(32).toString("base64");
33
+ }
34
+ /**
35
+ * The X25519 identity that a given passkey secret always produces.
36
+ *
37
+ * Deterministic on purpose: enrolment registers the public half, and every
38
+ * later unlock re-derives the private half from the same finger. If this were
39
+ * random, a passkey could enrol but never open anything.
40
+ */
41
+ export function keysFromPrfSecret(secret) {
42
+ if (secret.length !== PRF_SECRET_BYTES) {
43
+ fail("invalid_argument", `A passkey secret must be ${PRF_SECRET_BYTES} bytes, got ${secret.length}`);
44
+ }
45
+ const material = Buffer.from(hkdfSync("sha256", secret, Buffer.alloc(0), INFO, 32));
46
+ const privateKey = x25519PrivateKeyFromRaw(material);
47
+ const publicKeyBase64 = createPublicKey(privateKey).export({ type: "spki", format: "der" }).subarray(-32).toString("base64");
48
+ return { publicKeyBase64, privateKeyRaw: material };
49
+ }
50
+ export function isPasskeyRecord(value) {
51
+ const record = value;
52
+ return !!record && typeof record.credentialId === "string" && typeof record.prfSalt === "string"
53
+ && typeof record.label === "string" && typeof record.recipientId === "string";
54
+ }
@@ -0,0 +1,19 @@
1
+ import { VaultlineError } from "./errors.js";
2
+ export declare const passwordLockPath: (dataDir: string) => string;
3
+ /**
4
+ * Structural damage, as opposed to a wrong password. Carries no hint about
5
+ * either secret, and its message is written for the window: what stopped
6
+ * working, what did not, and the way back in.
7
+ */
8
+ export declare class PasswordLockCorruptError extends VaultlineError {
9
+ constructor(path: string, reason: string);
10
+ }
11
+ /** Seals the canonical phrase under a password, replacing any previous lock in one atomic step. */
12
+ export declare function wrapPhrase(dataDir: string, phrase: string, password: string): Promise<void>;
13
+ /**
14
+ * Trades a password for the phrase. Null means exactly one thing — the
15
+ * password does not open this lock — so callers can say so without hedging;
16
+ * a file that is damaged in any detectable way throws instead.
17
+ */
18
+ export declare function unwrapPhrase(dataDir: string, password: string): Promise<string | null>;
19
+ export declare function hasPasswordLock(dataDir: string): Promise<boolean>;
@@ -0,0 +1,156 @@
1
+ import { randomBytes, scryptSync } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { aeadCipher, aeadDecipher } from "../packages/vaultline-crypto/src/aead.js";
5
+ import { TAG_BYTES } from "../packages/vaultline-crypto/src/format.js";
6
+ import { fail, VaultlineError } from "./errors.js";
7
+ /**
8
+ * A password in front of the phrase, never instead of it.
9
+ *
10
+ * Free users get to unlock with something they can remember, but the archive
11
+ * format and its guarantees do not move an inch: the password seals this
12
+ * vault's canonical 24-word phrase into a file on this machine, and unwrapping
13
+ * it yields that same phrase for the keystore. Losing the password loses
14
+ * nothing archival — the written-down phrase still opens everything, here or
15
+ * on a machine that has never seen this file. The lock never leaves this
16
+ * machine and is deliberately excluded from what a vault backup means.
17
+ *
18
+ * The scrypt cost matches every other phrase-derived key in this repo
19
+ * (crypto.ts, vaultline-crypto/recipients.ts), so config.json's history does
20
+ * not repeat: the file guarding the phrase must never be cheaper to attack
21
+ * than the envelopes sitting next to it.
22
+ */
23
+ const SCRYPT = { N: 1 << 15, r: 8, p: 1 };
24
+ /** 128·N·r for these params is exactly Node's default cap, so state the budget explicitly like crypto.ts does. */
25
+ const MAXMEM = 128 * 1024 * 1024;
26
+ const KEY_BYTES = 32;
27
+ const SALT_BYTES = 16;
28
+ const NONCE_BYTES = 12;
29
+ const AAD = Buffer.from("vaultline-phrase-lock:v1");
30
+ export const passwordLockPath = (dataDir) => join(dataDir, "phrase.lock");
31
+ /**
32
+ * Structural damage, as opposed to a wrong password. Carries no hint about
33
+ * either secret, and its message is written for the window: what stopped
34
+ * working, what did not, and the way back in.
35
+ */
36
+ export class PasswordLockCorruptError extends VaultlineError {
37
+ constructor(path, reason) {
38
+ super("ciphertext_integrity_failed", "The saved password lock on this machine is damaged, so a password cannot unlock this vault. Your archives and your recovery phrase are unaffected — unlock with the phrase, then choose a password again.", { path, reason });
39
+ this.name = "PasswordLockCorruptError";
40
+ }
41
+ }
42
+ function corrupt(path, reason) {
43
+ throw new PasswordLockCorruptError(path, reason);
44
+ }
45
+ /**
46
+ * Everything detectably wrong with the file is rejected here, by name, because
47
+ * past this point the AEAD tag cannot tell a flipped ciphertext bit from a
48
+ * wrong password — so this parse is the entire basis for the null-vs-throw
49
+ * contract `unwrapPhrase` promises its callers.
50
+ */
51
+ function parseEnvelope(raw, path) {
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(raw);
55
+ }
56
+ catch {
57
+ corrupt(path, "not JSON");
58
+ }
59
+ if (typeof parsed !== "object" || parsed === null)
60
+ corrupt(path, "not an object");
61
+ const envelope = parsed;
62
+ if (envelope.v !== 1) {
63
+ if (typeof envelope.v === "number" && envelope.v > 1)
64
+ fail("config_unsupported_version", "This password lock was written by a newer Sealkeep. Update Sealkeep on this machine, or unlock with your recovery phrase.", { path, version: envelope.v });
65
+ corrupt(path, "unrecognised version");
66
+ }
67
+ const kdf = envelope.kdf;
68
+ if (typeof kdf !== "object" || kdf === null || kdf.name !== "scrypt")
69
+ corrupt(path, "unrecognised kdf");
70
+ const { N, r, p } = kdf;
71
+ // Bounds double as a memory guard: parameters are honoured from the file for
72
+ // forward compatibility, so a doctored file must not be able to turn scrypt
73
+ // into a memory bomb or into something cheap enough to brute-force.
74
+ if (typeof N !== "number" || !Number.isInteger(N) || N < 1 << 14 || N > 1 << 20 || (N & (N - 1)) !== 0)
75
+ corrupt(path, "scrypt N out of range");
76
+ if (typeof r !== "number" || !Number.isInteger(r) || r < 1 || r > 64)
77
+ corrupt(path, "scrypt r out of range");
78
+ if (typeof p !== "number" || !Number.isInteger(p) || p < 1 || p > 16)
79
+ corrupt(path, "scrypt p out of range");
80
+ if (128 * N * r * p > MAXMEM)
81
+ corrupt(path, "scrypt cost exceeds the memory budget");
82
+ for (const field of ["salt", "nonce", "box"]) {
83
+ if (typeof envelope[field] !== "string")
84
+ corrupt(path, `${field} is not a string`);
85
+ }
86
+ const salt = Buffer.from(envelope.salt, "base64");
87
+ const nonce = Buffer.from(envelope.nonce, "base64");
88
+ const box = Buffer.from(envelope.box, "base64");
89
+ if (salt.length !== SALT_BYTES)
90
+ corrupt(path, "salt is the wrong length");
91
+ if (nonce.length !== NONCE_BYTES)
92
+ corrupt(path, "nonce is the wrong length");
93
+ if (box.length <= TAG_BYTES)
94
+ corrupt(path, "box is too short to hold anything");
95
+ return envelope;
96
+ }
97
+ function derive(password, salt, params) {
98
+ // NFKD for the same reason recoveryKey normalizes the phrase: the same
99
+ // characters typed through different keyboards must derive the same key.
100
+ return scryptSync(password.normalize("NFKD"), salt, KEY_BYTES, { ...params, maxmem: MAXMEM });
101
+ }
102
+ /** Seals the canonical phrase under a password, replacing any previous lock in one atomic step. */
103
+ export async function wrapPhrase(dataDir, phrase, password) {
104
+ // The same bounds the setup endpoint declares, enforced where the sealing
105
+ // happens so no caller can create a lock the wizard would have refused.
106
+ if (password.length < 8 || password.length > 1024)
107
+ fail("invalid_argument", "The vault password must be between 8 and 1024 characters.");
108
+ const salt = randomBytes(SALT_BYTES);
109
+ const nonce = randomBytes(NONCE_BYTES);
110
+ const cipher = aeadCipher("chacha20-poly1305", derive(password, salt, SCRYPT), nonce);
111
+ cipher.setAAD(AAD);
112
+ const box = Buffer.concat([cipher.update(Buffer.from(phrase, "utf8")), cipher.final(), cipher.getAuthTag()]);
113
+ const envelope = {
114
+ v: 1,
115
+ kdf: { name: "scrypt", ...SCRYPT },
116
+ salt: salt.toString("base64"),
117
+ nonce: nonce.toString("base64"),
118
+ box: box.toString("base64")
119
+ };
120
+ const path = passwordLockPath(dataDir);
121
+ // Temp-and-rename so a crash mid-write can only ever leave the old lock (or
122
+ // none), never a half-written file that would read as damage later.
123
+ const temp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
124
+ await mkdir(dataDir, { recursive: true, mode: 0o700 });
125
+ await writeFile(temp, `${JSON.stringify(envelope)}\n`, { mode: 0o600 });
126
+ await chmod(temp, 0o600);
127
+ await rename(temp, path);
128
+ }
129
+ /**
130
+ * Trades a password for the phrase. Null means exactly one thing — the
131
+ * password does not open this lock — so callers can say so without hedging;
132
+ * a file that is damaged in any detectable way throws instead.
133
+ */
134
+ export async function unwrapPhrase(dataDir, password) {
135
+ const path = passwordLockPath(dataDir);
136
+ const raw = await readFile(path, "utf8").catch(() => null);
137
+ if (raw === null)
138
+ fail("recovery_phrase_missing", "This vault has no password lock on this machine.", { path });
139
+ const envelope = parseEnvelope(raw, path);
140
+ const box = Buffer.from(envelope.box, "base64");
141
+ const decipher = aeadDecipher("chacha20-poly1305", derive(password, Buffer.from(envelope.salt, "base64"), envelope.kdf), Buffer.from(envelope.nonce, "base64"));
142
+ decipher.setAAD(AAD);
143
+ decipher.setAuthTag(box.subarray(box.length - TAG_BYTES));
144
+ try {
145
+ return Buffer.concat([decipher.update(box.subarray(0, box.length - TAG_BYTES)), decipher.final()]).toString("utf8");
146
+ }
147
+ catch {
148
+ // The one failure the tag cannot attribute: an undamaged-looking box that
149
+ // does not authenticate. Anything an attacker could learn from splitting
150
+ // this further is an oracle, so it stays a single answer: not this password.
151
+ return null;
152
+ }
153
+ }
154
+ export async function hasPasswordLock(dataDir) {
155
+ return (await stat(passwordLockPath(dataDir)).catch(() => null)) !== null;
156
+ }
@@ -0,0 +1,9 @@
1
+ export declare const localApiTokenPath: (dataDir: string) => string;
2
+ /**
3
+ * Finds the bundled `web/` directory by walking up from this module.
4
+ *
5
+ * The compiled layout (`dist/src/…`) and the `tsx` layout (`src/…`) sit at different
6
+ * depths, and the published package flattens things again, so the marker file is
7
+ * located rather than assumed.
8
+ */
9
+ export declare function findWebRoot(from?: string): string;
@@ -0,0 +1,24 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ export const localApiTokenPath = (dataDir) => join(dataDir, "local-api-token");
5
+ /**
6
+ * Finds the bundled `web/` directory by walking up from this module.
7
+ *
8
+ * The compiled layout (`dist/src/…`) and the `tsx` layout (`src/…`) sit at different
9
+ * depths, and the published package flattens things again, so the marker file is
10
+ * located rather than assumed.
11
+ */
12
+ export function findWebRoot(from = dirname(fileURLToPath(import.meta.url))) {
13
+ let directory = resolve(from);
14
+ for (let depth = 0; depth < 6; depth += 1) {
15
+ const candidate = join(directory, "web");
16
+ if (existsSync(join(candidate, "index.html")))
17
+ return candidate;
18
+ const parent = dirname(directory);
19
+ if (parent === directory)
20
+ break;
21
+ directory = parent;
22
+ }
23
+ return join(resolve(from), "..", "web");
24
+ }
@@ -0,0 +1,133 @@
1
+ import type { ProviderUploadClient, UploadLease } from "../control-plane.js";
2
+ /**
3
+ * Google Cloud Storage V4 signing (`GOOG4-RSA-SHA256`) plus resumable uploads.
4
+ *
5
+ * The canonical request has the same shape as AWS SigV4; the difference is the
6
+ * algorithm label, the `goog4_request` scope terminator, and an RSA-SHA256
7
+ * signature made with a service-account private key instead of an HMAC.
8
+ */
9
+ export declare const GCS_ALGORITHM = "GOOG4-RSA-SHA256";
10
+ export type ServiceAccount = {
11
+ clientEmail: string;
12
+ privateKey: string;
13
+ };
14
+ export type GcsEndpoint = {
15
+ bucket: string;
16
+ host?: string;
17
+ port?: number;
18
+ protocol?: "https" | "http";
19
+ };
20
+ export declare function gcsCredentialScope(date: string): string;
21
+ export type GcsSignInput = {
22
+ method: string;
23
+ bucket: string;
24
+ object: string;
25
+ serviceAccount: ServiceAccount;
26
+ expiresInSeconds?: number;
27
+ headers?: Record<string, string>;
28
+ query?: Record<string, string>;
29
+ when?: Date;
30
+ host?: string;
31
+ port?: number;
32
+ protocol?: "https" | "http";
33
+ };
34
+ export declare function signGcsUrl(input: GcsSignInput): {
35
+ url: string;
36
+ signature: string;
37
+ canonicalRequest: string;
38
+ stringToSign: string;
39
+ expiresAt: string;
40
+ };
41
+ /** Everything a crashed resumable upload needs to continue. Journal exactly
42
+ * this shape. The session URI inside is a bearer capability: the journal that
43
+ * holds it must be protected like a credential, and it never belongs in logs
44
+ * or error messages. */
45
+ export type GcsStreamState = {
46
+ session: string;
47
+ confirmedBytes: number;
48
+ };
49
+ export type GcsStreamOptions = {
50
+ /** State from an earlier run's onState: skips initiation, asks the session
51
+ * how much the provider confirmed, and requires the incoming stream to
52
+ * start exactly there. `confirmedBytes` is how a mismatch — say, a PUT that
53
+ * landed after the journal's last write — is caught before a byte is sent. */
54
+ resume?: GcsStreamState;
55
+ /** Awaited after every successful chunk PUT with the cumulative confirmed
56
+ * bytes. This call is the durability point a resume journal writes at. */
57
+ onState?: (state: GcsStreamState) => void | Promise<void>;
58
+ };
59
+ /**
60
+ * GCS resumable upload client.
61
+ *
62
+ * Initiation returns a session URI which is itself a bearer capability: it is used
63
+ * immediately, never persisted, and never logged.
64
+ */
65
+ export declare class GcsUploadClient implements ProviderUploadClient {
66
+ private readonly endpoint;
67
+ private readonly serviceAccount;
68
+ private readonly chunkBytes;
69
+ readonly kind: "gcs";
70
+ constructor(endpoint: GcsEndpoint, serviceAccount: ServiceAccount, chunkBytes?: number);
71
+ private sign;
72
+ /** The chunk-folder layout's object verbs: plain signed single-object HTTP. */
73
+ putObject(object: string, body: Buffer): Promise<{
74
+ checksum: string;
75
+ }>;
76
+ headObject(object: string): Promise<{
77
+ exists: boolean;
78
+ bytes: number;
79
+ checksum?: string;
80
+ }>;
81
+ getObject(object: string): Promise<Buffer>;
82
+ deleteObject(object: string): Promise<void>;
83
+ /** Starts a resumable session and returns its URI. */
84
+ initiateResumable(object: string): Promise<string>;
85
+ /**
86
+ * Asks a session how many bytes the provider has durably confirmed, speaking
87
+ * the protocol GCS actually implements: an empty PUT whose content-range is
88
+ * `bytes *\/*` answers 308 with a `Range: bytes=0-N` header — N+1 bytes are
89
+ * confirmed — or 308 with no Range at all when nothing is confirmed yet.
90
+ * (Error messages here never carry the session URI: it is a bearer token.)
91
+ */
92
+ resumableStatus(session: string): Promise<{
93
+ confirmedBytes: number;
94
+ }>;
95
+ upload(lease: UploadLease, ciphertext: Buffer): Promise<{
96
+ remoteChecksum: string;
97
+ bytes: number;
98
+ }>;
99
+ /**
100
+ * Streams an object up without ever holding it whole: the seal loop hands
101
+ * over stored chunks as it produces them, and this accumulates just enough
102
+ * to satisfy GCS's rule that every non-final resumable PUT be a multiple of
103
+ * 256 KiB. Peak memory is one flush buffer; the disk holds nothing at all.
104
+ *
105
+ * The rolling MD5 is the same provider-language proof `upload` returns, so
106
+ * the verifier in upload.ts treats a streamed object exactly like a copied
107
+ * one. Total size is unknown until the source ends — every intermediate PUT
108
+ * says `bytes X-Y/*`, and the final one names the total.
109
+ *
110
+ * Resume: `options.resume` reuses an earlier run's session instead of
111
+ * initiating. The provider's confirmed byte count is queried first, and the
112
+ * incoming stream must start exactly at `resume.confirmedBytes` — when the
113
+ * two disagree the first flush would land at the wrong offset, so the run
114
+ * refuses before sending anything. `options.onState` is awaited after every
115
+ * successful chunk PUT with the cumulative confirmed bytes — the journal's
116
+ * durability point.
117
+ */
118
+ uploadStream(objectKey: string, chunks: AsyncIterable<Buffer>): Promise<{
119
+ remoteChecksum: string;
120
+ bytes: number;
121
+ }>;
122
+ uploadStream(objectKey: string, chunks: AsyncIterable<Buffer>, options: GcsStreamOptions): Promise<{
123
+ remoteChecksum: string | null;
124
+ bytes: number;
125
+ }>;
126
+ /** Reads an object back for restore or offload-proof: a signed GET, whole. */
127
+ download(lease: UploadLease): Promise<Buffer>;
128
+ head(lease: UploadLease): Promise<{
129
+ exists: boolean;
130
+ bytes: number;
131
+ checksum?: string;
132
+ }>;
133
+ }
@@ -0,0 +1,235 @@
1
+ import { createHash, createPrivateKey, createSign } from "node:crypto";
2
+ import { fail } from "../errors.js";
3
+ import { amzDates, canonicalHeaders, canonicalQuery, canonicalUri } from "./sigv4.js";
4
+ /**
5
+ * Google Cloud Storage V4 signing (`GOOG4-RSA-SHA256`) plus resumable uploads.
6
+ *
7
+ * The canonical request has the same shape as AWS SigV4; the difference is the
8
+ * algorithm label, the `goog4_request` scope terminator, and an RSA-SHA256
9
+ * signature made with a service-account private key instead of an HMAC.
10
+ */
11
+ export const GCS_ALGORITHM = "GOOG4-RSA-SHA256";
12
+ const sha256hex = (value) => createHash("sha256").update(value).digest("hex");
13
+ export function gcsCredentialScope(date) { return `${date}/auto/storage/goog4_request`; }
14
+ export function signGcsUrl(input) {
15
+ const when = input.when ?? new Date();
16
+ const { amzDate, date } = amzDates(when);
17
+ const expires = input.expiresInSeconds ?? 900;
18
+ const host = input.host ?? "storage.googleapis.com";
19
+ const authority = input.port ? `${host}:${input.port}` : host;
20
+ const headers = { host: authority, ...input.headers };
21
+ const { canonical, signed } = canonicalHeaders(headers);
22
+ const query = new Map(Object.entries(input.query ?? {}));
23
+ query.set("X-Goog-Algorithm", GCS_ALGORITHM);
24
+ query.set("X-Goog-Credential", `${input.serviceAccount.clientEmail}/${gcsCredentialScope(date)}`);
25
+ query.set("X-Goog-Date", amzDate);
26
+ query.set("X-Goog-Expires", String(expires));
27
+ query.set("X-Goog-SignedHeaders", signed);
28
+ const path = `/${input.bucket}/${input.object}`;
29
+ const canonicalRequest = [input.method.toUpperCase(), canonicalUri(path), canonicalQuery(query), canonical, signed, "UNSIGNED-PAYLOAD"].join("\n");
30
+ const stringToSign = [GCS_ALGORITHM, amzDate, gcsCredentialScope(date), sha256hex(canonicalRequest)].join("\n");
31
+ let key;
32
+ try {
33
+ key = createPrivateKey(input.serviceAccount.privateKey);
34
+ }
35
+ catch {
36
+ return fail("invalid_argument", "The service-account private key is not a readable PEM key");
37
+ }
38
+ if (key.asymmetricKeyType !== "rsa")
39
+ fail("invalid_argument", `GCS V4 signing needs an RSA service-account key, not ${String(key.asymmetricKeyType)}`);
40
+ const signature = createSign("RSA-SHA256").update(stringToSign).sign(key, "hex");
41
+ const protocol = input.protocol ?? "https";
42
+ return { url: `${protocol}://${authority}${canonicalUri(path)}?${canonicalQuery(query)}&X-Goog-Signature=${signature}`, signature, canonicalRequest, stringToSign, expiresAt: new Date(when.getTime() + expires * 1000).toISOString() };
43
+ }
44
+ /**
45
+ * GCS resumable upload client.
46
+ *
47
+ * Initiation returns a session URI which is itself a bearer capability: it is used
48
+ * immediately, never persisted, and never logged.
49
+ */
50
+ export class GcsUploadClient {
51
+ endpoint;
52
+ serviceAccount;
53
+ chunkBytes;
54
+ kind = "gcs";
55
+ constructor(endpoint, serviceAccount, chunkBytes = 8 * 1024 * 1024) {
56
+ this.endpoint = endpoint;
57
+ this.serviceAccount = serviceAccount;
58
+ this.chunkBytes = chunkBytes;
59
+ }
60
+ sign(method, object, headers = {}, query = {}) {
61
+ return signGcsUrl({ method, bucket: this.endpoint.bucket, object, serviceAccount: this.serviceAccount, headers, query, host: this.endpoint.host, port: this.endpoint.port, protocol: this.endpoint.protocol });
62
+ }
63
+ /** The chunk-folder layout's object verbs: plain signed single-object HTTP. */
64
+ async putObject(object, body) {
65
+ const { url } = this.sign("PUT", object);
66
+ const response = await fetch(url, { method: "PUT", body: new Uint8Array(body) });
67
+ if (!response.ok)
68
+ fail("internal", `Upload failed for ${object}: ${response.status} ${response.statusText}`, { object, status: response.status });
69
+ return { checksum: createHash("md5").update(body).digest("base64") };
70
+ }
71
+ async headObject(object) {
72
+ const { url } = this.sign("HEAD", object);
73
+ const response = await fetch(url, { method: "HEAD" });
74
+ if (response.status === 404)
75
+ return { exists: false, bytes: 0 };
76
+ if (!response.ok)
77
+ fail("internal", `Could not read ${object}: ${response.status} ${response.statusText}`, { object, status: response.status });
78
+ const hash = response.headers.get("x-goog-hash") ?? "";
79
+ const md5 = /(?:^|[ ,])md5=([A-Za-z0-9+/]+={0,2})/.exec(hash)?.[1];
80
+ return { exists: true, bytes: Number(response.headers.get("content-length") ?? "0"), checksum: md5 };
81
+ }
82
+ async getObject(object) {
83
+ const { url } = this.sign("GET", object);
84
+ const response = await fetch(url);
85
+ if (!response.ok)
86
+ fail("internal", `Could not download ${object}: ${response.status} ${response.statusText}`, { object, status: response.status });
87
+ return Buffer.from(await response.arrayBuffer());
88
+ }
89
+ async deleteObject(object) {
90
+ const { url } = this.sign("DELETE", object);
91
+ const response = await fetch(url, { method: "DELETE" });
92
+ if (!response.ok && response.status !== 404)
93
+ fail("internal", `Could not delete ${object}: ${response.status} ${response.statusText}`, { object, status: response.status });
94
+ }
95
+ /** Starts a resumable session and returns its URI. */
96
+ async initiateResumable(object) {
97
+ const { url } = this.sign("POST", object, { "x-goog-resumable": "start" });
98
+ const response = await fetch(url, { method: "POST", headers: { "x-goog-resumable": "start" } });
99
+ if (!response.ok)
100
+ fail("internal", `Could not start a resumable upload for ${object}: ${response.status} ${response.statusText}`, { object, status: response.status });
101
+ const session = response.headers.get("location");
102
+ return session ?? fail("internal", `Resumable initiation for ${object} returned no session URI`, { object });
103
+ }
104
+ /**
105
+ * Asks a session how many bytes the provider has durably confirmed, speaking
106
+ * the protocol GCS actually implements: an empty PUT whose content-range is
107
+ * `bytes *\/*` answers 308 with a `Range: bytes=0-N` header — N+1 bytes are
108
+ * confirmed — or 308 with no Range at all when nothing is confirmed yet.
109
+ * (Error messages here never carry the session URI: it is a bearer token.)
110
+ */
111
+ async resumableStatus(session) {
112
+ const response = await fetch(session, { method: "PUT", headers: { "content-range": "bytes */*" } });
113
+ // 2xx means the session already finalized its object; resuming would try
114
+ // to append to something finished, so the caller should verify instead.
115
+ if (response.ok)
116
+ fail("invalid_argument", "This resumable session has already been finalized; verify the stored object instead of resuming the upload.");
117
+ if (response.status !== 308)
118
+ fail("internal", `The resumable session status query answered ${response.status} ${response.statusText} instead of 308.`, { status: response.status });
119
+ const range = response.headers.get("range");
120
+ if (!range)
121
+ return { confirmedBytes: 0 };
122
+ const match = /^bytes=0-(\d+)$/.exec(range);
123
+ if (!match)
124
+ return fail("internal", `The resumable session reported a Range header this client cannot read: ${range}`, { range });
125
+ return { confirmedBytes: Number(match[1]) + 1 };
126
+ }
127
+ async upload(lease, ciphertext) {
128
+ const object = lease.objectKey;
129
+ const session = await this.initiateResumable(object);
130
+ for (let offset = 0; offset < Math.max(ciphertext.length, 1); offset += this.chunkBytes) {
131
+ const slice = ciphertext.subarray(offset, Math.min(offset + this.chunkBytes, ciphertext.length));
132
+ const last = offset + slice.length >= ciphertext.length;
133
+ const range = ciphertext.length === 0 ? "bytes */0" : `bytes ${offset}-${offset + slice.length - 1}/${ciphertext.length}`;
134
+ const response = await fetch(session, { method: "PUT", body: new Uint8Array(slice), headers: { "content-range": range } });
135
+ // 308 means "resume incomplete" and is the expected status between chunks.
136
+ if (!response.ok && !(response.status === 308 && !last)) {
137
+ fail("internal", `Resumable upload for ${object} failed at ${range}: ${response.status} ${response.statusText}`, { object, status: response.status });
138
+ }
139
+ if (last)
140
+ break;
141
+ }
142
+ // The strongest whole-object proof GCS reports back over HEAD is the MD5 it
143
+ // stored (`x-goog-hash`); it does not compute SHA-256. The verifier in
144
+ // upload.ts compares this value against head()'s, so both must speak the
145
+ // provider's language — returning a locally computed SHA-256 here made the
146
+ // comparison fail for every object and no GCS upload could ever be marked
147
+ // durable. MD5 is transport proof, not trust: the ciphertext is already
148
+ // AEAD-authenticated, and restore re-checks its SHA-256 independently.
149
+ return { remoteChecksum: createHash("md5").update(ciphertext).digest("base64"), bytes: ciphertext.length };
150
+ }
151
+ async uploadStream(objectKey, chunks, options = {}) {
152
+ const QUANTUM = 256 * 1024;
153
+ const resume = options.resume;
154
+ let session;
155
+ let sent = 0;
156
+ if (resume) {
157
+ const { confirmedBytes } = await this.resumableStatus(resume.session);
158
+ // The caller re-sealed from the offset its journal recorded. If the
159
+ // provider confirmed a different count — a PUT landed after the last
160
+ // journal write, say — the very first flush would splice bytes at the
161
+ // wrong position, so refuse while nothing has been sent yet.
162
+ if (confirmedBytes !== resume.confirmedBytes) {
163
+ fail("invalid_argument", `Cannot resume the upload of ${objectKey}: the provider has confirmed ${confirmedBytes} bytes but the incoming stream starts at byte ${resume.confirmedBytes}; query resumableStatus and re-seal from the provider's count.`, { object: objectKey, confirmedBytes, resumeFrom: resume.confirmedBytes });
164
+ }
165
+ session = resume.session;
166
+ sent = confirmedBytes;
167
+ }
168
+ else {
169
+ session = await this.initiateResumable(objectKey);
170
+ }
171
+ // A resumed run never saw the bytes the earlier run sent, so it cannot
172
+ // roll an MD5 over the whole object — null says "no provider-language
173
+ // checksum from this run". The whole-object proof for a resumed upload is
174
+ // the caller's own resumable SHA-256 over the sealed stream, verified via
175
+ // size plus our stored hash rather than the provider's md5.
176
+ const md5 = resume ? null : createHash("md5");
177
+ let held = [];
178
+ let heldBytes = 0;
179
+ const putPiece = async (piece, final) => {
180
+ // A body-less finalize names the total as `bytes *\/N` — the shape GCS
181
+ // expects when the stream ends exactly on a flush boundary, and the N=0
182
+ // case doubles as the empty-object write.
183
+ const range = final
184
+ ? (piece.length === 0 ? `bytes */${sent}` : `bytes ${sent}-${sent + piece.length - 1}/${sent + piece.length}`)
185
+ : `bytes ${sent}-${sent + piece.length - 1}/*`;
186
+ const response = await fetch(session, { method: "PUT", body: new Uint8Array(piece), headers: { "content-range": range } });
187
+ if (!response.ok && !(response.status === 308 && !final)) {
188
+ fail("internal", `Resumable upload for ${objectKey} failed at ${range}: ${response.status} ${response.statusText}`, { object: objectKey, status: response.status });
189
+ }
190
+ sent += piece.length;
191
+ // The journal's durability point: the provider now holds every byte
192
+ // below `sent`, so a crash after this callback can resume from here.
193
+ if (options.onState)
194
+ await options.onState({ session, confirmedBytes: sent });
195
+ };
196
+ for await (const chunk of chunks) {
197
+ md5?.update(chunk);
198
+ held.push(chunk);
199
+ heldBytes += chunk.length;
200
+ if (heldBytes >= Math.max(this.chunkBytes, QUANTUM)) {
201
+ const flushBytes = Math.floor(heldBytes / QUANTUM) * QUANTUM;
202
+ const buffered = Buffer.concat(held);
203
+ await putPiece(buffered.subarray(0, flushBytes), false);
204
+ held = flushBytes < buffered.length ? [buffered.subarray(flushBytes)] : [];
205
+ heldBytes = buffered.length - flushBytes;
206
+ }
207
+ }
208
+ await putPiece(Buffer.concat(held), true);
209
+ return { remoteChecksum: md5 ? md5.digest("base64") : null, bytes: sent };
210
+ }
211
+ /** Reads an object back for restore or offload-proof: a signed GET, whole. */
212
+ async download(lease) {
213
+ const { url } = this.sign("GET", lease.objectKey);
214
+ const response = await fetch(url);
215
+ if (!response.ok)
216
+ fail("internal", `Could not download ${lease.objectKey}: ${response.status} ${response.statusText}`, { key: lease.objectKey, status: response.status });
217
+ return Buffer.from(await response.arrayBuffer());
218
+ }
219
+ async head(lease) {
220
+ const { url } = this.sign("HEAD", lease.objectKey);
221
+ const response = await fetch(url, { method: "HEAD" });
222
+ if (response.status === 404)
223
+ return { exists: false, bytes: 0 };
224
+ if (!response.ok)
225
+ fail("internal", `Could not read ${lease.objectKey}: ${response.status} ${response.statusText}`, { key: lease.objectKey, status: response.status });
226
+ // `x-goog-hash` carries `crc32c=…` and, for non-composite objects, `md5=…`
227
+ // (base64 values; the header may arrive as one joined value or two). Our
228
+ // resumable uploads are single-stream, so MD5 is always present for objects
229
+ // we wrote. When it is absent the object cannot be proven from here, and
230
+ // returning undefined makes the verifier refuse rather than pretend.
231
+ const hash = response.headers.get("x-goog-hash") ?? "";
232
+ const md5 = /(?:^|[ ,])md5=([A-Za-z0-9+/]+={0,2})/.exec(hash)?.[1];
233
+ return { exists: true, bytes: Number(response.headers.get("content-length") ?? "0"), checksum: md5 };
234
+ }
235
+ }