sealkeep 0.9.0 → 0.11.1

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 (274) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +252 -1
  3. package/CONTROL_PLANE.md +2 -2
  4. package/LICENSE +1 -1
  5. package/README.md +108 -22
  6. package/THIRD_PARTY.md +2 -2
  7. package/THREAT_MODEL.md +23 -4
  8. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
  9. package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
  10. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
  11. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
  12. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
  13. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
  14. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
  15. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +1 -0
  16. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +53 -13
  17. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  18. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  20. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  21. package/dist/site/index.html +1808 -1904
  22. package/dist/site/llms.txt +67 -0
  23. package/dist/site/trust/architecture-data-flow.html +53 -0
  24. package/dist/site/trust/audit-roadmap.html +37 -0
  25. package/dist/site/trust/deployment-responsibility.html +11 -0
  26. package/dist/site/trust/dpa-sample.html +30 -0
  27. package/dist/site/trust/release-provenance.html +21 -0
  28. package/dist/site/trust/subprocessors.html +15 -0
  29. package/dist/site/trust/threat-model.html +34 -0
  30. package/dist/site/trust/toms.html +41 -0
  31. package/dist/site/trust-document.css +32 -0
  32. package/dist/site/trust.html +73 -0
  33. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  34. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  35. package/dist/site/visual/index.html +18 -0
  36. package/dist/site.zip +0 -0
  37. package/dist/src/activity.d.ts +9 -0
  38. package/dist/src/activity.js +90 -1
  39. package/dist/src/adapters.d.ts +175 -5
  40. package/dist/src/adapters.js +961 -73
  41. package/dist/src/agent-context.d.ts +135 -0
  42. package/dist/src/agent-context.js +1059 -0
  43. package/dist/src/archive-copies.d.ts +47 -0
  44. package/dist/src/archive-copies.js +179 -0
  45. package/dist/src/audit.d.ts +1 -1
  46. package/dist/src/audit.js +29 -4
  47. package/dist/src/autopilot.d.ts +52 -7
  48. package/dist/src/autopilot.js +143 -25
  49. package/dist/src/background-bandwidth.d.ts +46 -0
  50. package/dist/src/background-bandwidth.js +301 -0
  51. package/dist/src/background-cpu.d.ts +82 -0
  52. package/dist/src/background-cpu.js +212 -0
  53. package/dist/src/background-worker-error.d.ts +12 -0
  54. package/dist/src/background-worker-error.js +18 -0
  55. package/dist/src/branding.d.ts +24 -1
  56. package/dist/src/branding.js +31 -1
  57. package/dist/src/bridge.d.ts +233 -0
  58. package/dist/src/bridge.js +604 -0
  59. package/dist/src/byte-stream.d.ts +91 -0
  60. package/dist/src/byte-stream.js +385 -0
  61. package/dist/src/chunk-store.d.ts +41 -8
  62. package/dist/src/chunk-store.js +161 -65
  63. package/dist/src/cli.js +1698 -163
  64. package/dist/src/cloud.d.ts +841 -31
  65. package/dist/src/cloud.js +3196 -277
  66. package/dist/src/context-background.d.ts +37 -0
  67. package/dist/src/context-background.js +309 -0
  68. package/dist/src/context-drain-child.d.ts +1 -0
  69. package/dist/src/context-drain-child.js +98 -0
  70. package/dist/src/context-reader.d.ts +118 -0
  71. package/dist/src/context-reader.js +447 -0
  72. package/dist/src/control-plane/auth.d.ts +32 -4
  73. package/dist/src/control-plane/auth.js +85 -24
  74. package/dist/src/control-plane/server.js +19 -6
  75. package/dist/src/control-plane.d.ts +17 -1
  76. package/dist/src/control-plane.js +32 -6
  77. package/dist/src/crypto.d.ts +1 -1
  78. package/dist/src/crypto.js +5 -5
  79. package/dist/src/daemon-lease.d.ts +70 -0
  80. package/dist/src/daemon-lease.js +420 -0
  81. package/dist/src/daemon.d.ts +94 -1
  82. package/dist/src/daemon.js +1082 -105
  83. package/dist/src/darwin-service-policy.d.ts +41 -0
  84. package/dist/src/darwin-service-policy.js +60 -0
  85. package/dist/src/dashboard-cli.js +15 -15
  86. package/dist/src/device-authorization.d.ts +37 -0
  87. package/dist/src/device-authorization.js +199 -0
  88. package/dist/src/device-enrollment.d.ts +91 -0
  89. package/dist/src/device-enrollment.js +349 -0
  90. package/dist/src/disk.d.ts +17 -12
  91. package/dist/src/disk.js +43 -17
  92. package/dist/src/doctor.d.ts +35 -1
  93. package/dist/src/doctor.js +332 -41
  94. package/dist/src/durable-ticket-lock.d.ts +24 -0
  95. package/dist/src/durable-ticket-lock.js +232 -0
  96. package/dist/src/enroll.d.ts +1 -1
  97. package/dist/src/enroll.js +13 -7
  98. package/dist/src/env.d.ts +10 -1
  99. package/dist/src/env.js +11 -3
  100. package/dist/src/errors.d.ts +8 -8
  101. package/dist/src/errors.js +6 -6
  102. package/dist/src/flush.d.ts +12 -0
  103. package/dist/src/flush.js +37 -0
  104. package/dist/src/heartbeat.d.ts +86 -12
  105. package/dist/src/heartbeat.js +415 -29
  106. package/dist/src/index-background-watchdog.d.ts +1 -0
  107. package/dist/src/index-background-watchdog.js +94 -0
  108. package/dist/src/index-background-work.d.ts +21 -0
  109. package/dist/src/index-background-work.js +25 -0
  110. package/dist/src/index-background.d.ts +64 -0
  111. package/dist/src/index-background.js +394 -0
  112. package/dist/src/index-build-child.d.ts +1 -0
  113. package/dist/src/index-build-child.js +109 -0
  114. package/dist/src/index-manifest.d.ts +52 -0
  115. package/dist/src/index-manifest.js +444 -0
  116. package/dist/src/index-publication-proof.d.ts +84 -0
  117. package/dist/src/index-publication-proof.js +380 -0
  118. package/dist/src/index-publication-state.d.ts +149 -0
  119. package/dist/src/index-publication-state.js +696 -0
  120. package/dist/src/index-publication-verifier.d.ts +89 -0
  121. package/dist/src/index-publication-verifier.js +341 -0
  122. package/dist/src/index-publish.d.ts +62 -0
  123. package/dist/src/index-publish.js +540 -0
  124. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  125. package/dist/src/index-scratch-cleanup.js +166 -0
  126. package/dist/src/index-segment-types.d.ts +132 -0
  127. package/dist/src/index-segment-types.js +21 -0
  128. package/dist/src/index-segments.d.ts +9 -0
  129. package/dist/src/index-segments.js +516 -0
  130. package/dist/src/index-store.d.ts +123 -0
  131. package/dist/src/index-store.js +495 -0
  132. package/dist/src/index-sync.d.ts +91 -15
  133. package/dist/src/index-sync.js +286 -53
  134. package/dist/src/index-upgrade-publication.d.ts +30 -0
  135. package/dist/src/index-upgrade-publication.js +179 -0
  136. package/dist/src/integration-manager.d.ts +32 -0
  137. package/dist/src/integration-manager.js +394 -0
  138. package/dist/src/leakscan.js +1 -1
  139. package/dist/src/local-api.d.ts +56 -15
  140. package/dist/src/local-api.js +4979 -541
  141. package/dist/src/machine-settings.d.ts +51 -0
  142. package/dist/src/machine-settings.js +166 -0
  143. package/dist/src/managed-chunks.d.ts +5 -2
  144. package/dist/src/managed-chunks.js +14 -14
  145. package/dist/src/mcp-install.d.ts +11 -9
  146. package/dist/src/mcp-install.js +73 -28
  147. package/dist/src/mcp-workspace.d.ts +18 -0
  148. package/dist/src/mcp-workspace.js +50 -0
  149. package/dist/src/mcp.js +294 -25
  150. package/dist/src/migrate.js +27 -21
  151. package/dist/src/notify.d.ts +1 -1
  152. package/dist/src/notify.js +5 -5
  153. package/dist/src/offload.d.ts +201 -14
  154. package/dist/src/offload.js +1848 -140
  155. package/dist/src/onboarding.d.ts +8 -1
  156. package/dist/src/onboarding.js +4 -4
  157. package/dist/src/packages.d.ts +2 -2
  158. package/dist/src/packages.js +10 -2
  159. package/dist/src/passkey.d.ts +0 -1
  160. package/dist/src/passkey.js +2 -7
  161. package/dist/src/password-lock.d.ts +2 -2
  162. package/dist/src/password-lock.js +6 -6
  163. package/dist/src/paths.d.ts +2 -0
  164. package/dist/src/paths.js +2 -0
  165. package/dist/src/presence.d.ts +86 -0
  166. package/dist/src/presence.js +240 -0
  167. package/dist/src/progress-deadline.d.ts +21 -0
  168. package/dist/src/progress-deadline.js +91 -0
  169. package/dist/src/project-repair.d.ts +55 -0
  170. package/dist/src/project-repair.js +131 -0
  171. package/dist/src/providers/gcs.d.ts +28 -7
  172. package/dist/src/providers/gcs.js +35 -24
  173. package/dist/src/providers/gdrive.d.ts +71 -8
  174. package/dist/src/providers/gdrive.js +223 -50
  175. package/dist/src/providers/index.d.ts +11 -3
  176. package/dist/src/providers/index.js +30 -10
  177. package/dist/src/providers/s3.d.ts +30 -8
  178. package/dist/src/providers/s3.js +41 -30
  179. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  180. package/dist/src/providers/safe-storage-fetch.js +72 -0
  181. package/dist/src/queue.d.ts +136 -19
  182. package/dist/src/queue.js +862 -96
  183. package/dist/src/reclaim-transaction.d.ts +156 -0
  184. package/dist/src/reclaim-transaction.js +1027 -0
  185. package/dist/src/recovery-codes.d.ts +32 -0
  186. package/dist/src/recovery-codes.js +338 -0
  187. package/dist/src/recovery.js +12 -9
  188. package/dist/src/rehydrate.d.ts +25 -22
  189. package/dist/src/rehydrate.js +319 -23
  190. package/dist/src/restore.d.ts +57 -4
  191. package/dist/src/restore.js +272 -36
  192. package/dist/src/resume-stub.d.ts +92 -0
  193. package/dist/src/resume-stub.js +417 -0
  194. package/dist/src/retention.d.ts +98 -7
  195. package/dist/src/retention.js +1066 -62
  196. package/dist/src/rotate.js +3 -3
  197. package/dist/src/search.d.ts +566 -8
  198. package/dist/src/search.js +5940 -297
  199. package/dist/src/secrets.d.ts +51 -7
  200. package/dist/src/secrets.js +316 -24
  201. package/dist/src/service.d.ts +49 -11
  202. package/dist/src/service.js +776 -35
  203. package/dist/src/share.js +3 -3
  204. package/dist/src/shared-spaces.d.ts +98 -0
  205. package/dist/src/shared-spaces.js +214 -0
  206. package/dist/src/source-reader.d.ts +73 -0
  207. package/dist/src/source-reader.js +715 -0
  208. package/dist/src/spool.d.ts +1 -1
  209. package/dist/src/spool.js +1 -1
  210. package/dist/src/start-tui.js +2 -1
  211. package/dist/src/start.js +2 -2
  212. package/dist/src/storage-endpoint.d.ts +21 -0
  213. package/dist/src/storage-endpoint.js +122 -0
  214. package/dist/src/storage-setup.js +12 -12
  215. package/dist/src/storage-targets.d.ts +109 -6
  216. package/dist/src/storage-targets.js +975 -67
  217. package/dist/src/stream-to-cloud.d.ts +5 -1
  218. package/dist/src/stream-to-cloud.js +34 -14
  219. package/dist/src/sync-rules.d.ts +31 -6
  220. package/dist/src/sync-rules.js +153 -14
  221. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  222. package/dist/src/team-backfill-scheduling.js +33 -0
  223. package/dist/src/team-backfill.d.ts +116 -0
  224. package/dist/src/team-backfill.js +1429 -0
  225. package/dist/src/team-index-cache.d.ts +16 -0
  226. package/dist/src/team-index-cache.js +152 -0
  227. package/dist/src/team-offboarding.d.ts +38 -0
  228. package/dist/src/team-offboarding.js +1043 -0
  229. package/dist/src/team-presence.d.ts +127 -0
  230. package/dist/src/team-presence.js +904 -0
  231. package/dist/src/team-publication-policy.d.ts +20 -0
  232. package/dist/src/team-publication-policy.js +140 -0
  233. package/dist/src/team-realtime.d.ts +68 -0
  234. package/dist/src/team-realtime.js +816 -0
  235. package/dist/src/team-source-facts-cache.d.ts +23 -0
  236. package/dist/src/team-source-facts-cache.js +255 -0
  237. package/dist/src/trash.d.ts +1 -1
  238. package/dist/src/trash.js +2 -2
  239. package/dist/src/tui.js +11 -12
  240. package/dist/src/types.d.ts +173 -7
  241. package/dist/src/types.js +20 -0
  242. package/dist/src/ui-server.d.ts +163 -35
  243. package/dist/src/ui-server.js +712 -72
  244. package/dist/src/ui.d.ts +1 -2
  245. package/dist/src/ui.js +1 -2
  246. package/dist/src/upload.d.ts +27 -0
  247. package/dist/src/upload.js +383 -43
  248. package/dist/src/vault.d.ts +226 -30
  249. package/dist/src/vault.js +1776 -192
  250. package/dist/src/watcher.d.ts +7 -1
  251. package/dist/src/watcher.js +198 -55
  252. package/dist/src/worker.d.ts +27 -3
  253. package/dist/src/worker.js +274 -55
  254. package/package.json +33 -12
  255. package/scripts/native-reboot-rehearsal.mjs +90 -0
  256. package/web/app.js +6032 -343
  257. package/web/bootstrap.js +17 -0
  258. package/web/index.html +255 -57
  259. package/web/rail.js +317 -40
  260. package/web/retention.html +2 -2
  261. package/web/rules-view.js +188 -16
  262. package/web/sessions-view.js +485 -62
  263. package/web/sessions.html +2 -2
  264. package/web/setup-api.js +152 -29
  265. package/web/setup-logic.js +68 -9
  266. package/web/setup.html +113 -44
  267. package/web/setup.js +604 -71
  268. package/web/style.css +513 -98
  269. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
@@ -1,9 +1,31 @@
1
1
  import type { GdriveCredentials } from "./providers/gdrive.js";
2
+ import type { StorageDestinationIdentity } from "./types.js";
3
+ declare const nativeSecretOptions: {
4
+ timeout: number;
5
+ killSignal: "SIGKILL";
6
+ };
7
+ export type MacosSecurityInvocation = {
8
+ file: "/usr/sbin/taskpolicy";
9
+ args: string[];
10
+ options: typeof nativeSecretOptions;
11
+ };
12
+ /**
13
+ * launchd releases before v0.10 marked the complete daemon job Background.
14
+ * Even after the daemon clears its own Darwin background bit, launchd applies
15
+ * the old job policy to helpers it spawns; a normally instant Keychain lookup
16
+ * can then consume the whole timeout while the machine is busy. `-a` gives
17
+ * only this Apple-owned helper ordinary application resource policy. The
18
+ * daemon's Nice=5 and LowPriorityIO settings, and every archive worker, remain
19
+ * deferential.
20
+ */
21
+ export declare function macosSecurityInvocation(args: readonly string[]): MacosSecurityInvocation;
22
+ /** `security` returns errSecItemNotFound (-25300) as its low-byte exit status. */
23
+ export declare function isMacosSecurityItemNotFound(error: unknown): boolean;
2
24
  export type SecretRef = {
3
25
  service: string;
4
26
  account: string;
5
27
  };
6
- export type BackendName = "macos-keychain" | "secret-service" | "windows-dpapi" | "file";
28
+ export type BackendName = "macos-keychain" | "secret-service" | "linux-systemd-creds" | "windows-dpapi" | "file";
7
29
  export interface SecretBackend {
8
30
  readonly name: BackendName;
9
31
  /** True when this backend can actually be used on this machine right now. */
@@ -27,11 +49,6 @@ export declare class FileSecretBackend implements SecretBackend {
27
49
  delete(ref: SecretRef): Promise<void>;
28
50
  }
29
51
  export declare function secretBackends(dataDir: string): SecretBackend[];
30
- /**
31
- * Picks the strongest backend this machine supports. The file fallback always
32
- * qualifies. `SEALKEEP_SECRET_BACKEND` pins the choice, which is how a test suite
33
- * or a sandboxed environment stays out of the real OS keystore.
34
- */
35
52
  export declare function chooseBackend(dataDir: string, preferred?: BackendName, env?: NodeJS.ProcessEnv): Promise<SecretBackend>;
36
53
  export type ProviderCredentials = {
37
54
  accessKeyId: string;
@@ -41,6 +58,24 @@ export type ProviderCredentials = {
41
58
  clientEmail: string;
42
59
  privateKey: string;
43
60
  } | GdriveCredentials;
61
+ /**
62
+ * Non-secret evidence attached to a provider credential after the connection
63
+ * probe succeeds. The routing label is intentionally absent: labels can be
64
+ * edited account-wide, while this snapshot identifies the destination whose
65
+ * write/read/delete permissions were actually proved on this machine.
66
+ */
67
+ export type ProviderCredentialBinding = {
68
+ destination: StorageDestinationIdentity;
69
+ destinationFingerprint: string;
70
+ /** Null only for the narrowly-scoped local legacy migration. */
71
+ verifiedAt: string | null;
72
+ };
73
+ export type ProviderCredentialRecord = {
74
+ credentials: ProviderCredentials;
75
+ binding: ProviderCredentialBinding | null;
76
+ /** Raw credentials written by releases which predate destination binding. */
77
+ legacy: boolean;
78
+ };
44
79
  /**
45
80
  * Remembering the recovery phrase.
46
81
  *
@@ -77,10 +112,19 @@ export declare function resolveRecoveryPhrase(dataDir: string, vaultId: string,
77
112
  * Provider credentials are addressed by storage config id, never by bucket name,
78
113
  * so rotating a config cannot silently reuse an old credential.
79
114
  */
80
- export declare function storeProviderCredentials(dataDir: string, storageConfigId: string, credentials: ProviderCredentials, preferred?: BackendName): Promise<{
115
+ export declare function storeProviderCredentials(dataDir: string, storageConfigId: string, credentials: ProviderCredentials, preferred?: BackendName, binding?: ProviderCredentialBinding): Promise<{
81
116
  backend: BackendName;
82
117
  }>;
118
+ export declare function loadProviderCredentialRecord(dataDir: string, storageConfigId: string, preferred?: BackendName): Promise<ProviderCredentialRecord>;
83
119
  export declare function loadProviderCredentials(dataDir: string, storageConfigId: string, preferred?: BackendName): Promise<ProviderCredentials>;
120
+ /**
121
+ * Raw slot presence for disconnect. Parsing is deliberately skipped: a
122
+ * truncated or obsolete secret still belongs to the requested target and must
123
+ * remain removable. Backend failures propagate instead of being reported as
124
+ * an already-disconnected success.
125
+ */
126
+ export declare function providerCredentialSlotPresent(dataDir: string, storageConfigId: string, preferred?: BackendName): Promise<boolean>;
84
127
  export declare function deleteProviderCredentials(dataDir: string, storageConfigId: string, preferred?: BackendName): Promise<{
85
128
  backend: BackendName;
86
129
  }>;
130
+ export {};
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
2
3
  import { chmod, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
4
  import { platform } from "node:os";
4
5
  import { join } from "node:path";
@@ -6,6 +7,36 @@ import { promisify } from "node:util";
6
7
  import { fail } from "./errors.js";
7
8
  import { envVar } from "./env.js";
8
9
  const run = promisify(execFile);
10
+ // Native secret helpers can wait for a locked/login keychain prompt forever.
11
+ // An unattended daemon has nobody to answer that prompt, so every call must
12
+ // fail closed on a short bound and let the durable queue retry later.
13
+ const NATIVE_SECRET_TIMEOUT_MS = 5_000;
14
+ const nativeSecretOptions = { timeout: NATIVE_SECRET_TIMEOUT_MS, killSignal: "SIGKILL" };
15
+ /**
16
+ * launchd releases before v0.10 marked the complete daemon job Background.
17
+ * Even after the daemon clears its own Darwin background bit, launchd applies
18
+ * the old job policy to helpers it spawns; a normally instant Keychain lookup
19
+ * can then consume the whole timeout while the machine is busy. `-a` gives
20
+ * only this Apple-owned helper ordinary application resource policy. The
21
+ * daemon's Nice=5 and LowPriorityIO settings, and every archive worker, remain
22
+ * deferential.
23
+ */
24
+ export function macosSecurityInvocation(args) {
25
+ return {
26
+ file: "/usr/sbin/taskpolicy",
27
+ args: ["-a", "/usr/bin/security", ...args],
28
+ options: { ...nativeSecretOptions },
29
+ };
30
+ }
31
+ const runMacosSecurity = (args) => {
32
+ const invocation = macosSecurityInvocation(args);
33
+ return run(invocation.file, invocation.args, invocation.options);
34
+ };
35
+ /** `security` returns errSecItemNotFound (-25300) as its low-byte exit status. */
36
+ export function isMacosSecurityItemNotFound(error) {
37
+ return typeof error === "object" && error !== null
38
+ && "code" in error && error.code === 44;
39
+ }
9
40
  async function commandExists(command) {
10
41
  try {
11
42
  await run(platform() === "win32" ? "where" : "which", [command]);
@@ -15,6 +46,17 @@ async function commandExists(command) {
15
46
  return false;
16
47
  }
17
48
  }
49
+ async function commandWithInput(command, args, input, timeout = 5_000) {
50
+ return new Promise((resolve, reject) => {
51
+ const child = execFile(command, args, { encoding: "utf8", timeout, maxBuffer: 2 * 1024 * 1024 }, (error, stdout) => {
52
+ if (error)
53
+ reject(error);
54
+ else
55
+ resolve(stdout);
56
+ });
57
+ child.stdin?.end(input);
58
+ });
59
+ }
18
60
  /**
19
61
  * macOS Keychain.
20
62
  *
@@ -25,29 +67,81 @@ async function commandExists(command) {
25
67
  */
26
68
  class MacosKeychain {
27
69
  name = "macos-keychain";
28
- async available() { return platform() === "darwin" && (await commandExists("security")); }
29
- async get(ref) {
70
+ /**
71
+ * Having the `security` binary is not the same as having a keychain.
72
+ *
73
+ * This used to answer yes on any Mac, so a process whose HOME does not hold a
74
+ * login keychain — a test with a throwaway HOME, CI, a container — was told
75
+ * the keychain was usable, tried to write, and macOS put a MODAL DIALOG in
76
+ * front of a human offering to "Reset To Defaults" on their real keychain.
77
+ * A storage backend that cannot store must say so and let the file backend
78
+ * take over; `security list-keychains` is the cheapest honest answer.
79
+ */
80
+ async available() {
81
+ if (platform() !== "darwin")
82
+ return false;
30
83
  try {
31
- return (await run("security", ["find-generic-password", "-s", ref.service, "-a", ref.account, "-w"])).stdout.trim();
84
+ const { stdout } = await runMacosSecurity(["list-keychains", "-d", "user"]);
85
+ return stdout.trim().length > 0;
32
86
  }
33
87
  catch {
34
- return null;
88
+ return false;
89
+ }
90
+ }
91
+ async get(ref) {
92
+ try {
93
+ return (await runMacosSecurity(["find-generic-password", "-s", ref.service, "-a", ref.account, "-w"])).stdout.trim();
94
+ }
95
+ catch (error) {
96
+ if (isMacosSecurityItemNotFound(error))
97
+ return null;
98
+ throw error;
35
99
  }
36
100
  }
37
101
  async set(ref, value) {
38
- await run("security", ["add-generic-password", "-U", "-s", ref.service, "-a", ref.account, "-w", value]);
102
+ await runMacosSecurity(["add-generic-password", "-U", "-s", ref.service, "-a", ref.account, "-w", value]);
39
103
  }
40
104
  async delete(ref) {
41
105
  try {
42
- await run("security", ["delete-generic-password", "-s", ref.service, "-a", ref.account]);
106
+ await runMacosSecurity(["delete-generic-password", "-s", ref.service, "-a", ref.account]);
107
+ }
108
+ catch (error) {
109
+ if (!isMacosSecurityItemNotFound(error))
110
+ throw error;
43
111
  }
44
- catch { /* already absent */ }
45
112
  }
46
113
  }
47
114
  /** Freedesktop Secret Service via `secret-tool`, which reads the secret from stdin. */
48
115
  class SecretService {
49
116
  name = "secret-service";
50
- async available() { return platform() === "linux" && (await commandExists("secret-tool")); }
117
+ async available() {
118
+ if (platform() !== "linux" || !(await commandExists("secret-tool")))
119
+ return false;
120
+ // `secret-tool` is only a client. Minimal servers and containers often
121
+ // install the binary without a D-Bus Secret Service; choosing it there
122
+ // makes the first unattended write fail instead of falling back safely.
123
+ // A read-only search is insufficient: a headless VPS can expose the D-Bus
124
+ // API and a volatile session collection while its persistent login
125
+ // collection is missing/locked. Prove the exact operation Sealkeep needs
126
+ // with a random reversible store/read/clear, each time-bounded so a prompt
127
+ // that nobody can answer cannot hang every hook.
128
+ const account = `probe-${process.pid}-${randomUUID()}`;
129
+ const value = randomUUID();
130
+ try {
131
+ await new Promise((resolve, reject) => {
132
+ const child = execFile("secret-tool", ["store", "--label", "Sealkeep native-keystore availability probe", "service", "sealkeep-availability-probe", "account", account], { timeout: 2_000 }, (error) => error ? reject(error) : resolve());
133
+ child.stdin?.end(value);
134
+ });
135
+ const found = (await run("secret-tool", ["lookup", "service", "sealkeep-availability-probe", "account", account], { timeout: 2_000 })).stdout.trim();
136
+ return found === value;
137
+ }
138
+ catch {
139
+ return false;
140
+ }
141
+ finally {
142
+ await run("secret-tool", ["clear", "service", "sealkeep-availability-probe", "account", account], { timeout: 2_000 }).catch(() => undefined);
143
+ }
144
+ }
51
145
  async get(ref) {
52
146
  try {
53
147
  return (await run("secret-tool", ["lookup", "service", ref.service, "account", ref.account])).stdout.trim() || null;
@@ -69,6 +163,64 @@ class SecretService {
69
163
  catch { /* already absent */ }
70
164
  }
71
165
  }
166
+ /**
167
+ * systemd encrypted credentials for headless Linux.
168
+ *
169
+ * Secret Service is the preferred desktop backend, where PAM unlocks the
170
+ * login collection. A key-only server session often has no unlock password at
171
+ * all. On modern systemd, root (and environments granted access to a host/TPM
172
+ * credential key) can instead seal a user-scoped credential to this OS
173
+ * installation. The plaintext travels over stdin/stdout only; the vault keeps
174
+ * the authenticated Base64 ciphertext. A reversible probe prevents merely
175
+ * having the binary from selecting an unusable backend.
176
+ */
177
+ class LinuxSystemdCredentials {
178
+ root;
179
+ name = "linux-systemd-creds";
180
+ constructor(root) {
181
+ this.root = root;
182
+ }
183
+ nameFor(ref) {
184
+ return `sealkeep-${createHash("sha256").update(`${ref.service}\0${ref.account}`).digest("hex")}`;
185
+ }
186
+ path(ref) { return join(this.root, `${this.nameFor(ref)}.credential`); }
187
+ async encrypt(name, value) {
188
+ return commandWithInput("systemd-creds", ["--user", "--no-ask-password", "--with-key=host", `--name=${name}`, "encrypt", "-", "-"], value);
189
+ }
190
+ async decrypt(name, value) {
191
+ return commandWithInput("systemd-creds", ["--user", "--no-ask-password", `--name=${name}`, "decrypt", "-", "-"], value);
192
+ }
193
+ async available() {
194
+ if (platform() !== "linux" || !(await commandExists("systemd-creds")))
195
+ return false;
196
+ const name = `sealkeep-probe-${process.pid}-${randomUUID()}`;
197
+ const value = randomUUID();
198
+ try {
199
+ return (await this.decrypt(name, await this.encrypt(name, value))).trim() === value;
200
+ }
201
+ catch {
202
+ return false;
203
+ }
204
+ }
205
+ async get(ref) {
206
+ const encrypted = await readFile(this.path(ref), "utf8").catch(() => null);
207
+ if (!encrypted)
208
+ return null;
209
+ try {
210
+ return (await this.decrypt(this.nameFor(ref), encrypted)).trim() || null;
211
+ }
212
+ catch {
213
+ return null;
214
+ }
215
+ }
216
+ async set(ref, value) {
217
+ const encrypted = await this.encrypt(this.nameFor(ref), value);
218
+ await mkdir(this.root, { recursive: true, mode: 0o700 });
219
+ await writeFile(this.path(ref), encrypted.trim() + "\n", { mode: 0o600 });
220
+ await chmod(this.path(ref), 0o600);
221
+ }
222
+ async delete(ref) { await rm(this.path(ref), { force: true }); }
223
+ }
72
224
  /**
73
225
  * Windows DPAPI through PowerShell. The ciphertext is bound to the current user
74
226
  * account, so the stored file is useless to any other user on the machine.
@@ -127,13 +279,111 @@ export class FileSecretBackend {
127
279
  async delete(ref) { await rm(this.path(ref), { force: true }); }
128
280
  }
129
281
  export function secretBackends(dataDir) {
130
- return [new MacosKeychain(), new SecretService(), new WindowsDpapi(join(dataDir, "secrets")), new FileSecretBackend(join(dataDir, "secrets"))];
282
+ const root = join(dataDir, "secrets");
283
+ return [new MacosKeychain(), new SecretService(), new LinuxSystemdCredentials(root), new WindowsDpapi(root), new FileSecretBackend(root)];
131
284
  }
132
285
  /**
133
286
  * Picks the strongest backend this machine supports. The file fallback always
134
287
  * qualifies. `SEALKEEP_SECRET_BACKEND` pins the choice, which is how a test suite
135
288
  * or a sandboxed environment stays out of the real OS keystore.
136
289
  */
290
+ /**
291
+ * The service ids these secrets were stored under before the rename.
292
+ *
293
+ * A keystore entry is not ours to orphan. Renaming the id without moving the
294
+ * entry does not fail loudly — the lookup simply misses, and the product
295
+ * quietly behaves as though the user had never signed in, never stored a
296
+ * provider credential, never chose to remember a phrase. The secret is still
297
+ * sitting in their keychain under a name nothing looks for any more.
298
+ */
299
+ const LEGACY_SERVICE = new Map([
300
+ ["sealkeep-provider", "vaultline-provider"],
301
+ ["sealkeep-recovery", "vaultline-recovery"],
302
+ ["sealkeep-cloud", "vaultline-cloud"]
303
+ ]);
304
+ /**
305
+ * A backend whose reads heal. A miss under the current id retries the old one,
306
+ * and a hit is rewritten under the new id and the old entry removed — so the
307
+ * move happens once, on first use, without a migration step anyone has to run
308
+ * and without a second copy of a secret left behind.
309
+ */
310
+ function migrating(backend) {
311
+ return {
312
+ name: backend.name,
313
+ available: () => backend.available(),
314
+ async get(ref) {
315
+ const current = await backend.get(ref);
316
+ if (current !== null)
317
+ return current;
318
+ const legacy = LEGACY_SERVICE.get(ref.service);
319
+ if (!legacy)
320
+ return null;
321
+ const found = await backend.get({ ...ref, service: legacy });
322
+ if (found === null)
323
+ return null;
324
+ await backend.set(ref, found);
325
+ // Read it back before removing the only other copy. Without this, a
326
+ // write that reported success but did not land — a locked keychain, a
327
+ // full disk on the file backend — took the legacy entry with it and the
328
+ // secret was gone. And if another process rotated or forgot this secret
329
+ // between our read and our write, the value under the new id is theirs,
330
+ // not ours: leave it, and leave the legacy entry alone too rather than
331
+ // resurrecting something a user explicitly forgot.
332
+ const settled = await backend.get(ref);
333
+ if (settled !== found)
334
+ return settled;
335
+ await backend.delete({ ...ref, service: legacy });
336
+ return found;
337
+ },
338
+ set: (ref, value) => backend.set(ref, value),
339
+ // Forgetting must forget both, or a "forgotten" phrase comes back.
340
+ async delete(ref) {
341
+ await backend.delete(ref);
342
+ const legacy = LEGACY_SERVICE.get(ref.service);
343
+ if (legacy)
344
+ await backend.delete({ ...ref, service: legacy });
345
+ }
346
+ };
347
+ }
348
+ /**
349
+ * A stronger backend can become available after an upgrade (for example,
350
+ * systemd-creds arrives on a headless server that previously used the
351
+ * owner-only file fallback). Selecting it must not make the existing secret
352
+ * disappear. On a primary miss, read lower-priority usable backends, copy the
353
+ * value into the primary, verify the copy, and only then remove the old one.
354
+ */
355
+ function migratingBackends(primary, fallbacks) {
356
+ return {
357
+ name: primary.name,
358
+ available: () => primary.available(),
359
+ async get(ref) {
360
+ const current = await primary.get(ref);
361
+ if (current !== null)
362
+ return current;
363
+ for (const fallback of fallbacks) {
364
+ if (!(await fallback.available()))
365
+ continue;
366
+ const found = await fallback.get(ref);
367
+ if (found === null)
368
+ continue;
369
+ await primary.set(ref, found);
370
+ const settled = await primary.get(ref);
371
+ if (settled !== found)
372
+ return settled;
373
+ await fallback.delete(ref);
374
+ return found;
375
+ }
376
+ return null;
377
+ },
378
+ set: (ref, value) => primary.set(ref, value),
379
+ async delete(ref) {
380
+ await primary.delete(ref);
381
+ for (const fallback of fallbacks)
382
+ if (await fallback.available())
383
+ await fallback.delete(ref);
384
+ },
385
+ };
386
+ }
137
387
  export async function chooseBackend(dataDir, preferred, env = process.env) {
138
388
  preferred = preferred ?? envVar("SECRET_BACKEND", env);
139
389
  const backends = secretBackends(dataDir);
@@ -143,15 +393,17 @@ export async function chooseBackend(dataDir, preferred, env = process.env) {
143
393
  fail("invalid_argument", `Unknown secret backend: ${preferred}`);
144
394
  if (!(await chosen.available()))
145
395
  fail("invalid_argument", `Secret backend ${preferred} is not available on this machine`);
146
- return chosen;
396
+ return migrating(chosen);
397
+ }
398
+ for (let index = 0; index < backends.length; index += 1) {
399
+ if (!(await backends[index].available()))
400
+ continue;
401
+ return migratingBackends(migrating(backends[index]), backends.slice(index + 1).map(migrating));
147
402
  }
148
- for (const backend of backends)
149
- if (await backend.available())
150
- return backend;
151
- return new FileSecretBackend(join(dataDir, "secrets"));
403
+ return migrating(new FileSecretBackend(join(dataDir, "secrets")));
152
404
  }
153
- const SERVICE = "vaultline-provider";
154
- const PHRASE_SERVICE = "vaultline-recovery";
405
+ const SERVICE = "sealkeep-provider";
406
+ const PHRASE_SERVICE = "sealkeep-recovery";
155
407
  /**
156
408
  * Remembering the recovery phrase.
157
409
  *
@@ -197,22 +449,62 @@ export async function resolveRecoveryPhrase(dataDir, vaultId, explicit, env = pr
197
449
  * Provider credentials are addressed by storage config id, never by bucket name,
198
450
  * so rotating a config cannot silently reuse an old credential.
199
451
  */
200
- export async function storeProviderCredentials(dataDir, storageConfigId, credentials, preferred) {
452
+ export async function storeProviderCredentials(dataDir, storageConfigId, credentials, preferred, binding) {
201
453
  const backend = await chooseBackend(dataDir, preferred);
202
- await backend.set({ service: SERVICE, account: storageConfigId }, JSON.stringify(credentials));
454
+ const stored = binding
455
+ ? { version: 2, credentials, binding }
456
+ : credentials;
457
+ await backend.set({ service: SERVICE, account: storageConfigId }, JSON.stringify(stored));
203
458
  return { backend: backend.name };
204
459
  }
205
- export async function loadProviderCredentials(dataDir, storageConfigId, preferred) {
206
- const backend = await chooseBackend(dataDir, preferred);
207
- const raw = await backend.get({ service: SERVICE, account: storageConfigId });
208
- if (!raw)
209
- fail("signer_not_configured", `No stored credentials for storage config ${storageConfigId}. Run: sealkeep storage credentials set`, { storageConfigId, backend: backend.name });
460
+ function isObject(value) {
461
+ return typeof value === "object" && value !== null && !Array.isArray(value);
462
+ }
463
+ function providerCredentialRecord(raw, storageConfigId) {
464
+ let parsed;
210
465
  try {
211
- return JSON.parse(raw);
466
+ parsed = JSON.parse(raw);
212
467
  }
213
468
  catch {
214
469
  return fail("invalid_argument", `Stored credentials for ${storageConfigId} are not readable JSON`, { storageConfigId });
215
470
  }
471
+ if (isObject(parsed) && parsed.version === 2) {
472
+ if (!isObject(parsed.credentials) || !isObject(parsed.binding)
473
+ || !isObject(parsed.binding.destination)
474
+ || typeof parsed.binding.destinationFingerprint !== "string"
475
+ || (parsed.binding.verifiedAt !== null && typeof parsed.binding.verifiedAt !== "string")) {
476
+ return fail("invalid_argument", `Stored credentials for ${storageConfigId} have an invalid destination binding`, { storageConfigId });
477
+ }
478
+ return {
479
+ credentials: parsed.credentials,
480
+ binding: parsed.binding,
481
+ legacy: false,
482
+ };
483
+ }
484
+ if (!isObject(parsed)) {
485
+ return fail("invalid_argument", `Stored credentials for ${storageConfigId} have an invalid shape`, { storageConfigId });
486
+ }
487
+ return { credentials: parsed, binding: null, legacy: true };
488
+ }
489
+ export async function loadProviderCredentialRecord(dataDir, storageConfigId, preferred) {
490
+ const backend = await chooseBackend(dataDir, preferred);
491
+ const raw = await backend.get({ service: SERVICE, account: storageConfigId });
492
+ if (!raw)
493
+ fail("signer_not_configured", `No stored credentials for storage config ${storageConfigId}. Run: sealkeep storage credentials set`, { storageConfigId, backend: backend.name });
494
+ return providerCredentialRecord(raw, storageConfigId);
495
+ }
496
+ export async function loadProviderCredentials(dataDir, storageConfigId, preferred) {
497
+ return (await loadProviderCredentialRecord(dataDir, storageConfigId, preferred)).credentials;
498
+ }
499
+ /**
500
+ * Raw slot presence for disconnect. Parsing is deliberately skipped: a
501
+ * truncated or obsolete secret still belongs to the requested target and must
502
+ * remain removable. Backend failures propagate instead of being reported as
503
+ * an already-disconnected success.
504
+ */
505
+ export async function providerCredentialSlotPresent(dataDir, storageConfigId, preferred) {
506
+ const backend = await chooseBackend(dataDir, preferred);
507
+ return (await backend.get({ service: SERVICE, account: storageConfigId })) !== null;
216
508
  }
217
509
  export async function deleteProviderCredentials(dataDir, storageConfigId, preferred) {
218
510
  const backend = await chooseBackend(dataDir, preferred);
@@ -10,16 +10,20 @@ export type ServiceOptions = {
10
10
  * Environment the service runs with.
11
11
  *
12
12
  * A service manager starts the daemon with a bare environment — it does not
13
- * inherit the shell that installed it. The upload pass is gated on
14
- * `SEALKEEP_ENABLE_SIGNER`, so a service definition that carries no
15
- * environment produces a daemon that seals sessions, uploads nothing, and
16
- * therefore reclaims nothing, logging one line to `daemon.log` that nobody
17
- * reads. Whatever the installer decides uploads need, it has to be written
18
- * into the unit.
13
+ * inherit the shell that installed it. Upload-capable service definitions
14
+ * carry `SEALKEEP_ENABLE_SIGNER`; configured targets and machine-local
15
+ * credentials remain the separate gates which decide whether any write can
16
+ * actually happen.
19
17
  */
20
18
  environment?: Record<string, string>;
19
+ /** Grace distinguishes a package upgrade's brief gap from a real uninstall. */
20
+ cleanupGraceMs?: number;
21
+ /** Shortened only by isolated lifecycle rehearsals. */
22
+ cleanupMonitorMs?: number;
21
23
  /** Injected in tests so no real service manager is touched. */
22
24
  exec?: (file: string, args: string[]) => Promise<unknown>;
25
+ /** Current numeric user. Injected only by hermetic platform rehearsals. */
26
+ uid?: number;
23
27
  };
24
28
  export type ServicePlan = {
25
29
  kind: ServiceKind;
@@ -28,8 +32,22 @@ export type ServicePlan = {
28
32
  contents: string;
29
33
  enable: [string, string[]][];
30
34
  disable: [string, string[]][];
35
+ managerPath: string;
36
+ receiptPath: string;
31
37
  };
32
- export declare const SERVICE_LABEL = "ai.vaultline.agent";
38
+ export declare const SERVICE_LABEL = "ai.sealkeep.agent";
39
+ /**
40
+ * What this service was called before the rename, and why that is not just
41
+ * trivia.
42
+ *
43
+ * A launchd agent is not owned by the file that wrote it — it is owned by the
44
+ * OS, under a label, until something tells the OS to let go. Rename the label
45
+ * and install, and the old agent does not disappear: it keeps loading at every
46
+ * login, running an executable that may no longer exist, next to the new one.
47
+ * So every install and every uninstall tears the old label down first.
48
+ */
49
+ export declare const LEGACY_SERVICE_LABEL = "ai.vaultline.agent";
50
+ export declare const LEGACY_SYSTEMD_UNIT = "vaultline.service";
33
51
  /**
34
52
  * Describes the service this machine would install, without touching anything.
35
53
  *
@@ -37,22 +55,36 @@ export declare const SERVICE_LABEL = "ai.vaultline.agent";
37
55
  * exact file and commands, and tests can assert them without a service manager.
38
56
  */
39
57
  export declare function servicePlan(options: ServiceOptions): ServicePlan;
58
+ export type ServicePersistence = "boot" | "login";
40
59
  export type ServiceOutcome = {
41
60
  kind: ServiceKind;
42
61
  installed: boolean;
43
62
  path: string;
44
63
  ranCommands: string[];
64
+ managerPath?: string;
65
+ receiptPath?: string;
66
+ /** `boot` is verified systemd lingering; other native services start at login. */
67
+ persistence?: ServicePersistence;
45
68
  note?: string;
46
69
  };
70
+ /**
71
+ * Upgrade path for units installed before the package-independent manager
72
+ * existed. No new service is installed on a machine that did not already
73
+ * consent to one; an existing stale or inactive registration is repaired and
74
+ * activated immediately instead of waiting for the next login/reboot.
75
+ */
76
+ export declare function reconcileInstalledService(options: ServiceOptions): Promise<boolean>;
47
77
  /** Writes the unit file and asks the platform's service manager to start it at login. */
48
78
  export declare function installService(options: ServiceOptions): Promise<ServiceOutcome>;
49
79
  export declare function uninstallService(options: ServiceOptions): Promise<ServiceOutcome>;
50
- /** Reports whether the unit file this build would write is currently present. */
80
+ /** Reports durable installation separately from whether the manager has it live now. */
51
81
  export declare function serviceStatus(options: ServiceOptions): Promise<{
52
82
  kind: ServiceKind;
83
+ installed: boolean;
53
84
  present: boolean;
54
85
  path: string;
55
86
  runs?: string;
87
+ persistence?: ServicePersistence;
56
88
  }>;
57
89
  /**
58
90
  * The environment a service unit needs beyond its flags. A service manager
@@ -60,14 +92,20 @@ export declare function serviceStatus(options: ServiceOptions): Promise<{
60
92
  * where secrets live has to ride along in the unit — a vault set up with
61
93
  * SEALKEEP_SECRET_BACKEND=file kept its phrase in a keystore the daemon then
62
94
  * never looked in, and every tick died on "No recovery phrase available".
95
+ * Upload capability is included by default because storage may be connected
96
+ * after installation; target configuration and credentials remain mandatory.
97
+ * Callers representing an explicit `--no-upload` omit it with `uploads:false`.
63
98
  */
64
- export declare function serviceUnitEnvironment(base?: Record<string, string>, env?: NodeJS.ProcessEnv): Record<string, string>;
65
- /** The argv the service should run: this package's CLI, with the daemon subcommand. */
99
+ export declare function serviceUnitEnvironment(base?: Record<string, string>, env?: NodeJS.ProcessEnv, options?: {
100
+ uploads?: boolean;
101
+ }): Record<string, string>;
102
+ /** The argv the service should run: this package's daemon plus its loopback UI lane. */
66
103
  export declare function daemonInvocation(dataDir: string, options?: {
67
104
  reclaim?: boolean;
105
+ upload?: boolean;
68
106
  cliPath?: string;
107
+ intervalSeconds?: number;
69
108
  }): {
70
109
  executable: string;
71
110
  args: string[];
72
111
  };
73
- export declare function serviceKind(target?: NodeJS.Platform): ServiceKind;