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
@@ -0,0 +1,349 @@
1
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes } from "node:crypto";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { decryptArchive, encryptArchive, x25519PublicKeyFromRaw, } from "../packages/sealkeep-crypto/src/index.js";
5
+ import { approveCloudDeviceEnrollment, bootstrapMachineRefFor, bootstrapTokenRefFor, claimCloudDeviceEnrollment, cloudUrl, completeCloudDeviceEnrollment, createCloudDeviceEnrollment, listPendingCloudDeviceEnrollments, machineRefFor, tokenRefFor, } from "./cloud.js";
6
+ import { equalHex, phraseCheck } from "./crypto.js";
7
+ import { fail, isSealkeepError } from "./errors.js";
8
+ import { canonicalPhrase } from "./mnemonic.js";
9
+ import { chooseBackend, forgetRecoveryPhrase, rememberRecoveryPhrase, resolveRecoveryPhrase, } from "./secrets.js";
10
+ import { initializeTransferredVault, readConfig, writeConfig } from "./vault.js";
11
+ /**
12
+ * Fresh-device enrollment without key custody.
13
+ *
14
+ * Cloud authentication links the account, but it must not become a decryption
15
+ * key. A fresh machine therefore creates an ephemeral X25519 keypair locally.
16
+ * An already-unlocked machine seals the vault identity directly to its public
17
+ * half, and SealKeep Cloud relays only that ciphertext. The target private key
18
+ * is stored in this machine's native secret backend and never crosses HTTP.
19
+ */
20
+ const STATE_SERVICE = "sealkeep-device-enrollment";
21
+ const PAYLOAD_KIND = "sealkeep-personal-vault-enrollment";
22
+ const PAYLOAD_VERSION = 1;
23
+ const enrollmentStateRef = (requestId) => ({
24
+ service: STATE_SERVICE,
25
+ account: `${requestId}@${cloudUrl()}`,
26
+ });
27
+ function parseRawPublicKey(publicKey) {
28
+ const raw = Buffer.from(publicKey, "base64");
29
+ if (raw.length !== 32 || raw.toString("base64") !== publicKey) {
30
+ fail("invalid_argument", "A device enrollment public key must be a canonical 32-byte X25519 key");
31
+ }
32
+ return raw;
33
+ }
34
+ /** A short comparison code derived from the key itself, never supplied by Cloud. */
35
+ export function deviceEnrollmentVerificationCode(publicKey) {
36
+ const digest = createHash("sha256").update(parseRawPublicKey(publicKey)).digest();
37
+ return String(digest.readUInt32BE(0) % 1_000_000).padStart(6, "0");
38
+ }
39
+ export function generateDeviceEnrollmentKeys() {
40
+ const pair = generateKeyPairSync("x25519");
41
+ return {
42
+ publicKey: pair.publicKey.export({ type: "spki", format: "der" }).subarray(-32).toString("base64"),
43
+ privateKey: pair.privateKey.export({ type: "pkcs8", format: "der" }).toString("base64"),
44
+ };
45
+ }
46
+ function assertEnrollmentKeyPair(keys) {
47
+ parseRawPublicKey(keys.publicKey);
48
+ try {
49
+ const privateKey = createPrivateKey({ key: Buffer.from(keys.privateKey, "base64"), type: "pkcs8", format: "der" });
50
+ const derived = createPublicKey(privateKey).export({ type: "spki", format: "der" }).subarray(-32);
51
+ if (!derived.equals(Buffer.from(keys.publicKey, "base64")))
52
+ throw new Error("public key does not match");
53
+ }
54
+ catch {
55
+ fail("invalid_argument", "The local device enrollment key pair is not valid");
56
+ }
57
+ }
58
+ async function rememberDraft(dataDir, draft, preferred) {
59
+ const backend = await chooseBackend(dataDir, preferred);
60
+ await backend.set(enrollmentStateRef(draft.requestId), JSON.stringify(draft));
61
+ }
62
+ export async function readDeviceEnrollmentDraft(dataDir, requestId, preferred) {
63
+ const backend = await chooseBackend(dataDir, preferred);
64
+ const raw = await backend.get(enrollmentStateRef(requestId));
65
+ if (!raw)
66
+ return null;
67
+ try {
68
+ const draft = JSON.parse(raw);
69
+ if (draft.version !== 1 || draft.requestId !== requestId || !draft.privateKey || !draft.requestSecret)
70
+ return null;
71
+ if (deviceEnrollmentVerificationCode(draft.publicKey) !== draft.verificationCode)
72
+ return null;
73
+ return draft;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ /**
80
+ * Starts the target side after ordinary Cloud sign-in. The request secret is a
81
+ * capability for claiming this one response; it is not a vault key and cannot
82
+ * decrypt any archive.
83
+ */
84
+ export async function beginDeviceEnrollment(dataDir, label, preferred) {
85
+ return beginDeviceEnrollmentWithKeys(dataDir, label, generateDeviceEnrollmentKeys(), preferred);
86
+ }
87
+ /**
88
+ * Starts the existing-vault approval lane with a key pair prepared before
89
+ * hosted account authorization. Reusing that exact public key means the
90
+ * account page and the existing device approve the same target, instead of
91
+ * silently changing identity between the two steps.
92
+ */
93
+ export async function beginDeviceEnrollmentWithKeys(dataDir, label, keys, preferred) {
94
+ const cleanLabel = label.trim();
95
+ if (!cleanLabel)
96
+ fail("invalid_argument", "Name this machine before requesting your preserved memory");
97
+ assertEnrollmentKeyPair(keys);
98
+ const requestSecret = randomBytes(32).toString("base64url");
99
+ const created = await createCloudDeviceEnrollment(dataDir, {
100
+ label: cleanLabel,
101
+ publicKey: keys.publicKey,
102
+ requestSecret,
103
+ }, preferred);
104
+ const draft = {
105
+ version: 1,
106
+ requestId: created.id,
107
+ label: cleanLabel,
108
+ ...keys,
109
+ requestSecret,
110
+ verificationCode: deviceEnrollmentVerificationCode(keys.publicKey),
111
+ createdAt: created.created_at,
112
+ expiresAt: created.expires_at,
113
+ };
114
+ try {
115
+ await rememberDraft(dataDir, draft, preferred);
116
+ }
117
+ catch (error) {
118
+ fail("internal", `Cloud created the enrollment request, but this machine could not protect its private key locally: ${error instanceof Error ? error.message : "secret backend refused it"}. Do not approve this request; create a new one after the local keystore is available.`);
119
+ }
120
+ const { privateKey: _privateKey, requestSecret: _requestSecret, ...publicDraft } = draft;
121
+ return publicDraft;
122
+ }
123
+ /**
124
+ * Persists a request the hosted approval endpoint already created from the
125
+ * public key supplied at authorization start. The returned request secret is
126
+ * consumed only here, over the verifier-protected claim response, and then
127
+ * remains in the native secret backend with the private key.
128
+ */
129
+ export async function rememberHostedDeviceEnrollment(dataDir, input, preferred) {
130
+ if (!Number.isSafeInteger(input.requestId) || input.requestId < 1 || input.requestSecret.length < 32) {
131
+ fail("internal", "Sealkeep Cloud returned an invalid preserved-memory enrollment request");
132
+ }
133
+ const cleanLabel = input.label.trim();
134
+ if (!cleanLabel)
135
+ fail("internal", "Sealkeep Cloud returned an unnamed preserved-memory enrollment request");
136
+ assertEnrollmentKeyPair(input.keys);
137
+ if (!Number.isFinite(Date.parse(input.createdAt)) || !Number.isFinite(Date.parse(input.expiresAt))) {
138
+ fail("internal", "Sealkeep Cloud returned invalid preserved-memory enrollment timing");
139
+ }
140
+ const draft = {
141
+ version: 1,
142
+ requestId: input.requestId,
143
+ label: cleanLabel,
144
+ ...input.keys,
145
+ requestSecret: input.requestSecret,
146
+ verificationCode: deviceEnrollmentVerificationCode(input.keys.publicKey),
147
+ createdAt: input.createdAt,
148
+ expiresAt: input.expiresAt,
149
+ };
150
+ await rememberDraft(dataDir, draft, preferred);
151
+ const { privateKey: _privateKey, requestSecret: _requestSecret, ...publicDraft } = draft;
152
+ return publicDraft;
153
+ }
154
+ function enrollmentPayload(config, phrase) {
155
+ const canonical = canonicalPhrase(phrase);
156
+ if (!equalHex(config.recovery.phraseCheck, phraseCheck(canonical))) {
157
+ fail("recovery_phrase_mismatch", "This machine's stored recovery phrase does not open this vault, so enrollment was not approved");
158
+ }
159
+ return {
160
+ kind: PAYLOAD_KIND,
161
+ version: PAYLOAD_VERSION,
162
+ vaultId: config.vaultId,
163
+ recoveryPhrase: canonical,
164
+ storageMode: config.storageMode === "plain" ? "plain" : "sealed",
165
+ ...(config.recipients?.length ? { recipients: config.recipients } : {}),
166
+ ...(config.remoteNaming ? { remoteNaming: config.remoteNaming } : {}),
167
+ approvedAt: new Date().toISOString(),
168
+ };
169
+ }
170
+ export function sealDeviceEnrollmentPayload(config, phrase, targetPublicKey) {
171
+ const raw = parseRawPublicKey(targetPublicKey);
172
+ const sealed = encryptArchive(Buffer.from(JSON.stringify(enrollmentPayload(config, phrase))), {
173
+ recipients: [{ kind: "x25519", publicKey: x25519PublicKeyFromRaw(raw) }],
174
+ adapter: { agent: "sealkeep-device-enrollment", version: String(PAYLOAD_VERSION) },
175
+ });
176
+ return { envelope: sealed.envelope, sealed: sealed.ciphertext.toString("base64") };
177
+ }
178
+ function openDeviceEnrollmentPayload(envelope, sealed, privateKeyBase64) {
179
+ let payload;
180
+ try {
181
+ const privateKey = createPrivateKey({ key: Buffer.from(privateKeyBase64, "base64"), type: "pkcs8", format: "der" });
182
+ payload = JSON.parse(decryptArchive(envelope, Buffer.from(sealed, "base64"), { privateKey }).toString("utf8"));
183
+ }
184
+ catch {
185
+ return fail("forbidden", "The approved enrollment package was not sealed to this machine. Nothing local was changed.");
186
+ }
187
+ if (!payload || typeof payload !== "object")
188
+ return fail("internal", "The approved enrollment package is malformed");
189
+ const value = payload;
190
+ if (value.kind !== PAYLOAD_KIND || value.version !== PAYLOAD_VERSION || typeof value.vaultId !== "string" || typeof value.recoveryPhrase !== "string") {
191
+ return fail("internal", "The approved enrollment package is not a supported SealKeep vault identity");
192
+ }
193
+ const phrase = canonicalPhrase(value.recoveryPhrase);
194
+ if (!phrase.trim())
195
+ return fail("internal", "The approved enrollment package contains no recovery material");
196
+ return { ...value, recoveryPhrase: phrase };
197
+ }
198
+ /**
199
+ * Existing-device side. Requiring the independently displayed code prevents a
200
+ * Cloud-side public-key substitution from being approved silently.
201
+ */
202
+ export async function approveDeviceEnrollment(dataDir, requestId, displayedCode, preferred) {
203
+ const request = (await listPendingCloudDeviceEnrollments(dataDir, preferred)).find((item) => item.id === requestId);
204
+ if (!request)
205
+ fail("archive_not_found", "That device enrollment request is no longer pending");
206
+ const expected = deviceEnrollmentVerificationCode(request.public_key);
207
+ if (displayedCode.trim() !== expected) {
208
+ fail("forbidden", "The verification code does not match the new machine. Do not approve it; cancel and start enrollment again.");
209
+ }
210
+ const config = await readConfig(dataDir);
211
+ const phrase = await resolveRecoveryPhrase(dataDir, config.vaultId, undefined, process.env, preferred);
212
+ if (!phrase) {
213
+ fail("recovery_phrase_missing", "This machine must be locally unlocked before it can approve another machine. Open SealKeep Settings and unlock this vault; do not send the phrase to Cloud or to the new machine.");
214
+ }
215
+ const bundle = sealDeviceEnrollmentPayload(config, phrase, request.public_key);
216
+ await approveCloudDeviceEnrollment(dataDir, requestId, bundle, preferred);
217
+ return { approved: true, requestId, label: request.label };
218
+ }
219
+ function restoredConfig(dataDir, payload) {
220
+ return {
221
+ version: 1,
222
+ vaultId: payload.vaultId,
223
+ storage: { provider: "local", root: join(dataDir, "archives") },
224
+ ...(payload.storageMode === "plain" ? { storageMode: "plain" } : {}),
225
+ recovery: { phraseCheck: phraseCheck(payload.recoveryPhrase) },
226
+ ...(payload.recipients?.length ? { recipients: payload.recipients } : {}),
227
+ ...(payload.remoteNaming ? { remoteNaming: payload.remoteNaming } : {}),
228
+ };
229
+ }
230
+ async function physicalArchiveCount(dataDir) {
231
+ return (await readdir(join(dataDir, "archives")).catch((error) => {
232
+ if (error.code === "ENOENT")
233
+ return [];
234
+ throw error;
235
+ })).filter((name) => name.endsWith(".json")).length;
236
+ }
237
+ /**
238
+ * Read-only adoption gate. Recovery codes call this before atomically spending
239
+ * a code, so an occupied data directory or a lost Cloud login cannot burn a
240
+ * valid code and then fail locally.
241
+ */
242
+ export async function assertPersonalVaultAdoptable(dataDir, payload, preferred) {
243
+ const current = await readConfig(dataDir).catch((error) => {
244
+ if (isSealkeepError(error) && error.code === "vault_not_initialized")
245
+ return null;
246
+ throw error;
247
+ });
248
+ if (current?.vaultId === payload.vaultId)
249
+ return;
250
+ await assertFreshPersonalVaultTarget(dataDir, preferred);
251
+ }
252
+ /**
253
+ * Checks everything a fresh recovery target can check before a one-time code
254
+ * is redeemed. This deliberately does not need the encrypted vault payload.
255
+ */
256
+ export async function assertFreshPersonalVaultTarget(dataDir, preferred) {
257
+ const current = await readConfig(dataDir).catch((error) => {
258
+ if (isSealkeepError(error) && error.code === "vault_not_initialized")
259
+ return null;
260
+ throw error;
261
+ });
262
+ // Count physical records, including malformed ones. A damaged archive must
263
+ // block replacement just as strongly as a readable archive; skipping it
264
+ // here could burn a one-time recovery code and overwrite evidence.
265
+ const existingArchives = await physicalArchiveCount(dataDir);
266
+ if (existingArchives > 0) {
267
+ fail("forbidden", "This machine already has local archives. SealKeep will not replace that vault during device enrollment; use a separate data directory or finish syncing the existing vault first.");
268
+ }
269
+ const backend = await chooseBackend(dataDir, preferred);
270
+ const oldTokenRef = current ? tokenRefFor(current.vaultId) : bootstrapTokenRefFor(dataDir);
271
+ if (!await backend.get(oldTokenRef)) {
272
+ fail("unauthorized", "This machine is no longer signed in to SealKeep Cloud, so its approved vault cannot be adopted");
273
+ }
274
+ return current;
275
+ }
276
+ export async function adoptPersonalVaultMaterial(dataDir, payload, preferred) {
277
+ await assertPersonalVaultAdoptable(dataDir, payload, preferred);
278
+ const current = await readConfig(dataDir).catch((error) => {
279
+ if (isSealkeepError(error) && error.code === "vault_not_initialized")
280
+ return null;
281
+ throw error;
282
+ });
283
+ if (current?.vaultId === payload.vaultId) {
284
+ await rememberRecoveryPhrase(dataDir, current.vaultId, payload.recoveryPhrase, preferred);
285
+ return;
286
+ }
287
+ const existingArchives = await physicalArchiveCount(dataDir);
288
+ if (existingArchives > 0) {
289
+ fail("forbidden", "This machine already has local archives. SealKeep will not replace that vault during device enrollment; use a separate data directory or finish syncing the existing vault first.");
290
+ }
291
+ // Stage every new-vault secret before switching config identity. A crash can
292
+ // leave harmless duplicate keystore entries, but never a config whose Cloud
293
+ // token disappeared because it is still filed under a temporary or bootstrap
294
+ // identity.
295
+ const backend = await chooseBackend(dataDir, preferred);
296
+ const oldTokenRef = current ? tokenRefFor(current.vaultId) : bootstrapTokenRefFor(dataDir);
297
+ const oldMachineRef = current ? machineRefFor(current.vaultId) : bootstrapMachineRefFor(dataDir);
298
+ const nextTokenRef = tokenRefFor(payload.vaultId);
299
+ const nextMachineRef = machineRefFor(payload.vaultId);
300
+ const [token, machine] = await Promise.all([backend.get(oldTokenRef), backend.get(oldMachineRef)]);
301
+ if (!token)
302
+ fail("unauthorized", "This machine is no longer signed in to SealKeep Cloud, so its approved vault cannot be adopted");
303
+ try {
304
+ await backend.set(nextTokenRef, token);
305
+ if (machine)
306
+ await backend.set(nextMachineRef, machine);
307
+ await rememberRecoveryPhrase(dataDir, payload.vaultId, payload.recoveryPhrase, preferred);
308
+ const restored = restoredConfig(dataDir, payload);
309
+ if (current)
310
+ await writeConfig(dataDir, restored);
311
+ else
312
+ await initializeTransferredVault(dataDir, restored);
313
+ }
314
+ catch (error) {
315
+ await Promise.all([
316
+ backend.delete(nextTokenRef).catch(() => undefined),
317
+ backend.delete(nextMachineRef).catch(() => undefined),
318
+ forgetRecoveryPhrase(dataDir, payload.vaultId, preferred).catch(() => undefined),
319
+ ]);
320
+ throw error;
321
+ }
322
+ await Promise.all([
323
+ backend.delete(oldTokenRef).catch(() => undefined),
324
+ backend.delete(oldMachineRef).catch(() => undefined),
325
+ ...(current ? [forgetRecoveryPhrase(dataDir, current.vaultId, preferred).catch(() => undefined)] : []),
326
+ ]);
327
+ }
328
+ /** Target side: claim ciphertext, decrypt locally, adopt, then acknowledge. */
329
+ export async function completeDeviceEnrollment(dataDir, requestId, preferred) {
330
+ const draft = await readDeviceEnrollmentDraft(dataDir, requestId, preferred);
331
+ if (!draft)
332
+ fail("archive_not_found", "This machine no longer holds the private enrollment request. Start a new device enrollment request.");
333
+ const claimed = await claimCloudDeviceEnrollment(dataDir, requestId, draft.requestSecret, preferred);
334
+ const envelope = typeof claimed.envelope === "string" ? JSON.parse(claimed.envelope) : claimed.envelope;
335
+ const payload = openDeviceEnrollmentPayload(envelope, claimed.sealed, draft.privateKey);
336
+ await adoptPersonalVaultMaterial(dataDir, payload, preferred);
337
+ let cloudReceiptCleared = true;
338
+ try {
339
+ await completeCloudDeviceEnrollment(dataDir, requestId, draft.requestSecret, preferred);
340
+ }
341
+ catch {
342
+ cloudReceiptCleared = false;
343
+ }
344
+ if (cloudReceiptCleared) {
345
+ const backend = await chooseBackend(dataDir, preferred);
346
+ await backend.delete(enrollmentStateRef(requestId)).catch(() => undefined);
347
+ }
348
+ return { requestId, vaultId: payload.vaultId, verificationCode: draft.verificationCode, cloudReceiptCleared };
349
+ }
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * How much room archiving needs, and whether there is any.
3
3
  *
4
- * Archiving costs disk before it saves any. A transcript is gzipped to a
5
- * scratch file and then sealed into a second one, while the original is still
6
- * sitting there — so free space dips by roughly twice the compressed size
7
- * before the source is eligible to be reclaimed, and it is only ever reclaimed
8
- * later, by a separate decision.
4
+ * Archiving costs disk before it saves any. The current chunked writer streams
5
+ * one compressed-and-sealed object to a partial file while the original still
6
+ * exists. That source is input already paid for; the new object's worst-case
7
+ * size, plus the standing reserve, is the additional free space admission must
8
+ * protect. The source is only ever reclaimed later, by a separate decision.
9
9
  *
10
10
  * That matters because of who this is for. The people who install this have
11
11
  * nearly full disks; that is the problem it exists to solve. A background
@@ -40,18 +40,23 @@ export declare function resolveReserveBytes(dataDir: string): Promise<number>;
40
40
  * `vault.ts` assumed agent transcripts compress about 2x. Measured on a real
41
41
  * 1.6 GB Codex rollout it is 1.5x — the sealed copy is 68.8% of the source —
42
42
  * because these files carry a lot of base64 and tool output that has already
43
- * been compressed once. Planning space against 2x would under-reserve on every
44
- * real file, so the number here is the one that was observed, rounded against
45
- * our own favour.
43
+ * been compressed once. That remains useful when describing expected storage,
44
+ * but it cannot decide whether arbitrary not-yet-compressed input is safe to
45
+ * start.
46
46
  */
47
47
  export declare const OBSERVED_COMPRESSION = 0.7;
48
+ /** The normal worker seals one independently searchable zstd frame at a time. */
49
+ export declare const LOCAL_SEAL_CHUNK_BYTES: number;
50
+ /** Maximum bytes the current local chunk-seal can add to the archive volume. */
51
+ export declare function maximumArchiveOutputBytes(sourceBytes: number): number;
48
52
  /**
49
53
  * What one archive of this size needs before it is safe to start.
50
54
  *
51
- * The gzip scratch and the sealed object both exist at once, so it is two
52
- * compressed copies, plus the reserve. Incompressible input is the worst case
53
- * and it is not rare enough to ignore, so the ratio is capped at 1: a file that
54
- * does not shrink is planned for as if it will not.
55
+ * The source already exists, and the chunked seal writes one new partial object
56
+ * beside it. Incompressible input is the worst case: the output can be larger
57
+ * than the source by the codec's documented bound plus one authentication tag
58
+ * per chunk. The measured 70% ratio above describes typical storage, never a
59
+ * safe admission threshold.
55
60
  */
56
61
  export declare function spaceNeededFor(sourceBytes: number, reserveBytes?: number): number;
57
62
  /** Free bytes on the volume holding `path`, or undefined where that cannot be asked. */
package/dist/src/disk.js CHANGED
@@ -2,11 +2,11 @@ import { statfs } from "node:fs/promises";
2
2
  /**
3
3
  * How much room archiving needs, and whether there is any.
4
4
  *
5
- * Archiving costs disk before it saves any. A transcript is gzipped to a
6
- * scratch file and then sealed into a second one, while the original is still
7
- * sitting there — so free space dips by roughly twice the compressed size
8
- * before the source is eligible to be reclaimed, and it is only ever reclaimed
9
- * later, by a separate decision.
5
+ * Archiving costs disk before it saves any. The current chunked writer streams
6
+ * one compressed-and-sealed object to a partial file while the original still
7
+ * exists. That source is input already paid for; the new object's worst-case
8
+ * size, plus the standing reserve, is the additional free space admission must
9
+ * protect. The source is only ever reclaimed later, by a separate decision.
10
10
  *
11
11
  * That matters because of who this is for. The people who install this have
12
12
  * nearly full disks; that is the problem it exists to solve. A background
@@ -62,25 +62,51 @@ export async function resolveReserveBytes(dataDir) {
62
62
  * `vault.ts` assumed agent transcripts compress about 2x. Measured on a real
63
63
  * 1.6 GB Codex rollout it is 1.5x — the sealed copy is 68.8% of the source —
64
64
  * because these files carry a lot of base64 and tool output that has already
65
- * been compressed once. Planning space against 2x would under-reserve on every
66
- * real file, so the number here is the one that was observed, rounded against
67
- * our own favour.
65
+ * been compressed once. That remains useful when describing expected storage,
66
+ * but it cannot decide whether arbitrary not-yet-compressed input is safe to
67
+ * start.
68
68
  */
69
69
  export const OBSERVED_COMPRESSION = 0.7;
70
+ /** The normal worker seals one independently searchable zstd frame at a time. */
71
+ export const LOCAL_SEAL_CHUNK_BYTES = 64 * 1024 * 1024;
72
+ const ZSTD_SMALL_INPUT_BYTES = 128 * 1024;
73
+ const ARCHIVE_AUTH_TAG_BYTES = 16;
74
+ /**
75
+ * Zstd's documented one-pass upper bound for one input buffer.
76
+ *
77
+ * This mirrors `ZSTD_COMPRESSBOUND`: unlike an observed compression ratio it
78
+ * remains true for already-compressed or adversarial input. The worker calls
79
+ * the one-shot codec once per chunk, then encryption appends one authentication
80
+ * tag to that chunk.
81
+ */
82
+ function zstdCompressedBound(bytes) {
83
+ return bytes
84
+ + Math.floor(bytes / 256)
85
+ + (bytes < ZSTD_SMALL_INPUT_BYTES ? Math.floor((ZSTD_SMALL_INPUT_BYTES - bytes) / 2048) : 0);
86
+ }
87
+ /** Maximum bytes the current local chunk-seal can add to the archive volume. */
88
+ export function maximumArchiveOutputBytes(sourceBytes) {
89
+ const bytes = Math.max(0, Math.ceil(sourceBytes));
90
+ if (!Number.isFinite(bytes))
91
+ return Number.POSITIVE_INFINITY;
92
+ const completeChunks = Math.floor(bytes / LOCAL_SEAL_CHUNK_BYTES);
93
+ const remainder = bytes - completeChunks * LOCAL_SEAL_CHUNK_BYTES;
94
+ const chunks = Math.max(1, completeChunks + (remainder > 0 ? 1 : 0));
95
+ const compressed = completeChunks * zstdCompressedBound(LOCAL_SEAL_CHUNK_BYTES)
96
+ + (remainder > 0 || completeChunks === 0 ? zstdCompressedBound(remainder) : 0);
97
+ return compressed + chunks * ARCHIVE_AUTH_TAG_BYTES;
98
+ }
70
99
  /**
71
100
  * What one archive of this size needs before it is safe to start.
72
101
  *
73
- * The gzip scratch and the sealed object both exist at once, so it is two
74
- * compressed copies, plus the reserve. Incompressible input is the worst case
75
- * and it is not rare enough to ignore, so the ratio is capped at 1: a file that
76
- * does not shrink is planned for as if it will not.
102
+ * The source already exists, and the chunked seal writes one new partial object
103
+ * beside it. Incompressible input is the worst case: the output can be larger
104
+ * than the source by the codec's documented bound plus one authentication tag
105
+ * per chunk. The measured 70% ratio above describes typical storage, never a
106
+ * safe admission threshold.
77
107
  */
78
108
  export function spaceNeededFor(sourceBytes, reserveBytes = DISK_RESERVE_BYTES) {
79
- // One ciphertext, roughly the compressed size, plus the standing reserve.
80
- // The ×2 that used to live here paid for the legacy layout's staged gzip
81
- // body coexisting with the ciphertext; gzip-chunk seals in a single pass
82
- // and the staging is gone. A 5 GB session now asks for ~5.5 GB free, not 9.
83
- return Math.ceil(sourceBytes * Math.min(1, OBSERVED_COMPRESSION)) + reserveBytes;
109
+ return maximumArchiveOutputBytes(sourceBytes) + Math.max(0, Math.ceil(reserveBytes));
84
110
  }
85
111
  /** Free bytes on the volume holding `path`, or undefined where that cannot be asked. */
86
112
  export async function freeBytes(path) {
@@ -8,4 +8,38 @@ export type DoctorReport = {
8
8
  ok: boolean;
9
9
  checks: Check[];
10
10
  };
11
- export declare function runDoctor(dataDir: string, env?: NodeJS.ProcessEnv): Promise<DoctorReport>;
11
+ /**
12
+ * The archive-store fact shared by Doctor and the dashboard status endpoint.
13
+ *
14
+ * Counts are deliberately metadata-only: this check never opens ciphertext or
15
+ * transcript files. `detail` contains at most the first affected filename,
16
+ * matching Doctor's existing safe diagnostic and never the storage directory.
17
+ */
18
+ export type ArchiveIntegritySummary = {
19
+ ok: boolean;
20
+ /** Backward-compatible total of metadata files that could not be used. */
21
+ unreadableMetadataCount: number;
22
+ /** Files whose stable contents are not a valid archive record. */
23
+ invalidMetadataCount: number;
24
+ /** Files that still could not be read after bounded retries. */
25
+ transientMetadataReadErrorCount: number;
26
+ detachedArchiveCount: number;
27
+ /** The directory kept changing, so no coherent read-only snapshot was available. */
28
+ scanIncomplete: boolean;
29
+ detail: string;
30
+ };
31
+ export type ArchiveIntegrityScanOptions = {
32
+ /** Dependency seams keep descriptor-pressure and concurrent-change tests deterministic. */
33
+ readMetadata?: (path: string) => Promise<string>;
34
+ listDirectory?: (path: string) => Promise<string[]>;
35
+ concurrency?: number;
36
+ readAttempts?: number;
37
+ snapshotAttempts?: number;
38
+ };
39
+ /** Runtime context supplied by the local API when Doctor runs beside Autopilot. */
40
+ export type DoctorOptions = {
41
+ /** Native service-manager registration is present and running right now. */
42
+ nativeServicePresent?: boolean;
43
+ };
44
+ export declare function archiveIntegritySummary(directory: string, options?: ArchiveIntegrityScanOptions): Promise<ArchiveIntegritySummary>;
45
+ export declare function runDoctor(dataDir: string, env?: NodeJS.ProcessEnv, home?: string, options?: DoctorOptions): Promise<DoctorReport>;