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,156 @@
1
+ import type { ProviderKind, ProviderUploadClient, UploadLease } from "../control-plane.js";
2
+ /**
3
+ * Google Drive as a bring-your-own storage provider (Drive API v3).
4
+ *
5
+ * The privacy story lives in the scope. Sealkeep asks Google for
6
+ * `drive.file` ONLY: in Google's permission model that grant lets an app see
7
+ * and touch just the files it created itself — never the rest of the Drive,
8
+ * not even file names. So connecting Sealkeep exposes nothing a person
9
+ * already keeps in Drive, and what Sealkeep does create there is ciphertext
10
+ * sealed on this machine before any byte leaves it.
11
+ *
12
+ * Authentication is the OAuth desktop flow with PKCE and a loopback redirect
13
+ * (RFC 8252): a local one-shot server on 127.0.0.1 receives the code, and the
14
+ * PKCE verifier — not a client secret, desktop apps cannot keep one — proves
15
+ * the exchange. The only long-lived secret is the refresh token, which goes
16
+ * into the OS keystore via secrets.ts and never into a config file.
17
+ */
18
+ export declare const GDRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
19
+ /** Placeholder until a first-party client id ships. Point VAULTLINE_GDRIVE_CLIENT_ID at your own Desktop-app OAuth client to connect today. */
20
+ export declare const GDRIVE_CLIENT_ID_PLACEHOLDER = "000000000000-vaultline-placeholder.apps.googleusercontent.com";
21
+ export declare function gdriveClientId(env?: NodeJS.ProcessEnv): string;
22
+ export declare const GDRIVE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
23
+ export declare const GDRIVE_TOKEN_URL = "https://oauth2.googleapis.com/token";
24
+ export declare const GDRIVE_API_BASE = "https://www.googleapis.com";
25
+ /** What the keystore holds for a connected Drive. Restated nowhere: secrets.ts imports this into its union. */
26
+ export type GdriveCredentials = {
27
+ kind: "gdrive";
28
+ refreshToken: string;
29
+ clientId: string;
30
+ /** "cloud" = the refresh needs the account's server (web-client secret lives there). */
31
+ exchange?: "cloud";
32
+ /** Runtime-only: attached at load time, never persisted. Refreshes through the plane. */
33
+ refresher?: (refreshToken: string) => Promise<{
34
+ accessToken: string;
35
+ expiresIn: number;
36
+ }>;
37
+ };
38
+ export type GdriveEndpoint = {
39
+ /** Drive has no buckets; archives live in one folder with this name at the Drive root. */
40
+ folderName: string;
41
+ /** Store in the hidden per-app `appDataFolder` space instead of a visible folder. */
42
+ appData?: boolean;
43
+ /** Test overrides. When host is set, the token endpoint is `<base>/token` on the same stub. */
44
+ host?: string;
45
+ port?: number;
46
+ protocol?: "https" | "http";
47
+ };
48
+ /** RFC 7636 verifier/challenge pair: 32 random bytes → 43-char verifier, S256 challenge. */
49
+ export declare function createPkcePair(random?: () => Buffer): {
50
+ verifier: string;
51
+ challenge: string;
52
+ method: "S256";
53
+ };
54
+ export declare function gdriveConsentUrl(input: {
55
+ clientId: string;
56
+ redirectUri: string;
57
+ challenge: string;
58
+ state: string;
59
+ authUrl?: string;
60
+ }): string;
61
+ export declare function exchangeCodeForTokens(input: {
62
+ code: string;
63
+ verifier: string;
64
+ clientId: string;
65
+ redirectUri: string;
66
+ tokenUrl?: string;
67
+ fetchImpl?: typeof fetch;
68
+ }): Promise<{
69
+ refreshToken: string;
70
+ accessToken: string;
71
+ expiresIn: number;
72
+ }>;
73
+ /**
74
+ * Fire-and-forget platform opener. Failing to open a browser is never failing
75
+ * to connect — the consent URL is reported to the caller either way — so this
76
+ * mirrors ui-server's opener rules (BROWSER wins, `none` means don't) without
77
+ * importing that module: ui-server reaches local-api, and a provider that
78
+ * pulled the whole API surface into every upload would be an import cycle.
79
+ */
80
+ export declare function launchBrowser(url: string, env?: NodeJS.ProcessEnv, platformName?: NodeJS.Platform): void;
81
+ export type ConnectOptions = {
82
+ clientId?: string;
83
+ env?: NodeJS.ProcessEnv;
84
+ /** Called with the consent URL before anything waits on the browser; the CLI prints it here. */
85
+ onConsentUrl?: (url: string) => void;
86
+ /** Replaces the platform browser launch; tests drive the loopback redirect from here. */
87
+ opener?: (url: string) => void | Promise<void>;
88
+ fetchImpl?: typeof fetch;
89
+ authUrl?: string;
90
+ tokenUrl?: string;
91
+ /** Loopback port; 0 (the default) asks the OS for a random one, which is what Google's loopback flow expects. */
92
+ port?: number;
93
+ timeoutMs?: number;
94
+ };
95
+ /**
96
+ * The whole consent dance: loopback server, consent URL, code, PKCE exchange.
97
+ * Returns the credential shape the keystore stores. Everything slow or
98
+ * environment-shaped (fetch, browser, endpoints) is injectable so the entire
99
+ * flow runs offline under test.
100
+ */
101
+ export declare function connectGdrive(options?: ConnectOptions): Promise<GdriveCredentials>;
102
+ /**
103
+ * Drive upload client.
104
+ *
105
+ * A lease's objectKey (e.g. `vaultline/<id>.vlarchive`) has no path meaning in
106
+ * Drive, so the mapping is: files live flat in one folder (or the hidden
107
+ * appDataFolder space), named by the key's basename, and each carries
108
+ * `appProperties.vaultlineKey = <full objectKey>`. head/download locate by that
109
+ * property — never by name, which Drive does not keep unique.
110
+ */
111
+ export declare class GdriveUploadClient implements ProviderUploadClient {
112
+ private readonly endpoint;
113
+ private readonly credentials;
114
+ readonly kind: ProviderKind;
115
+ private folderId?;
116
+ private accessToken?;
117
+ private readonly chunkBytes;
118
+ private readonly fetchImpl;
119
+ private readonly now;
120
+ constructor(endpoint: GdriveEndpoint, credentials: GdriveCredentials, options?: {
121
+ chunkBytes?: number;
122
+ fetchImpl?: typeof fetch;
123
+ now?: () => number;
124
+ });
125
+ private base;
126
+ private tokenUrl;
127
+ private space;
128
+ /** Access token minted from the refresh token, cached until shortly before expiry so it never dies mid-chunk. */
129
+ private token;
130
+ private api;
131
+ private list;
132
+ /** Exact lookup by the appProperties key. Two files claiming one key is a state we refuse to guess about. */
133
+ private findByKey;
134
+ /** Finds or creates the archive folder; the id is cached per instance. */
135
+ private parentId;
136
+ /**
137
+ * Starts a resumable session (create, or in-place update when the key already
138
+ * has a file — re-uploading must never mint a same-named duplicate, or the
139
+ * appProperties lookup stops being exact). The session URI is a bearer
140
+ * capability: used immediately, never persisted, never logged.
141
+ */
142
+ private initiateResumable;
143
+ upload(lease: UploadLease, ciphertext: Buffer): Promise<{
144
+ remoteChecksum: string;
145
+ bytes: number;
146
+ }>;
147
+ head(lease: UploadLease): Promise<{
148
+ exists: boolean;
149
+ bytes: number;
150
+ checksum?: string;
151
+ }>;
152
+ /** Reads an object back for restore or offload-proof: locate by key, then `alt=media`. */
153
+ /** Removes the file holding this object key. Absent already means done. */
154
+ deleteByKey(objectKey: string): Promise<void>;
155
+ download(lease: UploadLease): Promise<Buffer>;
156
+ }
@@ -0,0 +1,335 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { createServer } from "node:http";
4
+ import { fail, VaultlineError } from "../errors.js";
5
+ /**
6
+ * Google Drive as a bring-your-own storage provider (Drive API v3).
7
+ *
8
+ * The privacy story lives in the scope. Sealkeep asks Google for
9
+ * `drive.file` ONLY: in Google's permission model that grant lets an app see
10
+ * and touch just the files it created itself — never the rest of the Drive,
11
+ * not even file names. So connecting Sealkeep exposes nothing a person
12
+ * already keeps in Drive, and what Sealkeep does create there is ciphertext
13
+ * sealed on this machine before any byte leaves it.
14
+ *
15
+ * Authentication is the OAuth desktop flow with PKCE and a loopback redirect
16
+ * (RFC 8252): a local one-shot server on 127.0.0.1 receives the code, and the
17
+ * PKCE verifier — not a client secret, desktop apps cannot keep one — proves
18
+ * the exchange. The only long-lived secret is the refresh token, which goes
19
+ * into the OS keystore via secrets.ts and never into a config file.
20
+ */
21
+ export const GDRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
22
+ /** Placeholder until a first-party client id ships. Point VAULTLINE_GDRIVE_CLIENT_ID at your own Desktop-app OAuth client to connect today. */
23
+ export const GDRIVE_CLIENT_ID_PLACEHOLDER = "000000000000-vaultline-placeholder.apps.googleusercontent.com";
24
+ export function gdriveClientId(env = process.env) {
25
+ return env.VAULTLINE_GDRIVE_CLIENT_ID?.trim() || GDRIVE_CLIENT_ID_PLACEHOLDER;
26
+ }
27
+ export const GDRIVE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
28
+ export const GDRIVE_TOKEN_URL = "https://oauth2.googleapis.com/token";
29
+ export const GDRIVE_API_BASE = "https://www.googleapis.com";
30
+ const base64url = (buffer) => buffer.toString("base64url");
31
+ /** RFC 7636 verifier/challenge pair: 32 random bytes → 43-char verifier, S256 challenge. */
32
+ export function createPkcePair(random = () => randomBytes(32)) {
33
+ const verifier = base64url(random());
34
+ return { verifier, challenge: base64url(createHash("sha256").update(verifier).digest()), method: "S256" };
35
+ }
36
+ export function gdriveConsentUrl(input) {
37
+ const url = new URL(input.authUrl ?? GDRIVE_AUTH_URL);
38
+ url.searchParams.set("client_id", input.clientId);
39
+ url.searchParams.set("redirect_uri", input.redirectUri);
40
+ url.searchParams.set("response_type", "code");
41
+ url.searchParams.set("scope", GDRIVE_SCOPE);
42
+ url.searchParams.set("code_challenge", input.challenge);
43
+ url.searchParams.set("code_challenge_method", "S256");
44
+ url.searchParams.set("state", input.state);
45
+ // A refresh token is only issued for offline access, and only reliably when
46
+ // consent is re-prompted; without both, a reconnect can come back token-less.
47
+ url.searchParams.set("access_type", "offline");
48
+ url.searchParams.set("prompt", "consent");
49
+ return url.toString();
50
+ }
51
+ export async function exchangeCodeForTokens(input) {
52
+ const fetchImpl = input.fetchImpl ?? fetch;
53
+ const response = await fetchImpl(input.tokenUrl ?? GDRIVE_TOKEN_URL, {
54
+ method: "POST",
55
+ headers: { "content-type": "application/x-www-form-urlencoded" },
56
+ body: new URLSearchParams({
57
+ grant_type: "authorization_code",
58
+ code: input.code,
59
+ client_id: input.clientId,
60
+ redirect_uri: input.redirectUri,
61
+ code_verifier: input.verifier
62
+ }).toString()
63
+ });
64
+ if (!response.ok)
65
+ fail("unauthorized", `Google rejected the authorization code: ${response.status} ${response.statusText}`, { status: response.status });
66
+ const data = await response.json();
67
+ const refreshToken = data.refresh_token
68
+ ?? fail("unauthorized", "Google returned no refresh token. Remove Sealkeep at https://myaccount.google.com/permissions and connect again.");
69
+ return { refreshToken, accessToken: data.access_token ?? "", expiresIn: data.expires_in ?? 0 };
70
+ }
71
+ /**
72
+ * Fire-and-forget platform opener. Failing to open a browser is never failing
73
+ * to connect — the consent URL is reported to the caller either way — so this
74
+ * mirrors ui-server's opener rules (BROWSER wins, `none` means don't) without
75
+ * importing that module: ui-server reaches local-api, and a provider that
76
+ * pulled the whole API surface into every upload would be an import cycle.
77
+ */
78
+ export function launchBrowser(url, env = process.env, platformName = process.platform) {
79
+ const preference = env.BROWSER?.trim();
80
+ if (preference?.toLowerCase() === "none")
81
+ return;
82
+ const opener = preference ? { command: preference, args: [url] }
83
+ : platformName === "darwin" ? { command: "open", args: [url] }
84
+ : platformName === "win32" ? { command: "cmd", args: ["/c", "start", "", url] }
85
+ : { command: "xdg-open", args: [url] };
86
+ try {
87
+ const child = spawn(opener.command, opener.args, { detached: true, stdio: "ignore", windowsHide: true });
88
+ child.once("error", () => { });
89
+ child.unref();
90
+ }
91
+ catch { /* same: the printed URL is the fallback */ }
92
+ }
93
+ /**
94
+ * The whole consent dance: loopback server, consent URL, code, PKCE exchange.
95
+ * Returns the credential shape the keystore stores. Everything slow or
96
+ * environment-shaped (fetch, browser, endpoints) is injectable so the entire
97
+ * flow runs offline under test.
98
+ */
99
+ export async function connectGdrive(options = {}) {
100
+ const clientId = options.clientId ?? gdriveClientId(options.env);
101
+ const { verifier, challenge } = createPkcePair();
102
+ const state = base64url(randomBytes(16));
103
+ const server = createServer();
104
+ await new Promise((ready, refuse) => { server.once("error", refuse); server.listen(options.port ?? 0, "127.0.0.1", ready); });
105
+ const port = server.address().port;
106
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
107
+ const consentUrl = gdriveConsentUrl({ clientId, redirectUri, challenge, state, authUrl: options.authUrl });
108
+ let timer;
109
+ const code = new Promise((resolve, reject) => {
110
+ timer = setTimeout(() => reject(new VaultlineError("unauthorized", `No response from the browser within ${Math.round((options.timeoutMs ?? 300_000) / 1000)}s. Run the connect command again.`)), options.timeoutMs ?? 300_000);
111
+ server.on("request", (request, response) => {
112
+ const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
113
+ if (url.pathname !== "/callback") {
114
+ response.writeHead(404);
115
+ response.end();
116
+ return;
117
+ }
118
+ const deny = (message) => {
119
+ response.writeHead(400, { "content-type": "text/html; charset=utf-8" });
120
+ response.end("<p>Sealkeep could not finish connecting. Return to the terminal.</p>");
121
+ reject(new VaultlineError("unauthorized", message));
122
+ };
123
+ if (url.searchParams.get("error"))
124
+ return deny(`Google reported: ${url.searchParams.get("error")}`);
125
+ // A code arriving under the wrong state was not minted for this attempt;
126
+ // accepting it would let anything that can reach 127.0.0.1 splice its own
127
+ // account into this vault's uploads.
128
+ if (url.searchParams.get("state") !== state)
129
+ return deny("The browser reply did not match this connection attempt (state mismatch)");
130
+ const received = url.searchParams.get("code");
131
+ if (!received)
132
+ return deny("The browser reply carried no authorization code");
133
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
134
+ response.end("<p>Sealkeep is connected to Google Drive. You can close this tab.</p>");
135
+ resolve(received);
136
+ });
137
+ });
138
+ // A denial can land while the opener is still being awaited, before anything
139
+ // awaits `code`; without a handler already attached that early rejection is
140
+ // "unhandled" and can bring the process down instead of failing the command.
141
+ code.catch(() => { });
142
+ try {
143
+ options.onConsentUrl?.(consentUrl);
144
+ await Promise.resolve((options.opener ?? launchBrowser)(consentUrl)).catch(() => { });
145
+ const authorizationCode = await code;
146
+ const tokens = await exchangeCodeForTokens({ code: authorizationCode, verifier, clientId, redirectUri, tokenUrl: options.tokenUrl, fetchImpl: options.fetchImpl });
147
+ return { kind: "gdrive", refreshToken: tokens.refreshToken, clientId };
148
+ }
149
+ finally {
150
+ if (timer)
151
+ clearTimeout(timer);
152
+ server.close();
153
+ }
154
+ }
155
+ /** Drive query strings quote values with single quotes; backslash-escape the two characters that break out. */
156
+ const escapeQuery = (value) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
157
+ const FOLDER_MIME = "application/vnd.google-apps.folder";
158
+ /**
159
+ * Drive upload client.
160
+ *
161
+ * A lease's objectKey (e.g. `vaultline/<id>.vlarchive`) has no path meaning in
162
+ * Drive, so the mapping is: files live flat in one folder (or the hidden
163
+ * appDataFolder space), named by the key's basename, and each carries
164
+ * `appProperties.vaultlineKey = <full objectKey>`. head/download locate by that
165
+ * property — never by name, which Drive does not keep unique.
166
+ */
167
+ export class GdriveUploadClient {
168
+ endpoint;
169
+ credentials;
170
+ // "gdrive" is not yet in control-plane's ProviderKind union — that file (and its
171
+ // restatements in types.ts / local-api.ts) is frozen in this change, so the new
172
+ // kind rides through under a cast contained to this one line. Remove the cast
173
+ // when the union learns "gdrive".
174
+ kind = "gdrive";
175
+ folderId;
176
+ accessToken;
177
+ chunkBytes;
178
+ fetchImpl;
179
+ now;
180
+ constructor(endpoint, credentials, options = {}) {
181
+ this.endpoint = endpoint;
182
+ this.credentials = credentials;
183
+ this.chunkBytes = options.chunkBytes ?? 8 * 1024 * 1024;
184
+ // Drive rejects resumable chunks that are not 256 KiB multiples (except the last).
185
+ if (this.chunkBytes % (256 * 1024) !== 0 || this.chunkBytes <= 0) {
186
+ fail("invalid_argument", `Drive resumable chunks must be positive multiples of 256 KiB, not ${this.chunkBytes} bytes`);
187
+ }
188
+ this.fetchImpl = options.fetchImpl ?? fetch;
189
+ this.now = options.now ?? Date.now;
190
+ }
191
+ base() {
192
+ const { host, port, protocol } = this.endpoint;
193
+ if (!host)
194
+ return GDRIVE_API_BASE;
195
+ return `${protocol ?? "https"}://${host}${port ? `:${port}` : ""}`;
196
+ }
197
+ tokenUrl() { return this.endpoint.host ? `${this.base()}/token` : GDRIVE_TOKEN_URL; }
198
+ space() { return this.endpoint.appData ? "appDataFolder" : "drive"; }
199
+ /** Access token minted from the refresh token, cached until shortly before expiry so it never dies mid-chunk. */
200
+ async token() {
201
+ if (this.accessToken && this.now() < this.accessToken.expiresAt)
202
+ return this.accessToken.token;
203
+ // A cloud-exchanged credential refreshes through the account's server —
204
+ // the web client's secret lives there, never in this process.
205
+ if (this.credentials.refresher) {
206
+ const fresh = await this.credentials.refresher(this.credentials.refreshToken);
207
+ this.accessToken = { token: fresh.accessToken, expiresAt: this.now() + Math.max(fresh.expiresIn - 60, 60) * 1000 };
208
+ return fresh.accessToken;
209
+ }
210
+ const response = await this.fetchImpl(this.tokenUrl(), {
211
+ method: "POST",
212
+ headers: { "content-type": "application/x-www-form-urlencoded" },
213
+ body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: this.credentials.refreshToken, client_id: this.credentials.clientId }).toString()
214
+ });
215
+ if (!response.ok)
216
+ fail("unauthorized", `Google Drive refused to mint an access token (${response.status}). Reconnect with: sealkeep storage connect gdrive`, { status: response.status });
217
+ const data = await response.json();
218
+ const token = data.access_token ?? fail("unauthorized", "Google Drive's token response carried no access token");
219
+ this.accessToken = { token, expiresAt: this.now() + Math.max((data.expires_in ?? 0) - 60, 60) * 1000 };
220
+ return token;
221
+ }
222
+ async api(path, init = {}) {
223
+ const token = await this.token();
224
+ return this.fetchImpl(`${this.base()}${path}`, { ...init, headers: { authorization: `Bearer ${token}`, ...init.headers } });
225
+ }
226
+ async list(query) {
227
+ const params = new URLSearchParams({ q: query, spaces: this.space(), fields: "files(id,name,size,md5Checksum)", pageSize: "10" });
228
+ const response = await this.api(`/drive/v3/files?${params.toString()}`);
229
+ if (!response.ok)
230
+ fail("internal", `Google Drive file lookup failed: ${response.status} ${response.statusText}`, { status: response.status });
231
+ const data = await response.json();
232
+ return data.files ?? [];
233
+ }
234
+ /** Exact lookup by the appProperties key. Two files claiming one key is a state we refuse to guess about. */
235
+ async findByKey(objectKey) {
236
+ const files = await this.list(`appProperties has { key = 'vaultlineKey' and value = '${escapeQuery(objectKey)}' } and trashed = false`);
237
+ if (files.length > 1)
238
+ fail("internal", `Google Drive holds ${files.length} files claiming ${objectKey}; refusing to pick one`, { objectKey, count: files.length });
239
+ return files[0] ?? null;
240
+ }
241
+ /** Finds or creates the archive folder; the id is cached per instance. */
242
+ async parentId() {
243
+ if (this.endpoint.appData)
244
+ return "appDataFolder";
245
+ if (this.folderId)
246
+ return this.folderId;
247
+ const name = this.endpoint.folderName;
248
+ const existing = (await this.list(`name = '${escapeQuery(name)}' and mimeType = '${FOLDER_MIME}' and trashed = false`))[0]?.id;
249
+ if (existing) {
250
+ this.folderId = existing;
251
+ return existing;
252
+ }
253
+ const response = await this.api("/drive/v3/files?fields=id", {
254
+ method: "POST",
255
+ headers: { "content-type": "application/json; charset=UTF-8" },
256
+ body: JSON.stringify({ name, mimeType: FOLDER_MIME })
257
+ });
258
+ if (!response.ok)
259
+ fail("internal", `Could not create the "${name}" folder in Google Drive: ${response.status} ${response.statusText}`, { status: response.status });
260
+ const created = await response.json();
261
+ this.folderId = created.id ?? fail("internal", "Drive created a folder but returned no id");
262
+ return this.folderId;
263
+ }
264
+ /**
265
+ * Starts a resumable session (create, or in-place update when the key already
266
+ * has a file — re-uploading must never mint a same-named duplicate, or the
267
+ * appProperties lookup stops being exact). The session URI is a bearer
268
+ * capability: used immediately, never persisted, never logged.
269
+ */
270
+ async initiateResumable(objectKey, existingFileId, totalBytes) {
271
+ const metadata = existingFileId
272
+ ? { appProperties: { vaultlineKey: objectKey } }
273
+ : { name: objectKey.split("/").pop() ?? objectKey, parents: [await this.parentId()], appProperties: { vaultlineKey: objectKey } };
274
+ const response = await this.api(existingFileId ? `/upload/drive/v3/files/${existingFileId}?uploadType=resumable` : "/upload/drive/v3/files?uploadType=resumable", {
275
+ method: existingFileId ? "PATCH" : "POST",
276
+ headers: { "content-type": "application/json; charset=UTF-8", "x-upload-content-length": String(totalBytes) },
277
+ body: JSON.stringify(metadata)
278
+ });
279
+ if (!response.ok)
280
+ fail("internal", `Could not start a Drive resumable upload for ${objectKey}: ${response.status} ${response.statusText}`, { objectKey, status: response.status });
281
+ return response.headers.get("location") ?? fail("internal", `Drive's resumable initiation for ${objectKey} returned no session URI`, { objectKey });
282
+ }
283
+ async upload(lease, ciphertext) {
284
+ const objectKey = lease.objectKey;
285
+ const existing = await this.findByKey(objectKey);
286
+ const session = await this.initiateResumable(objectKey, existing?.id ?? null, ciphertext.length);
287
+ for (let offset = 0; offset < Math.max(ciphertext.length, 1); offset += this.chunkBytes) {
288
+ const slice = ciphertext.subarray(offset, Math.min(offset + this.chunkBytes, ciphertext.length));
289
+ const last = offset + slice.length >= ciphertext.length;
290
+ const range = ciphertext.length === 0 ? "bytes */0" : `bytes ${offset}-${offset + slice.length - 1}/${ciphertext.length}`;
291
+ const response = await this.fetchImpl(session, { method: "PUT", body: new Uint8Array(slice), headers: { "content-range": range } });
292
+ // 308 is "resume incomplete", the expected status between chunks.
293
+ if (!response.ok && !(response.status === 308 && !last)) {
294
+ fail("internal", `Drive resumable upload for ${objectKey} failed at ${range}: ${response.status} ${response.statusText}`, { objectKey, status: response.status });
295
+ }
296
+ if (last)
297
+ break;
298
+ }
299
+ // The whole-object proof Drive reports back is `md5Checksum` in file
300
+ // metadata, as lowercase hex — it computes no SHA-256, and unlike GCS's
301
+ // base64 header this one is hex. The verifier in upload.ts compares this
302
+ // value against head()'s, so both sides must speak that same hex dialect;
303
+ // MD5 here is transport proof, not trust — the ciphertext is
304
+ // AEAD-authenticated and restore re-checks its SHA-256 independently.
305
+ return { remoteChecksum: createHash("md5").update(ciphertext).digest("hex"), bytes: ciphertext.length };
306
+ }
307
+ async head(lease) {
308
+ const file = await this.findByKey(lease.objectKey);
309
+ if (!file)
310
+ return { exists: false, bytes: 0 };
311
+ // `md5Checksum` (hex) is absent for Google-native docs but always present
312
+ // for binary content we wrote. If it is ever missing the object cannot be
313
+ // proven from here, and undefined makes the verifier refuse rather than pretend.
314
+ return { exists: true, bytes: Number(file.size ?? "0"), checksum: file.md5Checksum };
315
+ }
316
+ /** Reads an object back for restore or offload-proof: locate by key, then `alt=media`. */
317
+ /** Removes the file holding this object key. Absent already means done. */
318
+ async deleteByKey(objectKey) {
319
+ const existing = await this.findByKey(objectKey);
320
+ if (!existing)
321
+ return;
322
+ const response = await this.api(`/drive/v3/files/${existing.id}`, { method: "DELETE" });
323
+ if (!response.ok && response.status !== 404) {
324
+ fail("internal", `Drive refused the delete: ${response.status} ${response.statusText}. The stored copy may still be there.`);
325
+ }
326
+ }
327
+ async download(lease) {
328
+ const file = await this.findByKey(lease.objectKey)
329
+ ?? fail("archive_not_found", `Google Drive holds no file for ${lease.objectKey}`, { key: lease.objectKey });
330
+ const response = await this.api(`/drive/v3/files/${file.id}?alt=media`);
331
+ if (!response.ok)
332
+ fail("internal", `Could not download ${lease.objectKey}: ${response.status} ${response.statusText}`, { key: lease.objectKey, status: response.status });
333
+ return Buffer.from(await response.arrayBuffer());
334
+ }
335
+ }
@@ -0,0 +1,45 @@
1
+ import { type BackendName, type ProviderCredentials } from "../secrets.js";
2
+ import { type ProviderConfig, type ProviderKind, type ProviderUploadClient, type UploadLease } from "../control-plane.js";
3
+ import { S3UploadClient } from "./s3.js";
4
+ import { GcsUploadClient } from "./gcs.js";
5
+ import { GdriveUploadClient } from "./gdrive.js";
6
+ export type EndpointOverrides = {
7
+ host?: string;
8
+ port?: number;
9
+ protocol?: "https" | "http";
10
+ pathStyle?: boolean;
11
+ partBytes?: number;
12
+ allowSmallParts?: boolean;
13
+ };
14
+ /**
15
+ * "gdrive" rides alongside control-plane's ProviderKind rather than inside it:
16
+ * that union (and its restatements in types.ts and local-api.ts) is frozen in
17
+ * this change, so the widening lives here at the edge that accepts the new
18
+ * provider. Fold it into ProviderKind when the control-plane learns "gdrive".
19
+ */
20
+ export type UploadProviderKind = ProviderKind | "gdrive";
21
+ export type UploadProviderConfig = Omit<ProviderConfig, "provider"> & {
22
+ provider: UploadProviderKind;
23
+ };
24
+ /**
25
+ * Builds the client for a configured provider. This is the only place a credential
26
+ * is handed to a network client, and the feature flag is checked here rather than
27
+ * at import time so an operator can turn uploads off without a restart.
28
+ */
29
+ export declare function createUploadClient(config: UploadProviderConfig, credentials: ProviderCredentials, overrides?: EndpointOverrides, env?: NodeJS.ProcessEnv): ProviderUploadClient;
30
+ export declare function uploadClientFromStore(dataDir: string, storageConfigId: string, config: UploadProviderConfig, overrides?: EndpointOverrides, backend?: BackendName): Promise<ProviderUploadClient>;
31
+ /**
32
+ * Mints a lease that a configured client may actually use.
33
+ *
34
+ * The object key is derived from the configured prefix and the archive id; a caller
35
+ * cannot choose it, which keeps one account's objects inside its own prefix.
36
+ */
37
+ export declare function createActiveLease(config: UploadProviderConfig, input: {
38
+ archiveId?: string;
39
+ ciphertextSha256: string;
40
+ bytes: number;
41
+ now?: number;
42
+ ttlMs?: number;
43
+ }, env?: NodeJS.ProcessEnv): UploadLease;
44
+ export { S3UploadClient, GcsUploadClient, GdriveUploadClient };
45
+ export type { ProviderKind };
@@ -0,0 +1,74 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { fail } from "../errors.js";
3
+ import { loadProviderCredentials } from "../secrets.js";
4
+ import { DEFAULT_LEASE_TTL_MS, signerEnabled } from "../control-plane.js";
5
+ import { S3UploadClient } from "./s3.js";
6
+ import { GcsUploadClient } from "./gcs.js";
7
+ import { GdriveUploadClient } from "./gdrive.js";
8
+ function isAwsStyle(credentials) {
9
+ return "accessKeyId" in credentials;
10
+ }
11
+ function isGdriveCredentials(credentials) {
12
+ return "kind" in credentials && credentials.kind === "gdrive";
13
+ }
14
+ /**
15
+ * Builds the client for a configured provider. This is the only place a credential
16
+ * is handed to a network client, and the feature flag is checked here rather than
17
+ * at import time so an operator can turn uploads off without a restart.
18
+ */
19
+ export function createUploadClient(config, credentials, overrides = {}, env = process.env) {
20
+ if (!signerEnabled(env))
21
+ fail("signer_not_configured", "Uploads are disabled. Set VAULTLINE_ENABLE_SIGNER=1 to enable a configured provider client.", { provider: config.provider });
22
+ if (config.provider === "gdrive") {
23
+ if (!isGdriveCredentials(credentials))
24
+ fail("invalid_argument", "Google Drive needs an OAuth credential. Run: sealkeep storage connect gdrive");
25
+ // Drive has no buckets: the configured bucket names the archive folder, and
26
+ // a prefix of "appdata" selects the hidden per-app appDataFolder space.
27
+ return new GdriveUploadClient({ folderName: config.bucket, appData: config.prefix.replace(/^\/+|\/+$/g, "") === "appdata", host: overrides.host, port: overrides.port, protocol: overrides.protocol }, credentials);
28
+ }
29
+ if (config.provider === "gcs") {
30
+ if (isAwsStyle(credentials) || isGdriveCredentials(credentials))
31
+ fail("invalid_argument", "GCS needs a service-account credential, not an access key or OAuth token");
32
+ return new GcsUploadClient({ bucket: config.bucket, host: overrides.host, port: overrides.port, protocol: overrides.protocol }, credentials);
33
+ }
34
+ if (!isAwsStyle(credentials))
35
+ fail("invalid_argument", `${config.provider} needs an access key credential, not a service account or OAuth token`);
36
+ const endpoint = { provider: config.provider === "vaultline" ? "s3" : config.provider, region: config.region ?? "auto", bucket: config.bucket, host: overrides.host, port: overrides.port, protocol: overrides.protocol, pathStyle: overrides.pathStyle };
37
+ return new S3UploadClient(config.provider, endpoint, credentials, overrides.partBytes, overrides.allowSmallParts);
38
+ }
39
+ export async function uploadClientFromStore(dataDir, storageConfigId, config, overrides = {}, backend) {
40
+ const credentials = await loadProviderCredentials(dataDir, storageConfigId, backend);
41
+ // A Drive credential minted through the account's server refreshes there
42
+ // too — attach the (runtime-only) refresher with the dataDir it needs.
43
+ if (isGdriveCredentials(credentials) && credentials.exchange === "cloud") {
44
+ const { gdriveRefresh } = await import("../cloud.js");
45
+ credentials.refresher = (refreshToken) => gdriveRefresh(dataDir, refreshToken);
46
+ }
47
+ return createUploadClient(config, credentials, overrides);
48
+ }
49
+ /**
50
+ * Mints a lease that a configured client may actually use.
51
+ *
52
+ * The object key is derived from the configured prefix and the archive id; a caller
53
+ * cannot choose it, which keeps one account's objects inside its own prefix.
54
+ */
55
+ export function createActiveLease(config, input, env = process.env) {
56
+ if (!signerEnabled(env))
57
+ fail("signer_not_configured", "Active leases require an enabled signer", { provider: config.provider });
58
+ if (!config.bucket || !config.prefix)
59
+ fail("invalid_argument", "bucket and prefix are required");
60
+ const archiveId = input.archiveId ?? randomUUID();
61
+ const issuedAt = input.now ?? Date.now();
62
+ return {
63
+ // UploadLease.provider is still control-plane's narrower union; the cast
64
+ // goes away with the UploadProviderKind widening above.
65
+ archiveId, provider: config.provider,
66
+ objectKey: `${config.prefix.replace(/^\/+|\/+$/g, "")}/${archiveId}.vlarchive`,
67
+ expiresAt: new Date(issuedAt + (input.ttlMs ?? DEFAULT_LEASE_TTL_MS)).toISOString(),
68
+ method: config.provider === "gcs" || config.provider === "gdrive" ? "RESUMABLE" : "MULTIPART",
69
+ uploadUrl: `${config.provider}://${config.bucket}/${config.prefix.replace(/^\/+|\/+$/g, "")}/${archiveId}.vlarchive`,
70
+ requiredHeaders: { "content-type": "application/vnd.vaultline.ciphertext", "x-vaultline-ciphertext-sha256": input.ciphertextSha256, "content-length": String(input.bytes) },
71
+ status: "active", uploadable: true
72
+ };
73
+ }
74
+ export { S3UploadClient, GcsUploadClient, GdriveUploadClient };