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,851 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { chooseBackend } from "./secrets.js";
4
+ import { encryptArchive as sealEnvelope, decryptArchive as openEnvelope, x25519PrivateKeyFromRaw, x25519PublicKeyFromRaw } from "../packages/vaultline-crypto/src/index.js";
5
+ import { canonicalPhrase } from "./mnemonic.js";
6
+ import { fail, isVaultlineError, VaultlineError } from "./errors.js";
7
+ import { sha256 } from "./crypto.js";
8
+ import { listArchives, readConfig, writeRecord } from "./vault.js";
9
+ /**
10
+ * Sealkeep Cloud: the managed tier.
11
+ *
12
+ * The trade for paying is that you configure nothing — no bucket, no IAM, no
13
+ * credentials. What you do not trade away is control of your data: this module
14
+ * asks the control plane for permission to write one object, then talks to
15
+ * storage directly. Ciphertext never passes through our servers, and the
16
+ * control plane is never sent a key that opens it.
17
+ */
18
+ export const DEFAULT_CLOUD_URL = "https://shared.spala.ai/p04946/api";
19
+ const cloudUrl = (env = process.env) => (env.VAULTLINE_CLOUD_URL ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
20
+ /**
21
+ * Where this vault's cloud token lives in the keystore.
22
+ *
23
+ * Keyed by vault as well as by cloud URL. Keying on the URL alone made the
24
+ * token machine-global: every vault on the machine inherited whichever account
25
+ * happened to be signed in, so a brand-new free-tier vault reported itself
26
+ * signed in as someone else, showed a green sealed-key check it had no claim
27
+ * to, and — because `resolveSyncRules` reads that account — silently applied a
28
+ * stranger's sync rules. On this machine that held back 659 of 662 sessions on
29
+ * a vault that had never logged in to anything.
30
+ *
31
+ * Exported because `enroll` stores a token at this same location. Two copies of
32
+ * a key format that must agree is how `access_key_id` drifted from `akid` and
33
+ * would have signed every managed upload with an undefined credential — one
34
+ * definition, imported, so a change here cannot leave the other behind.
35
+ */
36
+ export function tokenRefFor(vaultId, env = process.env) {
37
+ return { service: "vaultline-cloud", account: `${vaultId}@${cloudUrl(env)}` };
38
+ }
39
+ async function tokenRef(dataDir) {
40
+ const { readConfig } = await import("./vault.js");
41
+ const { vaultId } = await readConfig(dataDir);
42
+ return tokenRefFor(vaultId);
43
+ }
44
+ /** A failed cloud call should say what the service said, not just "request failed". */
45
+ async function call(path, init = {}, env = process.env) {
46
+ const { token, ...rest } = init;
47
+ const headers = new Headers(rest.headers);
48
+ if (rest.body)
49
+ headers.set("content-type", "application/json");
50
+ if (token)
51
+ headers.set("authorization", `Bearer ${token}`);
52
+ let response;
53
+ try {
54
+ response = await fetch(`${cloudUrl(env)}${path}`, { ...rest, headers });
55
+ }
56
+ catch (error) {
57
+ return fail("internal", `Sealkeep Cloud is unreachable: ${error instanceof Error ? error.message : "network error"}`);
58
+ }
59
+ const text = await response.text();
60
+ const body = text ? safeJson(text) : null;
61
+ if (!response.ok) {
62
+ const message = body?.error ?? `${response.status} ${response.statusText}`;
63
+ if (response.status === 401)
64
+ fail("unauthorized", `Sealkeep Cloud rejected the credentials: ${message}`);
65
+ if (response.status === 402)
66
+ fail("forbidden", message);
67
+ if (response.status === 403)
68
+ fail("forbidden", message);
69
+ if (response.status === 409)
70
+ fail("lease_expired", message);
71
+ fail("internal", `Sealkeep Cloud returned ${response.status}: ${message}`);
72
+ }
73
+ return body;
74
+ }
75
+ const safeJson = (text) => { try {
76
+ return JSON.parse(text);
77
+ }
78
+ catch {
79
+ return { error: text.slice(0, 200) };
80
+ } };
81
+ /* ------------------------------------------------------------------ account */
82
+ export async function register(dataDir, input, preferred) {
83
+ const account = await call("/signup", { method: "POST", body: JSON.stringify(input) });
84
+ await login(dataDir, { email: input.email, password: input.password }, preferred);
85
+ return account;
86
+ }
87
+ /** The token lives in the OS keychain, never in a config file on disk. */
88
+ export async function login(dataDir, input, preferred) {
89
+ const result = await call("/login", { method: "POST", body: JSON.stringify(input) });
90
+ const backend = await chooseBackend(dataDir, preferred);
91
+ await backend.set(await tokenRef(dataDir), result.token);
92
+ return result.user;
93
+ }
94
+ /**
95
+ * Sign in with a one-time code from the web panel instead of a password.
96
+ *
97
+ * An account created through Google has no password anybody knows — the server
98
+ * stores a bcrypt of a random string it never shows. Without this, every Google
99
+ * customer would have a working dashboard and an unusable CLI. The code is
100
+ * single-use and the server clears it on redemption.
101
+ */
102
+ export async function loginWithCode(dataDir, input, preferred) {
103
+ // Everything local is proven before the exchange, because the exchange
104
+ // consumes the single-use pairing code. Running `cloud login` before `setup`
105
+ // used to burn the code on the way to a "no vault" error — the person then
106
+ // stood at a panel whose displayed code was already dead, with no hint why.
107
+ const backend = await chooseBackend(dataDir, preferred);
108
+ const ref = await tokenRef(dataDir);
109
+ const result = await call("/cli/exchange", { method: "POST", body: JSON.stringify(input) });
110
+ await backend.set(ref, result.token);
111
+ // Announce this machine to the account, so the panel's device list shows it
112
+ // and Revoke has a row to act on. The exchange endpoint itself cannot write
113
+ // the row (it is public; the control plane only accepts tenant writes from an
114
+ // authenticated caller), so the freshly minted token does it. Best-effort: a
115
+ // machine that fails to register is still signed in — it is merely invisible,
116
+ // which was previously true of every machine.
117
+ try {
118
+ const { hostname } = await import("node:os");
119
+ await call("/v1/cloud/devices/register", { method: "POST", token: result.token, body: JSON.stringify({ label: hostname() }) });
120
+ }
121
+ catch { /* visibility is not worth failing a successful sign-in over */ }
122
+ return result.user;
123
+ }
124
+ export async function logout(dataDir, preferred) {
125
+ const backend = await chooseBackend(dataDir, preferred);
126
+ await backend.delete(await tokenRef(dataDir));
127
+ }
128
+ export async function cloudToken(dataDir, preferred) {
129
+ const backend = await chooseBackend(dataDir, preferred);
130
+ const token = await backend.get(await tokenRef(dataDir));
131
+ if (!token)
132
+ fail("unauthorized", "Not signed in to Sealkeep Cloud. Run: sealkeep cloud login");
133
+ return token;
134
+ }
135
+ export async function accountStatus(dataDir, preferred) {
136
+ return call("/me", { token: await cloudToken(dataDir, preferred) });
137
+ }
138
+ /**
139
+ * Refuses an escrow password that is also the account password.
140
+ *
141
+ * `login` sends the account password to the control plane, where it is bcrypted
142
+ * — which is ordinary and fine for authentication, and fatal here. An escrow
143
+ * password is a key that opens the blob on its own, so if it is the same string
144
+ * the account is authenticated with, then we have been handed that key, and so
145
+ * has anyone who takes the database or compels us to produce it. The product's
146
+ * central claim — that we cannot open your archives — would be false, and
147
+ * false in a way no customer could detect.
148
+ *
149
+ * Enforced rather than documented, because a rule that only exists in prose is
150
+ * a rule people break by pasting the password they already use. The comparison
151
+ * is deliberately naive: this is a mistake-catcher, not a policy engine, and
152
+ * the caller is the only place both strings are ever in hand at once.
153
+ *
154
+ * The real fix is for authentication to stop sending the raw password at all —
155
+ * send one derivation to log in, keep another for encryption, so reuse becomes
156
+ * harmless instead of forbidden. That is a control-plane change; until it
157
+ * happens, this keeps the guarantee true.
158
+ */
159
+ export function assertEscrowPasswordDistinct(accountPassword, escrowPassword) {
160
+ if (accountPassword.trim() && accountPassword.trim() === escrowPassword.trim()) {
161
+ fail("invalid_argument", "Your unlock password must be different from your account password. The account password is sent to Sealkeep when you sign in, so reusing it here would hand us a key that opens your archives — which is exactly what this product promises we do not have.");
162
+ }
163
+ }
164
+ export function sealEscrow(secretMaterial, rawPhrase, also = []) {
165
+ const phrase = canonicalPhrase(rawPhrase);
166
+ // The phrase is always a recipient and always first: every other way in can
167
+ // be revoked, forgotten or left on a lost laptop, and this is the one that
168
+ // belongs entirely to the person whose data it is.
169
+ const recipients = [{ kind: "phrase", phrase }];
170
+ for (const unlock of also) {
171
+ if (unlock.kind === "password") {
172
+ if (!unlock.password.trim())
173
+ fail("invalid_argument", "An escrow password cannot be empty");
174
+ recipients.push({ kind: "phrase", phrase: unlock.password });
175
+ }
176
+ else {
177
+ const raw = Buffer.from(unlock.publicKeyBase64, "base64");
178
+ if (raw.length !== 32)
179
+ fail("invalid_argument", "An X25519 escrow key must be 32 bytes, base64 encoded");
180
+ recipients.push({ kind: "x25519", publicKey: x25519PublicKeyFromRaw(raw) });
181
+ }
182
+ }
183
+ return sealEnvelope(Buffer.from(JSON.stringify(secretMaterial)), { recipients, adapter: { agent: "vaultline-escrow", version: "1" } });
184
+ }
185
+ export function openEscrow(envelope, ciphertext, rawPhrase) {
186
+ const phrase = canonicalPhrase(rawPhrase);
187
+ try {
188
+ return JSON.parse(openEnvelope(envelope, ciphertext, { phrase }).toString("utf8"));
189
+ }
190
+ catch {
191
+ return fail("recovery_phrase_mismatch", "That recovery phrase does not open this escrow blob");
192
+ }
193
+ }
194
+ /**
195
+ * Opens the blob with a password that was registered as an unlock.
196
+ *
197
+ * Separate from `openEscrow` so the two cannot be confused at a call site: this
198
+ * one is reached by typing something memorable, which is the convenience the
199
+ * paid tier is for and also the weaker of the two doors. The failure message
200
+ * says password rather than phrase, because being told your recovery phrase is
201
+ * wrong when you typed a password is how people conclude they have lost
202
+ * everything.
203
+ */
204
+ export function openEscrowWithPassword(envelope, ciphertext, password) {
205
+ try {
206
+ return JSON.parse(openEnvelope(envelope, ciphertext, { phrase: password }).toString("utf8"));
207
+ }
208
+ catch {
209
+ return fail("recovery_phrase_mismatch", "That password does not open this escrow blob");
210
+ }
211
+ }
212
+ /**
213
+ * Opens the blob with a key: a passkey's derived identity, or an org's escrow
214
+ * key. The private half is never stored by us and never travels — it is
215
+ * re-derived from the authenticator, or held by whoever owns the org key.
216
+ */
217
+ export function openEscrowWithKey(envelope, ciphertext, privateKeyRaw) {
218
+ try {
219
+ const privateKey = x25519PrivateKeyFromRaw(privateKeyRaw);
220
+ return JSON.parse(openEnvelope(envelope, ciphertext, { privateKey }).toString("utf8"));
221
+ }
222
+ catch {
223
+ return fail("recovery_phrase_mismatch", "That key does not open this escrow blob");
224
+ }
225
+ }
226
+ /**
227
+ * Hand the sealed blob to the control plane.
228
+ *
229
+ * What crosses the wire is ciphertext plus the envelope needed to unwrap it
230
+ * *given the phrase*. The phrase itself is never sent, so possession of this
231
+ * record — by us, or by anyone who takes the database — does not open it.
232
+ */
233
+ export async function pushEscrow(dataDir, secretMaterial, rawPhrase, preferred, also = []) {
234
+ const { envelope, ciphertext } = sealEscrow(secretMaterial, rawPhrase, also);
235
+ await call("/v1/cloud/escrow", {
236
+ method: "PUT",
237
+ token: await cloudToken(dataDir, preferred),
238
+ body: JSON.stringify({ envelope: JSON.stringify(envelope), sealed: ciphertext.toString("base64") })
239
+ });
240
+ return { stored: true };
241
+ }
242
+ /** Fetch and open the escrow blob. Requires the phrase; nothing else will do. */
243
+ /** Whether a sealed copy exists, without needing the phrase to find out. */
244
+ export async function hasSealedCopy(dataDir, preferred) {
245
+ const answer = await call("/v1/cloud/escrow", { token: await cloudToken(dataDir, preferred) });
246
+ return (Array.isArray(answer) ? answer : answer ? [answer] : []).some((row) => Boolean(row?.sealed));
247
+ }
248
+ export async function pullEscrow(dataDir, rawPhrase, preferred) {
249
+ // The control plane answers with a list, even though sealing replaces rather
250
+ // than appends and the list holds one row. This asked for `.sealed` on the
251
+ // list itself, which is always undefined, so the escrow was unreadable no
252
+ // matter what had been stored — the paid tier's whole safety net.
253
+ // Newest-first is belt and braces in case replacement ever becomes an append.
254
+ const answer = await call("/v1/cloud/escrow", { token: await cloudToken(dataDir, preferred) });
255
+ const rows = (Array.isArray(answer) ? answer : answer ? [answer] : []).filter((row) => row?.sealed);
256
+ const blob = rows.sort((a, b) => String(b.created_at ?? "").localeCompare(String(a.created_at ?? "")) || Number(b.id ?? 0) - Number(a.id ?? 0))[0];
257
+ if (!blob?.sealed)
258
+ fail("archive_not_found", "No sealed copy is stored for this account. Store one with `sealkeep recovery seal`.");
259
+ const envelope = typeof blob.envelope === "string" ? JSON.parse(blob.envelope) : blob.envelope;
260
+ return openEscrow(envelope, Buffer.from(blob.sealed, "base64"), rawPhrase);
261
+ }
262
+ /* ------------------------------------------------------------------ uploads */
263
+ /** Asks for permission to write exactly one object. The key is chosen server-side. */
264
+ export async function requestUpload(dataDir, input, preferred) {
265
+ return call("/v1/cloud/uploads", {
266
+ method: "POST",
267
+ token: await cloudToken(dataDir, preferred),
268
+ body: JSON.stringify({ vault_ref: input.vaultRef, ciphertext_sha256: input.ciphertextSha256, bytes: input.bytes })
269
+ });
270
+ }
271
+ /**
272
+ * What this build can actually honor, checked at run time because a lease
273
+ * arrives from the network and the types above only constrain our own code.
274
+ *
275
+ * - An unknown vended provider must be refused before anything is signed:
276
+ * `S3Endpoint` derives a default host when none is given, and for a provider
277
+ * it does not recognise that default is the AWS endpoint — a signed request,
278
+ * carrying the vended key id, sent to a host the lease never named.
279
+ * - An unknown presigned method, or a non-http upload URL, must be refused
280
+ * before anything is fetched: a control plane speaking a newer delegation
281
+ * (the CREDENTIALS contract in docs/managed-streaming-contract.md, or the
282
+ * placeholder `vaultline+pending://` leases) has to surface as "update the
283
+ * CLI", not as whatever fetch makes of a marker that is not an HTTP verb.
284
+ */
285
+ const SIGNABLE_VENDED_PROVIDERS = new Set(["s3", "r2", "b2"]);
286
+ const PRESIGNED_UPLOAD_METHODS = new Set(["PUT", "POST"]);
287
+ /**
288
+ * A credential past its expiry signs requests the provider refuses — and a
289
+ * long resume can outlive a one-hour key. Refusing HERE, with the fix in the
290
+ * sentence, beats a 403 whose meaning the person has to reverse-engineer.
291
+ * A minute of skew is allowed: clocks disagree, and the provider is the
292
+ * judge that matters.
293
+ */
294
+ function assertVendedFresh(v, lease) {
295
+ const fallback = lease.expires_at;
296
+ const expires = Date.parse(v.expires_at ?? fallback ?? "");
297
+ if (Number.isFinite(expires) && expires < Date.now() - 60_000) {
298
+ fail("invalid_argument", "The storage credential for this upload has expired. Request a new lease (re-run the upload) and it continues from where it stopped.");
299
+ }
300
+ }
301
+ function assertVendedSignable(v) {
302
+ if (!SIGNABLE_VENDED_PROVIDERS.has(v.provider)) {
303
+ fail("internal", `Sealkeep Cloud vended a credential for "${v.provider}", which this version of the CLI cannot sign requests for. Update the CLI and try again.`);
304
+ }
305
+ }
306
+ /**
307
+ * Straight to storage, whichever way the lease delegated.
308
+ *
309
+ * A presigned lease is obeyed verbatim — the signer chose the method and the
310
+ * headers, and changing either invalidates the signature. A vended lease is
311
+ * signed here with the same SigV4 code the bring-your-own-bucket path uses, so
312
+ * managed B2 and a customer's own S3 bucket travel through one tested signer
313
+ * rather than two. A lease naming a delegation this build does not know is
314
+ * refused before a single byte or signature leaves the machine.
315
+ */
316
+ export async function putCiphertext(lease, ciphertext, signal) {
317
+ if (lease.vended) {
318
+ assertVendedSignable(lease.vended);
319
+ assertVendedFresh(lease.vended, lease);
320
+ const { S3UploadClient } = await import("./providers/s3.js");
321
+ const v = lease.vended;
322
+ const client = new S3UploadClient(v.provider, { provider: v.provider, region: v.region, bucket: v.bucket, host: v.host, port: v.port, protocol: v.protocol }, { accessKeyId: v.akid, secretAccessKey: v.secret_access_key, sessionToken: v.session_token });
323
+ await client.upload({
324
+ archiveId: lease.vault_ref, provider: v.provider, objectKey: lease.object_key, expiresAt: lease.expires_at,
325
+ method: "MULTIPART", uploadUrl: "", requiredHeaders: {}, status: "active", uploadable: true
326
+ }, ciphertext);
327
+ return;
328
+ }
329
+ if (!lease.upload_url?.url)
330
+ fail("internal", "Sealkeep Cloud returned a lease with nowhere to upload to");
331
+ const { url, method, headers } = lease.upload_url;
332
+ if (!PRESIGNED_UPLOAD_METHODS.has((method || "PUT").toUpperCase())) {
333
+ fail("internal", `Sealkeep Cloud returned an upload lease with method "${method}", which this version of the CLI does not know how to perform. Update the CLI and try again.`);
334
+ }
335
+ if (!/^https?:\/\//i.test(url)) {
336
+ fail("internal", "Sealkeep Cloud returned an upload address that is not an http(s) URL; refusing to send the archive anywhere else.");
337
+ }
338
+ const response = await fetch(url, { method: method || "PUT", headers, body: new Uint8Array(ciphertext), ...(signal ? { signal } : {}) });
339
+ if (!response.ok)
340
+ fail("internal", `Storage rejected the upload: ${response.status} ${(await response.text()).slice(0, 200)}`);
341
+ }
342
+ /**
343
+ * Proves managed storage is holding the bytes we sent, before we tell retention
344
+ * it may delete the local copy.
345
+ *
346
+ * Returns a description of the problem, or null when the object is proven.
347
+ *
348
+ * `completeUpload` alone is not proof: it is a metering call to the control
349
+ * plane, and a POST that returns 200 says the lease was consumed, not that any
350
+ * object exists. Writing `verifiedAt` on the strength of it — which is what this
351
+ * path used to do, under a comment claiming the opposite — meant a paying
352
+ * customer could push to storage that kept nothing and then have retention
353
+ * trash the only copy.
354
+ */
355
+ async function verifyStored(dataDir, lease, object, digest, preferred) {
356
+ if (lease.vended) {
357
+ assertVendedSignable(lease.vended);
358
+ assertVendedFresh(lease.vended, lease);
359
+ // The machine holds a credential scoped to this object, so it can ask the
360
+ // provider directly. Cheap, and no egress.
361
+ const { S3UploadClient } = await import("./providers/s3.js");
362
+ const v = lease.vended;
363
+ const client = new S3UploadClient(v.provider, { provider: v.provider, region: v.region, bucket: v.bucket, host: v.host, port: v.port, protocol: v.protocol }, { accessKeyId: v.akid, secretAccessKey: v.secret_access_key, sessionToken: v.session_token });
364
+ const head = await client.head({
365
+ archiveId: lease.vault_ref, provider: v.provider, objectKey: lease.object_key, expiresAt: lease.expires_at,
366
+ method: "MULTIPART", uploadUrl: "", requiredHeaders: {}, status: "active", uploadable: true
367
+ });
368
+ if (!head.exists)
369
+ return "the provider does not report the object as present";
370
+ if (head.bytes !== object.length)
371
+ return `the provider stored ${head.bytes} bytes; ${object.length} were sent`;
372
+ return null;
373
+ }
374
+ // A presigned PUT URL cannot be reused for a HEAD, so the only proof
375
+ // available on this path is reading the object back and hashing it. That
376
+ // costs egress on every push, which is a real reason to finish moving
377
+ // managed storage onto a provider that vends credentials instead.
378
+ const roundTrip = await pullCiphertext(dataDir, lease.vault_ref, preferred);
379
+ if (roundTrip.bytes !== object.length)
380
+ return `storage returned ${roundTrip.bytes} bytes; ${object.length} were sent`;
381
+ if (sha256(roundTrip.ciphertext) !== digest)
382
+ return "the bytes read back do not match what was uploaded";
383
+ return null;
384
+ }
385
+ export async function completeUpload(dataDir, leaseId, bytes, preferred) {
386
+ return call(`/v1/cloud/uploads/${leaseId}/complete`, {
387
+ method: "POST",
388
+ token: await cloudToken(dataDir, preferred),
389
+ body: JSON.stringify({ bytes })
390
+ });
391
+ }
392
+ /** What managed storage is holding for this account. The ref is what `sealkeep open` takes. */
393
+ /**
394
+ * The managed index slot: one sealed content index per account, moved through
395
+ * control-plane-signed URLs exactly like archive objects — known lengths, the
396
+ * signer the plane already has. `kind` is "meta" (the tiny envelope, read
397
+ * first to decide freshness) or "blob" (the sealed index itself). The plane
398
+ * sees ciphertext sizes and update times; tokens never leave the machines.
399
+ */
400
+ /* ------------------------------------------------------- drive via account */
401
+ /**
402
+ * Drive OAuth on the account's shared Google client, Paul's way: reuse the
403
+ * client the backend already owns, keep its secret THERE, and let this
404
+ * machine hold only its own tokens. The CLI opens the consent URL, a loopback
405
+ * listener on the registered fixed port catches the code, and the plane —
406
+ * authenticated as this account — exchanges it. Nothing is stored server-side.
407
+ */
408
+ export async function gdriveOauthConfig(dataDir, preferred) {
409
+ return call(`/v1/cloud/gdrive/config`, { token: await cloudToken(dataDir, preferred) });
410
+ }
411
+ export async function gdriveExchange(dataDir, input, preferred) {
412
+ return call(`/v1/cloud/gdrive/exchange`, {
413
+ method: "POST", token: await cloudToken(dataDir, preferred),
414
+ body: JSON.stringify({ code: input.code, redirect_uri: input.redirectUri })
415
+ });
416
+ }
417
+ export async function gdriveRefresh(dataDir, refreshToken, preferred) {
418
+ const fresh = await call(`/v1/cloud/gdrive/refresh`, {
419
+ method: "POST", token: await cloudToken(dataDir, preferred),
420
+ body: JSON.stringify({ refresh_token: refreshToken })
421
+ });
422
+ return { accessToken: fresh.access_token, expiresIn: fresh.expires_in ?? 3600 };
423
+ }
424
+ export async function gdriveClaim(dataDir, state, preferred) {
425
+ try {
426
+ return await call(`/v1/cloud/gdrive/claim`, {
427
+ method: "POST", token: await cloudToken(dataDir, preferred),
428
+ body: JSON.stringify({ state })
429
+ });
430
+ }
431
+ catch (error) {
432
+ if (isVaultlineError(error) && /returned 404|No pending/i.test(error.message))
433
+ return null;
434
+ throw error;
435
+ }
436
+ }
437
+ /**
438
+ * The consent flow for the shared client. Two shapes, decided by the server's
439
+ * registered redirect: a REMOTE https callback (the plane exchanges and parks
440
+ * the tokens; this machine polls its one-time state — works even when the
441
+ * browser lives on another device), or a loopback listener that catches the
442
+ * code here and exchanges through the authed endpoint.
443
+ */
444
+ export async function connectGdriveViaCloud(dataDir, options = {}) {
445
+ const { createServer } = await import("node:http");
446
+ const { randomBytes } = await import("node:crypto");
447
+ const config = await gdriveOauthConfig(dataDir, options.preferred);
448
+ const redirect = new URL(config.redirect_uri);
449
+ const state = randomBytes(16).toString("base64url");
450
+ if (redirect.protocol === "https:") {
451
+ const consent = new URL("https://accounts.google.com/o/oauth2/v2/auth");
452
+ consent.searchParams.set("client_id", config.client_id);
453
+ consent.searchParams.set("redirect_uri", config.redirect_uri);
454
+ consent.searchParams.set("response_type", "code");
455
+ consent.searchParams.set("scope", config.scope);
456
+ consent.searchParams.set("access_type", "offline");
457
+ consent.searchParams.set("prompt", "consent");
458
+ consent.searchParams.set("state", state);
459
+ options.onConsentUrl?.(consent.toString());
460
+ const { launchBrowser } = await import("./providers/gdrive.js");
461
+ await Promise.resolve((options.opener ?? launchBrowser)(consent.toString())).catch(() => undefined);
462
+ const deadline = Date.now() + (options.timeoutMs ?? 300_000);
463
+ while (Date.now() < deadline) {
464
+ const parcel = await gdriveClaim(dataDir, state, options.preferred);
465
+ if (parcel)
466
+ return { kind: "gdrive", refreshToken: parcel.refresh_token, clientId: config.client_id, exchange: "cloud" };
467
+ await new Promise((rest) => setTimeout(rest, options.pollMs ?? 2000));
468
+ }
469
+ throw new VaultlineError("unauthorized", "No consent arrived in time. Run the connect again.");
470
+ }
471
+ const server = createServer();
472
+ await new Promise((ready, refuse) => { server.once("error", refuse); server.listen(Number(redirect.port), "127.0.0.1", ready); });
473
+ let timer;
474
+ const code = new Promise((resolve, reject) => {
475
+ timer = setTimeout(() => reject(new VaultlineError("unauthorized", "No response from the browser in time. Run the connect again.")), options.timeoutMs ?? 300_000);
476
+ server.on("request", (request, response) => {
477
+ const url = new URL(request.url ?? "/", config.redirect_uri);
478
+ if (url.pathname !== redirect.pathname) {
479
+ response.writeHead(404);
480
+ response.end();
481
+ return;
482
+ }
483
+ const deny = (message) => {
484
+ response.writeHead(400, { "content-type": "text/html; charset=utf-8" });
485
+ response.end("<p>Sealkeep could not finish connecting. Return to the terminal.</p>");
486
+ reject(new VaultlineError("unauthorized", message));
487
+ };
488
+ if (url.searchParams.get("error"))
489
+ return deny(`Google reported: ${url.searchParams.get("error")}`);
490
+ if (url.searchParams.get("state") !== state)
491
+ return deny("The browser reply did not match this connection attempt (state mismatch)");
492
+ const received = url.searchParams.get("code");
493
+ if (!received)
494
+ return deny("The browser reply carried no authorization code");
495
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
496
+ response.end("<p>Sealkeep is connected to Google Drive. You can close this tab.</p>");
497
+ resolve(received);
498
+ });
499
+ });
500
+ code.catch(() => { });
501
+ const consent = new URL("https://accounts.google.com/o/oauth2/v2/auth");
502
+ consent.searchParams.set("client_id", config.client_id);
503
+ consent.searchParams.set("redirect_uri", config.redirect_uri);
504
+ consent.searchParams.set("response_type", "code");
505
+ consent.searchParams.set("scope", config.scope);
506
+ consent.searchParams.set("access_type", "offline");
507
+ consent.searchParams.set("prompt", "consent");
508
+ consent.searchParams.set("state", state);
509
+ try {
510
+ options.onConsentUrl?.(consent.toString());
511
+ const { launchBrowser } = await import("./providers/gdrive.js");
512
+ await Promise.resolve((options.opener ?? launchBrowser)(consent.toString())).catch(() => undefined);
513
+ const authorizationCode = await code;
514
+ const tokens = await gdriveExchange(dataDir, { code: authorizationCode, redirectUri: config.redirect_uri }, options.preferred);
515
+ return { kind: "gdrive", refreshToken: tokens.refresh_token, clientId: config.client_id, exchange: "cloud" };
516
+ }
517
+ finally {
518
+ if (timer)
519
+ clearTimeout(timer);
520
+ server.close();
521
+ }
522
+ }
523
+ export async function pushIndexObject(dataDir, kind, body, preferred) {
524
+ const token = await cloudToken(dataDir, preferred);
525
+ const lease = await call(`/v1/cloud/index/upload`, { method: "POST", body: JSON.stringify({ kind, bytes: body.length }), token });
526
+ const target = lease.upload_url ?? fail("internal", "The control plane vended no upload URL for the index slot");
527
+ const response = await fetch(target.url, { method: target.method || "PUT", headers: target.headers ?? {}, body: new Uint8Array(body) });
528
+ if (!response.ok)
529
+ fail("internal", `The index ${kind} upload was refused: ${response.status} ${response.statusText}`);
530
+ }
531
+ export async function pullIndexObject(dataDir, kind, preferred) {
532
+ const token = await cloudToken(dataDir, preferred);
533
+ const lease = await call(`/v1/cloud/index/download?kind=${kind}`, { token }).catch((error) => {
534
+ if (isVaultlineError(error) && /not.*found|no index/i.test(error.message))
535
+ return null;
536
+ throw error;
537
+ });
538
+ if (!lease?.download?.url)
539
+ return null;
540
+ const response = await fetch(lease.download.url);
541
+ if (response.status === 404)
542
+ return null;
543
+ if (!response.ok)
544
+ fail("internal", `The index ${kind} download was refused: ${response.status} ${response.statusText}`);
545
+ return Buffer.from(await response.arrayBuffer());
546
+ }
547
+ export async function listCloudArchives(dataDir, preferred) {
548
+ const token = await cloudToken(dataDir, preferred);
549
+ const rows = await call("/v1/cloud/archives", { token });
550
+ return Array.isArray(rows) ? rows : [];
551
+ }
552
+ /**
553
+ * Really deletes one stored object from managed storage.
554
+ *
555
+ * Goes through the control plane rather than storage directly, because it is
556
+ * the same call the account panel's Archives section makes — one delete path,
557
+ * so the product and the panel cannot come to mean different things by
558
+ * "deleted". The control plane removes the object and its row, and billing
559
+ * follows the row.
560
+ */
561
+ export async function deleteCloudArchive(dataDir, vaultRef, preferred) {
562
+ await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}`, { method: "DELETE", token: await cloudToken(dataDir, preferred) });
563
+ }
564
+ /**
565
+ * Fetches the ciphertext, whichever way the control plane delegated.
566
+ *
567
+ * Symmetrical with putCiphertext: a presigned URL is followed verbatim, and a
568
+ * vended credential is signed here. Providers that Spala cannot presign for —
569
+ * anything S3-compatible — arrive as the vended shape.
570
+ */
571
+ async function fetchLeasedObject(lease) {
572
+ if (lease.vended) {
573
+ assertVendedSignable(lease.vended);
574
+ assertVendedFresh(lease.vended, {});
575
+ const { S3UploadClient } = await import("./providers/s3.js");
576
+ const v = lease.vended;
577
+ if (!lease.object_key)
578
+ fail("internal", "Sealkeep Cloud vended a credential but no object key");
579
+ const client = new S3UploadClient(v.provider, { provider: v.provider, region: v.region, bucket: v.bucket, host: v.host, port: v.port, protocol: v.protocol }, { accessKeyId: v.akid, secretAccessKey: v.secret_access_key, sessionToken: v.session_token });
580
+ return client.download({
581
+ archiveId: lease.vault_ref, provider: v.provider, objectKey: lease.object_key, expiresAt: new Date(Date.now() + 9e5).toISOString(),
582
+ method: "MULTIPART", uploadUrl: "", requiredHeaders: {}, status: "active", uploadable: true
583
+ });
584
+ }
585
+ if (!lease.download?.url)
586
+ fail("internal", "Sealkeep Cloud returned a lease with nowhere to download from");
587
+ const response = await fetch(lease.download.url, { method: lease.download.method ?? "GET", headers: lease.download.headers });
588
+ if (!response.ok)
589
+ fail("internal", `Storage refused the download: ${response.status} ${response.statusText}`);
590
+ return Buffer.from(await response.arrayBuffer());
591
+ }
592
+ /**
593
+ * A byte range of a stored object, for chunk-granular reads. The whole-object
594
+ * hash cannot be checked on a slice — that is the point of a slice — so the
595
+ * caller's integrity comes from the per-chunk hashes and AEAD tags inside
596
+ * (see vaultline-crypto/chunk-access). Only the presigned-URL lease shape
597
+ * supports ranges today; a vended-credential lease falls back to whole-object.
598
+ */
599
+ export async function pullCiphertextRange(dataDir, vaultRef, storedOffset, storedLength, preferred) {
600
+ const token = await cloudToken(dataDir, preferred);
601
+ const lease = await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}/download`, { token });
602
+ if (!lease.download?.url) {
603
+ const whole = await fetchLeasedObject(lease);
604
+ return whole.subarray(storedOffset, storedOffset + storedLength);
605
+ }
606
+ const response = await fetch(lease.download.url, { headers: { range: `bytes=${storedOffset}-${storedOffset + storedLength - 1}` } });
607
+ if (response.status === 206)
608
+ return Buffer.from(await response.arrayBuffer());
609
+ if (response.ok) {
610
+ // A store that ignores Range answers 200 with everything; take the slice
611
+ // rather than failing a read that can still be served.
612
+ return Buffer.from(await response.arrayBuffer()).subarray(storedOffset, storedOffset + storedLength);
613
+ }
614
+ return fail("internal", `Storage refused the ranged read: ${response.status} ${response.statusText}`);
615
+ }
616
+ /**
617
+ * Existence and stored size of a managed object, proven against STORAGE, not
618
+ * the plane's row: the row is minted by metering and a metering row is not an
619
+ * object. A one-byte ranged GET on the download URL answers both questions
620
+ * for the cost of a header — the Content-Range total is the provider's own
621
+ * count. This is what lets chunk-folder verification and resume treat managed
622
+ * storage with exactly the discipline `verifyStored` demands of it.
623
+ */
624
+ export async function statCiphertext(dataDir, vaultRef, preferred) {
625
+ const token = await cloudToken(dataDir, preferred);
626
+ let lease;
627
+ try {
628
+ lease = await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}/download`, { token });
629
+ }
630
+ catch (error) {
631
+ if (isVaultlineError(error) && (error.code === "archive_not_found" || /returned 404|not[ _-]?found/i.test(error.message))) {
632
+ return { exists: false, bytes: 0 };
633
+ }
634
+ throw error;
635
+ }
636
+ if (!lease.download?.url)
637
+ return { exists: true, bytes: lease.bytes };
638
+ const response = await fetch(lease.download.url, { headers: { range: "bytes=0-0" } });
639
+ if (response.status === 404)
640
+ return { exists: false, bytes: 0 };
641
+ if (response.status === 206) {
642
+ const total = Number((response.headers.get("content-range") ?? "").split("/").pop());
643
+ await response.arrayBuffer().catch(() => undefined);
644
+ return { exists: true, bytes: Number.isFinite(total) && total > 0 ? total : lease.bytes };
645
+ }
646
+ if (response.ok)
647
+ return { exists: true, bytes: Buffer.from(await response.arrayBuffer()).length };
648
+ return fail("internal", `Storage refused the presence check: ${response.status} ${response.statusText}`);
649
+ }
650
+ export async function pullCiphertext(dataDir, vaultRef, preferred) {
651
+ const token = await cloudToken(dataDir, preferred);
652
+ const lease = await call(`/v1/cloud/archives/${encodeURIComponent(vaultRef)}/download`, { token });
653
+ const ciphertext = await fetchLeasedObject(lease);
654
+ const digest = createHash("sha256").update(ciphertext).digest("hex");
655
+ if (digest !== lease.ciphertext_sha256)
656
+ fail("internal", "Downloaded ciphertext does not match the hash recorded at upload; refusing it");
657
+ return { ciphertext, bytes: ciphertext.length };
658
+ }
659
+ /**
660
+ * Managed objects are self-describing.
661
+ *
662
+ * The envelope — wrapped keys, nonce prefix, chunk layout — used to live only in
663
+ * the local index, which meant a customer who lost the machine held ciphertext in
664
+ * our bucket, their identity key in escrow, and no way to put the two together.
665
+ * That is not a backup. So the uploaded object carries its own envelope:
666
+ *
667
+ * "VLA1" | uint32be(envelopeJsonLength) | envelopeJson | ciphertext
668
+ *
669
+ * The envelope's archive key is already wrapped to the recovery phrase, so this
670
+ * adds nothing the server can read; the blob stays opaque to us end to end.
671
+ * Objects written before this change have no magic and unframe as legacy, which
672
+ * keeps them restorable from the machine that made them.
673
+ */
674
+ const OBJECT_MAGIC = Buffer.from("VLA1", "ascii");
675
+ export function frameObject(envelope, ciphertext) {
676
+ const meta = Buffer.from(JSON.stringify(envelope), "utf8");
677
+ const length = Buffer.allocUnsafe(4);
678
+ length.writeUInt32BE(meta.length, 0);
679
+ return Buffer.concat([OBJECT_MAGIC, length, meta, ciphertext]);
680
+ }
681
+ export function unframeObject(blob) {
682
+ if (blob.length < OBJECT_MAGIC.length + 4 || !blob.subarray(0, OBJECT_MAGIC.length).equals(OBJECT_MAGIC))
683
+ return { envelope: null, ciphertext: blob };
684
+ const metaLength = blob.readUInt32BE(OBJECT_MAGIC.length);
685
+ const start = OBJECT_MAGIC.length + 4;
686
+ if (metaLength > blob.length - start)
687
+ fail("internal", "Archive header is truncated");
688
+ try {
689
+ return { envelope: JSON.parse(blob.subarray(start, start + metaLength).toString("utf8")), ciphertext: blob.subarray(start + metaLength) };
690
+ }
691
+ catch {
692
+ fail("internal", "Archive header is not readable");
693
+ }
694
+ }
695
+ /**
696
+ * Fetch one archive and open it, on any machine.
697
+ *
698
+ * This is the whole disaster-recovery story in one call: the recovery phrase
699
+ * unwraps the archive key from the envelope the object carries, so a fresh
700
+ * laptop with nothing but the phrase can read the archive back. The plaintext
701
+ * hash recorded at seal time is checked inside decryptArchive, after the AEAD
702
+ * has authenticated every chunk.
703
+ */
704
+ export async function openFromCloud(dataDir, vaultRef, destination, rawPhrase, preferred) {
705
+ const { ciphertext: blob } = await pullCiphertext(dataDir, vaultRef, preferred);
706
+ const { envelope, ciphertext } = unframeObject(blob);
707
+ if (!envelope) {
708
+ // Two very different objects arrive with no envelope: a plain-mode
709
+ // archive (readable by its owner's explicit choice at setup — bare gzip,
710
+ // recognisable by its magic bytes) and a genuinely pre-envelope legacy
711
+ // object. Refusing the first with advice about the second sent people
712
+ // hunting for a machine that does not need to exist.
713
+ if (blob.length > 2 && blob[0] === 0x1f && blob[1] === 0x8b) {
714
+ const { gunzip } = await import("node:zlib");
715
+ const { promisify } = await import("node:util");
716
+ const plain = await promisify(gunzip)(blob);
717
+ await writeFile(destination, plain, { mode: 0o600 });
718
+ return { output: destination, bytes: plain.length };
719
+ }
720
+ fail("archive_not_found", `${vaultRef} was stored before archives carried their own envelope. Restore it with \`sealkeep recover ${vaultRef} <destination>\` on the machine that archived it, then push it again.`);
721
+ }
722
+ let plaintext;
723
+ try {
724
+ plaintext = openEnvelope(envelope, ciphertext, { phrase: canonicalPhrase(rawPhrase) });
725
+ }
726
+ catch (error) {
727
+ fail("recovery_phrase_mismatch", `Unable to open ${vaultRef}: ${error instanceof Error ? error.message : "decryption failed"}`);
728
+ }
729
+ await writeFile(destination, plaintext, { mode: 0o600 });
730
+ return { output: destination, bytes: plaintext.length };
731
+ }
732
+ /**
733
+ * The judgement, kept separate from the fetching so it can be tested honestly.
734
+ *
735
+ * `recorded` is what the control plane says the archive is; `got` is what
736
+ * storage actually returned, or the error it failed with. A raw status code is
737
+ * not an explanation, so a missing object is named in words a customer can act
738
+ * on.
739
+ */
740
+ export function classifyArchive(recorded, got) {
741
+ const bytes = Number(recorded.bytes) || 0;
742
+ if ("error" in got) {
743
+ return {
744
+ vaultRef: recorded.vault_ref, bytes, ok: false,
745
+ problem: /\b404\b/.test(got.error) ? "listed as stored, but there is no object behind it" : got.error
746
+ };
747
+ }
748
+ if (got.length !== bytes) {
749
+ return { vaultRef: recorded.vault_ref, bytes, ok: false, problem: `storage returned ${got.length} bytes, the record says ${bytes}` };
750
+ }
751
+ return { vaultRef: recorded.vault_ref, bytes, ok: true };
752
+ }
753
+ /**
754
+ * Proves the archives really are retrievable.
755
+ *
756
+ * A backup nobody has tested is a hope, not a backup. This fetches each durable
757
+ * archive back and checks it against the hash recorded at upload, so the two
758
+ * ways an archive can quietly rot are both caught: the object is missing from
759
+ * storage, or the bytes no longer match what was sealed.
760
+ *
761
+ * It deliberately re-downloads rather than trusting a HEAD. Size alone would
762
+ * pass an object that was truncated and re-padded, and the whole point of the
763
+ * exercise is to distrust the happy path.
764
+ */
765
+ export async function verifyArchives(dataDir, options = {}, preferred) {
766
+ const rows = (await listCloudArchives(dataDir, preferred)).filter((row) => row.state === "durable");
767
+ const checked = typeof options.limit === "number" ? rows.slice(0, options.limit) : rows;
768
+ const results = [];
769
+ for (const row of checked) {
770
+ const bytes = Number(row.bytes) || 0;
771
+ let check;
772
+ try {
773
+ const { ciphertext } = await pullCiphertext(dataDir, row.vault_ref, preferred);
774
+ check = classifyArchive({ vault_ref: row.vault_ref, bytes }, ciphertext);
775
+ }
776
+ catch (error) {
777
+ check = classifyArchive({ vault_ref: row.vault_ref, bytes }, { error: error instanceof Error ? error.message : "unknown error" });
778
+ }
779
+ // Record what was just proved.
780
+ //
781
+ // Verification used to be write-only: it fetched every archive back,
782
+ // matched every hash, printed "all archives fetched back and matched", and
783
+ // changed nothing. So an archive whose upload succeeded but whose process
784
+ // died before writing the marker — a killed terminal, a closed laptop —
785
+ // stayed "local only" forever, and `retention` refused to reclaim its
786
+ // source because reclaiming requires a verified remote. The bytes were
787
+ // safe, the product knew they were safe, and it still would not act on it.
788
+ //
789
+ // Only ever upgrades: a record that already claims a verified remote is
790
+ // left alone, so this cannot overwrite a bring-your-own-bucket target with
791
+ // a managed one.
792
+ if (check.ok)
793
+ await recordVerified(dataDir, check.vaultRef, bytes).catch(() => undefined);
794
+ results.push(check);
795
+ options.onProgress?.(check);
796
+ }
797
+ return results;
798
+ }
799
+ /** Writes the verified marker for an archive this machine holds locally. */
800
+ async function recordVerified(dataDir, vaultRef, bytes) {
801
+ const record = (await listArchives(dataDir)).find((item) => item.id === vaultRef);
802
+ if (!record || record.version !== 2 || record.remote?.verifiedAt)
803
+ return;
804
+ const stored = {
805
+ provider: "vaultline", bucket: "vaultline-managed", objectKey: vaultRef,
806
+ bytes, checksum: record.cipher.ciphertextSha256, verifiedAt: new Date().toISOString()
807
+ };
808
+ await writeRecord((await readConfig(dataDir)).storage.root, { ...record, remote: stored });
809
+ }
810
+ export async function pushArchive(dataDir, archiveId, preferred, options = {}) {
811
+ await readConfig(dataDir);
812
+ const record = (await listArchives(dataDir)).find((item) => item.id === archiveId);
813
+ if (!record)
814
+ fail("archive_not_found", `Archive not found: ${archiveId}`, { archiveId });
815
+ const ciphertext = await readFile(record.objectPath);
816
+ if (sha256(ciphertext) !== record.cipher.ciphertextSha256)
817
+ fail("ciphertext_integrity_failed", "Ciphertext changed on disk since it was archived", { archiveId });
818
+ // What we store is the envelope plus the ciphertext, so the archive can be
819
+ // opened by anyone holding the recovery phrase and nothing else.
820
+ const object = record.envelope ? frameObject(record.envelope, ciphertext) : ciphertext;
821
+ const digest = sha256(object);
822
+ const lease = await requestUpload(dataDir, { vaultRef: record.id, ciphertextSha256: digest, bytes: object.length }, preferred);
823
+ await putCiphertext(lease, object, options.signal);
824
+ try {
825
+ await completeUpload(dataDir, lease.lease_id, object.length, preferred);
826
+ // Retention reads remote.verifiedAt to decide whether a source may be
827
+ // reclaimed, so this is the field that authorises deleting a customer's
828
+ // only local copy. It is written only once storage has been made to prove
829
+ // it holds these exact bytes — the same discipline the bring-your-own-bucket
830
+ // path follows.
831
+ const problem = await verifyStored(dataDir, lease, object, digest, preferred);
832
+ if (problem) {
833
+ return {
834
+ archiveId: record.id, objectKey: lease.object_key, bytes: object.length, durable: false,
835
+ note: `Uploaded, but managed storage could not be proven to hold it: ${problem}. The local copy is untouched.`
836
+ };
837
+ }
838
+ const stored = {
839
+ provider: "vaultline", bucket: "vaultline-managed", objectKey: lease.object_key,
840
+ bytes: object.length, checksum: digest, verifiedAt: new Date().toISOString()
841
+ };
842
+ await writeRecord((await readConfig(dataDir)).storage.root, { ...record, remote: stored });
843
+ return { archiveId: record.id, objectKey: lease.object_key, bytes: object.length, durable: true };
844
+ }
845
+ catch (error) {
846
+ return {
847
+ archiveId: record.id, objectKey: lease.object_key, bytes: object.length, durable: false,
848
+ note: `Uploaded, but the control plane did not record it: ${error instanceof Error ? error.message : "unknown error"}`
849
+ };
850
+ }
851
+ }