sealkeep 0.8.1 → 0.11.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 (275) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +244 -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.js +4 -4
  16. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  17. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  18. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  20. package/dist/site/index.html +1808 -1904
  21. package/dist/site/llms.txt +67 -0
  22. package/dist/site/trust/architecture-data-flow.html +53 -0
  23. package/dist/site/trust/audit-roadmap.html +37 -0
  24. package/dist/site/trust/deployment-responsibility.html +11 -0
  25. package/dist/site/trust/dpa-sample.html +30 -0
  26. package/dist/site/trust/release-provenance.html +21 -0
  27. package/dist/site/trust/subprocessors.html +15 -0
  28. package/dist/site/trust/threat-model.html +34 -0
  29. package/dist/site/trust/toms.html +41 -0
  30. package/dist/site/trust-document.css +32 -0
  31. package/dist/site/trust.html +73 -0
  32. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  33. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  34. package/dist/site/visual/index.html +18 -0
  35. package/dist/site.zip +0 -0
  36. package/dist/src/activity.d.ts +9 -0
  37. package/dist/src/activity.js +90 -1
  38. package/dist/src/adapters.d.ts +175 -5
  39. package/dist/src/adapters.js +961 -73
  40. package/dist/src/agent-context.d.ts +135 -0
  41. package/dist/src/agent-context.js +1059 -0
  42. package/dist/src/archive-copies.d.ts +47 -0
  43. package/dist/src/archive-copies.js +179 -0
  44. package/dist/src/audit.d.ts +1 -1
  45. package/dist/src/audit.js +29 -4
  46. package/dist/src/autopilot.d.ts +52 -7
  47. package/dist/src/autopilot.js +143 -25
  48. package/dist/src/background-bandwidth.d.ts +46 -0
  49. package/dist/src/background-bandwidth.js +301 -0
  50. package/dist/src/background-cpu.d.ts +82 -0
  51. package/dist/src/background-cpu.js +212 -0
  52. package/dist/src/background-worker-error.d.ts +12 -0
  53. package/dist/src/background-worker-error.js +18 -0
  54. package/dist/src/branding.d.ts +24 -1
  55. package/dist/src/branding.js +31 -1
  56. package/dist/src/bridge.d.ts +233 -0
  57. package/dist/src/bridge.js +604 -0
  58. package/dist/src/byte-stream.d.ts +91 -0
  59. package/dist/src/byte-stream.js +385 -0
  60. package/dist/src/chunk-store.d.ts +41 -8
  61. package/dist/src/chunk-store.js +161 -65
  62. package/dist/src/cli.js +1746 -166
  63. package/dist/src/cloud.d.ts +841 -31
  64. package/dist/src/cloud.js +3196 -277
  65. package/dist/src/context-background.d.ts +37 -0
  66. package/dist/src/context-background.js +309 -0
  67. package/dist/src/context-drain-child.d.ts +1 -0
  68. package/dist/src/context-drain-child.js +98 -0
  69. package/dist/src/context-reader.d.ts +118 -0
  70. package/dist/src/context-reader.js +447 -0
  71. package/dist/src/control-plane/auth.d.ts +32 -4
  72. package/dist/src/control-plane/auth.js +85 -24
  73. package/dist/src/control-plane/server.js +19 -6
  74. package/dist/src/control-plane.d.ts +17 -1
  75. package/dist/src/control-plane.js +32 -6
  76. package/dist/src/crypto.d.ts +1 -1
  77. package/dist/src/crypto.js +5 -5
  78. package/dist/src/daemon-lease.d.ts +70 -0
  79. package/dist/src/daemon-lease.js +420 -0
  80. package/dist/src/daemon.d.ts +94 -1
  81. package/dist/src/daemon.js +1082 -105
  82. package/dist/src/darwin-service-policy.d.ts +41 -0
  83. package/dist/src/darwin-service-policy.js +60 -0
  84. package/dist/src/dashboard-cli.js +15 -15
  85. package/dist/src/device-authorization.d.ts +37 -0
  86. package/dist/src/device-authorization.js +199 -0
  87. package/dist/src/device-enrollment.d.ts +91 -0
  88. package/dist/src/device-enrollment.js +349 -0
  89. package/dist/src/disk.d.ts +17 -12
  90. package/dist/src/disk.js +43 -17
  91. package/dist/src/doctor.d.ts +35 -1
  92. package/dist/src/doctor.js +316 -41
  93. package/dist/src/durable-ticket-lock.d.ts +24 -0
  94. package/dist/src/durable-ticket-lock.js +232 -0
  95. package/dist/src/enroll.d.ts +1 -1
  96. package/dist/src/enroll.js +13 -7
  97. package/dist/src/env.d.ts +10 -1
  98. package/dist/src/env.js +11 -3
  99. package/dist/src/errors.d.ts +8 -8
  100. package/dist/src/errors.js +6 -6
  101. package/dist/src/flush.d.ts +12 -0
  102. package/dist/src/flush.js +37 -0
  103. package/dist/src/heartbeat.d.ts +86 -12
  104. package/dist/src/heartbeat.js +415 -29
  105. package/dist/src/index-background-watchdog.d.ts +1 -0
  106. package/dist/src/index-background-watchdog.js +94 -0
  107. package/dist/src/index-background-work.d.ts +21 -0
  108. package/dist/src/index-background-work.js +25 -0
  109. package/dist/src/index-background.d.ts +64 -0
  110. package/dist/src/index-background.js +394 -0
  111. package/dist/src/index-build-child.d.ts +1 -0
  112. package/dist/src/index-build-child.js +109 -0
  113. package/dist/src/index-manifest.d.ts +52 -0
  114. package/dist/src/index-manifest.js +444 -0
  115. package/dist/src/index-publication-proof.d.ts +84 -0
  116. package/dist/src/index-publication-proof.js +380 -0
  117. package/dist/src/index-publication-state.d.ts +149 -0
  118. package/dist/src/index-publication-state.js +696 -0
  119. package/dist/src/index-publication-verifier.d.ts +89 -0
  120. package/dist/src/index-publication-verifier.js +341 -0
  121. package/dist/src/index-publish.d.ts +62 -0
  122. package/dist/src/index-publish.js +540 -0
  123. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  124. package/dist/src/index-scratch-cleanup.js +166 -0
  125. package/dist/src/index-segment-types.d.ts +132 -0
  126. package/dist/src/index-segment-types.js +21 -0
  127. package/dist/src/index-segments.d.ts +9 -0
  128. package/dist/src/index-segments.js +516 -0
  129. package/dist/src/index-store.d.ts +123 -0
  130. package/dist/src/index-store.js +495 -0
  131. package/dist/src/index-sync.d.ts +91 -15
  132. package/dist/src/index-sync.js +286 -53
  133. package/dist/src/index-upgrade-publication.d.ts +30 -0
  134. package/dist/src/index-upgrade-publication.js +179 -0
  135. package/dist/src/integration-manager.d.ts +32 -0
  136. package/dist/src/integration-manager.js +394 -0
  137. package/dist/src/leakscan.js +1 -1
  138. package/dist/src/local-api.d.ts +56 -15
  139. package/dist/src/local-api.js +4979 -541
  140. package/dist/src/machine-settings.d.ts +51 -0
  141. package/dist/src/machine-settings.js +166 -0
  142. package/dist/src/managed-chunks.d.ts +5 -2
  143. package/dist/src/managed-chunks.js +14 -14
  144. package/dist/src/mcp-install.d.ts +11 -9
  145. package/dist/src/mcp-install.js +73 -28
  146. package/dist/src/mcp-workspace.d.ts +18 -0
  147. package/dist/src/mcp-workspace.js +50 -0
  148. package/dist/src/mcp.js +294 -25
  149. package/dist/src/migrate.d.ts +1 -0
  150. package/dist/src/migrate.js +33 -5
  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 +5897 -290
  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 +235 -29
  249. package/dist/src/vault.js +1783 -190
  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/recipients.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  275. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
@@ -0,0 +1,32 @@
1
+ import { type EscrowBlob } from "./cloud.js";
2
+ import type { BackendName } from "./secrets.js";
3
+ export declare const RECOVERY_CODE_COUNT = 5;
4
+ export declare const RECOVERY_KIT_AGENT = "sealkeep-recovery-kit";
5
+ export declare const RECOVERY_KIT_VERSION = 1;
6
+ export type RecoveryKitStatus = {
7
+ available: boolean;
8
+ remaining: number;
9
+ createdAt: string | null;
10
+ };
11
+ export type RecoveryKitCreated = RecoveryKitStatus & {
12
+ codes: string[];
13
+ };
14
+ export declare function generateRecoveryCode(): string;
15
+ /** Normalization is deliberately strict enough to catch transcription mistakes. */
16
+ export declare function canonicalRecoveryCode(value: string): string;
17
+ export declare function recoveryKitStatusFromBlob(blob: EscrowBlob | null): RecoveryKitStatus;
18
+ export declare function recoveryKitStatus(dataDir: string, preferred?: BackendName): Promise<RecoveryKitStatus>;
19
+ /**
20
+ * Generates a replacement set. Codes are returned once and never persisted in
21
+ * plaintext; only independent scrypt+AEAD wraps enter the escrow envelope.
22
+ */
23
+ export declare function createRecoveryCodes(dataDir: string, rawPhrase: string, preferred?: BackendName, count?: number): Promise<RecoveryKitCreated>;
24
+ /**
25
+ * Restores the original vault on a Gmail-authorized fresh machine. Cloud first
26
+ * awards one opaque capsule to one random claim; only the winner receives a
27
+ * code-decryptable key wrap. Response-loss retries reuse the durable claim id.
28
+ */
29
+ export declare function recoverWithRecoveryCode(dataDir: string, rawCode: string, preferred?: BackendName): Promise<{
30
+ vaultId: string;
31
+ remaining: number;
32
+ }>;
@@ -0,0 +1,338 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { decryptArchive, encryptArchive, } from "../packages/sealkeep-crypto/src/index.js";
3
+ import { cloudRecoveryKitStatus, claimCloudRecoveryCodeCapsule, cloudToken, consumeCloudRecoveryCode, redeemCloudRecoveryCode, replaceCloudRecoveryKit, } from "./cloud.js";
4
+ import { adoptPersonalVaultMaterial, assertFreshPersonalVaultTarget } from "./device-enrollment.js";
5
+ import { fail, isSealkeepError } from "./errors.js";
6
+ import { canonicalPhrase } from "./mnemonic.js";
7
+ import { readConfig } from "./vault.js";
8
+ import { equalHex, phraseCheck } from "./crypto.js";
9
+ import { chooseBackend, rememberRecoveryPhrase, resolveRecoveryPhrase } from "./secrets.js";
10
+ const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
11
+ const CODE_BYTES = 16;
12
+ const CLAIM_BYTES = 16;
13
+ const CLAIM_SERVICE = "sealkeep-recovery-claim";
14
+ export const RECOVERY_CODE_COUNT = 5;
15
+ export const RECOVERY_KIT_AGENT = "sealkeep-recovery-kit";
16
+ export const RECOVERY_KIT_VERSION = 1;
17
+ /** Crockford Base32: case-insensitive and omits the easiest-to-confuse letters. */
18
+ function base32(input) {
19
+ let bits = 0;
20
+ let value = 0;
21
+ let output = "";
22
+ for (const byte of input) {
23
+ value = (value << 8) | byte;
24
+ bits += 8;
25
+ while (bits >= 5) {
26
+ output += ALPHABET[(value >>> (bits - 5)) & 31];
27
+ bits -= 5;
28
+ }
29
+ }
30
+ if (bits > 0)
31
+ output += ALPHABET[(value << (5 - bits)) & 31];
32
+ return output;
33
+ }
34
+ export function generateRecoveryCode() {
35
+ const body = base32(randomBytes(CODE_BYTES));
36
+ return `SK-${body.match(/.{1,4}/g).join("-")}`;
37
+ }
38
+ /** Normalization is deliberately strict enough to catch transcription mistakes. */
39
+ export function canonicalRecoveryCode(value) {
40
+ const compact = value.trim().toUpperCase().replace(/[\s-]+/g, "");
41
+ const body = compact.startsWith("SK") ? compact.slice(2) : compact;
42
+ if (!/^[0-9A-HJKMNP-TV-Z]{26}$/.test(body)) {
43
+ fail("invalid_argument", "That is not a SealKeep backup code. Enter all 26 characters from the Recovery Kit.");
44
+ }
45
+ return `SK-${body.match(/.{1,4}/g).join("-")}`;
46
+ }
47
+ function material(config, phrase) {
48
+ return {
49
+ kind: "sealkeep-recovery-kit",
50
+ version: RECOVERY_KIT_VERSION,
51
+ vaultId: config.vaultId,
52
+ recoveryPhrase: canonicalPhrase(phrase),
53
+ storageMode: config.storageMode === "plain" ? "plain" : "sealed",
54
+ ...(config.recipients?.length ? { recipients: config.recipients } : {}),
55
+ ...(config.remoteNaming ? { remoteNaming: config.remoteNaming } : {}),
56
+ createdAt: new Date().toISOString(),
57
+ };
58
+ }
59
+ function isRecoveryKit(envelope) {
60
+ return envelope.manifest.adapter?.agent === RECOVERY_KIT_AGENT
61
+ && envelope.manifest.adapter?.version === String(RECOVERY_KIT_VERSION);
62
+ }
63
+ function envelopeFrom(blob) {
64
+ return typeof blob.envelope === "string" ? JSON.parse(blob.envelope) : blob.envelope;
65
+ }
66
+ export function recoveryKitStatusFromBlob(blob) {
67
+ if (!blob)
68
+ return { available: false, remaining: 0, createdAt: null };
69
+ const envelope = envelopeFrom(blob);
70
+ if (!isRecoveryKit(envelope))
71
+ return { available: false, remaining: 0, createdAt: blob.created_at ?? null };
72
+ // Compatibility inspection for pre-capsule kits only. Current kits expose
73
+ // just the permanent phrase wrap here; authoritative one-time-code status is
74
+ // account scoped and comes from cloudRecoveryKitStatus().
75
+ const remaining = Math.max(0, envelope.wrappedKeys.filter((item) => item.type === "phrase").length - 1);
76
+ return { available: remaining > 0, remaining, createdAt: blob.created_at ?? envelope.manifest.createdAt ?? null };
77
+ }
78
+ export async function recoveryKitStatus(dataDir, preferred) {
79
+ const status = await cloudRecoveryKitStatus(dataDir, preferred);
80
+ return { available: status.available, remaining: status.remaining, createdAt: status.created_at ?? null };
81
+ }
82
+ function recoveryCodeRecipientId(rawCode) {
83
+ return createHash("sha256").update(canonicalRecoveryCode(rawCode)).digest("hex").slice(0, 32);
84
+ }
85
+ /**
86
+ * Generates a replacement set. Codes are returned once and never persisted in
87
+ * plaintext; only independent scrypt+AEAD wraps enter the escrow envelope.
88
+ */
89
+ export async function createRecoveryCodes(dataDir, rawPhrase, preferred, count = RECOVERY_CODE_COUNT) {
90
+ if (count !== RECOVERY_CODE_COUNT)
91
+ fail("invalid_argument", `A Recovery Kit contains exactly ${RECOVERY_CODE_COUNT} backup codes`);
92
+ const config = await readConfig(dataDir);
93
+ const phrase = canonicalPhrase(rawPhrase);
94
+ const codes = Array.from({ length: count }, generateRecoveryCode);
95
+ const recipients = [
96
+ { kind: "phrase", phrase },
97
+ ...codes.map((code) => ({ kind: "phrase", phrase: code })),
98
+ ];
99
+ const sealed = encryptArchive(Buffer.from(JSON.stringify(material(config, phrase))), {
100
+ recipients,
101
+ adapter: { agent: RECOVERY_KIT_AGENT, version: String(RECOVERY_KIT_VERSION) },
102
+ });
103
+ const [masterWrap, ...codeWraps] = sealed.envelope.wrappedKeys;
104
+ if (!masterWrap || codeWraps.length !== codes.length)
105
+ fail("internal", "The Recovery Kit could not be split into encrypted capsules");
106
+ const masterEnvelope = { ...sealed.envelope, wrappedKeys: [masterWrap] };
107
+ const capsules = codeWraps.map((wrappedKey, index) => ({
108
+ recipient_id: recoveryCodeRecipientId(codes[index]),
109
+ envelope: { ...sealed.envelope, wrappedKeys: [wrappedKey] },
110
+ sealed: sealed.ciphertext.toString("base64"),
111
+ }));
112
+ const stored = await replaceCloudRecoveryKit(dataDir, { envelope: masterEnvelope, ciphertext: sealed.ciphertext }, capsules, preferred);
113
+ return { available: true, remaining: stored.remaining, createdAt: stored.created_at ?? sealed.envelope.manifest.createdAt, codes };
114
+ }
115
+ function openWithCode(blob, rawCode) {
116
+ const code = canonicalRecoveryCode(rawCode);
117
+ const envelope = envelopeFrom(blob);
118
+ if (!isRecoveryKit(envelope)) {
119
+ return fail("archive_not_found", "This account has no Backup Code Recovery Kit yet. Use the 24-word phrase or approve this machine from another device.");
120
+ }
121
+ const ciphertext = Buffer.from(blob.sealed, "base64");
122
+ if (envelope.wrappedKeys.length !== 1 || envelope.wrappedKeys[0]?.type !== "phrase") {
123
+ return fail("internal", "The Recovery Kit capsule stored for this account is malformed");
124
+ }
125
+ let plaintext;
126
+ try {
127
+ plaintext = decryptArchive(envelope, ciphertext, { phrase: code });
128
+ }
129
+ catch {
130
+ return fail("recovery_phrase_mismatch", "That backup code is not unused in this Recovery Kit. Check the code or use another one.");
131
+ }
132
+ let decoded;
133
+ try {
134
+ decoded = JSON.parse(plaintext.toString("utf8"));
135
+ }
136
+ catch {
137
+ return fail("internal", "The Recovery Kit stored for this account is malformed");
138
+ }
139
+ if (decoded.kind !== "sealkeep-recovery-kit" || decoded.version !== RECOVERY_KIT_VERSION
140
+ || typeof decoded.vaultId !== "string" || typeof decoded.recoveryPhrase !== "string") {
141
+ return fail("internal", "The Recovery Kit stored for this account is malformed");
142
+ }
143
+ return decoded;
144
+ }
145
+ function pendingClaimRef(rawCode) {
146
+ const code = canonicalRecoveryCode(rawCode);
147
+ return { service: CLAIM_SERVICE, account: createHash("sha256").update(code).digest("hex") };
148
+ }
149
+ function parsePendingClaim(raw) {
150
+ if (!raw)
151
+ return null;
152
+ try {
153
+ const value = JSON.parse(raw);
154
+ if (![1, 2].includes(Number(value.version)) || !/^[a-f0-9]{32}$/.test(String(value.claimId))
155
+ || !/^[a-f0-9]{32}$/.test(String(value.recipientId)))
156
+ return null;
157
+ if (value.version === 1)
158
+ return {
159
+ version: 2,
160
+ claimId: String(value.claimId),
161
+ recipientId: String(value.recipientId),
162
+ phase: "claimed",
163
+ };
164
+ if (value.phase !== "claimed" && value.phase !== "stored")
165
+ return null;
166
+ if (value.phase === "stored" && (typeof value.vaultId !== "string" || !Number.isSafeInteger(value.remaining)))
167
+ return null;
168
+ return {
169
+ version: 2,
170
+ claimId: String(value.claimId),
171
+ recipientId: String(value.recipientId),
172
+ phase: value.phase,
173
+ ...(typeof value.vaultId === "string" ? { vaultId: value.vaultId } : {}),
174
+ ...(typeof value.remaining === "number" ? { remaining: value.remaining } : {}),
175
+ };
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ }
181
+ function isRecoveryCodeConflict(error) {
182
+ return isSealkeepError(error) && error.code === "lease_expired";
183
+ }
184
+ /** Only a redeem response can establish that this account has no such capsule.
185
+ * Do not reinterpret authentication failures, outages, or claim-status misses
186
+ * as a bad code: those have different retry and recovery semantics. */
187
+ function throwUnavailableRedeem(error) {
188
+ if (isSealkeepError(error) && error.code === "internal" && error.details.httpStatus === 404) {
189
+ return fail("recovery_phrase_mismatch", "That backup code is unavailable for this account or already used. Sign in to the account that created the Recovery Kit, or try another code.");
190
+ }
191
+ return throwRecoveryCodeConflict(error);
192
+ }
193
+ function isUnavailableRedeem(error) {
194
+ return isRecoveryCodeConflict(error)
195
+ || (isSealkeepError(error) && error.code === "internal" && error.details.httpStatus === 404);
196
+ }
197
+ function throwRecoveryCodeConflict(error) {
198
+ // Cloud maps an unavailable/used recovery recipient to lease_expired. Do
199
+ // not expose that implementation detail: the setup UI can safely offer the
200
+ // next printed code, while auth/network errors retain their real meaning.
201
+ if (isRecoveryCodeConflict(error)) {
202
+ return fail("recovery_phrase_mismatch", "That backup code is unavailable or already used. Try another code from this Recovery Kit.");
203
+ }
204
+ throw error;
205
+ }
206
+ async function consumeClaim(dataDir, pending, preferred) {
207
+ try {
208
+ await consumeCloudRecoveryCode(dataDir, pending.recipientId, pending.claimId, preferred);
209
+ }
210
+ catch (consumeError) {
211
+ // A lost consume response is safe to resolve only through the exact random
212
+ // claim. A 404 means the server did not spend it; retain the phase marker
213
+ // so a later retry can submit consume again.
214
+ try {
215
+ await claimCloudRecoveryCodeCapsule(dataDir, pending.recipientId, pending.claimId, preferred);
216
+ }
217
+ catch {
218
+ throwRecoveryCodeConflict(consumeError);
219
+ }
220
+ }
221
+ }
222
+ /**
223
+ * Restores the original vault on a Gmail-authorized fresh machine. Cloud first
224
+ * awards one opaque capsule to one random claim; only the winner receives a
225
+ * code-decryptable key wrap. Response-loss retries reuse the durable claim id.
226
+ */
227
+ export async function recoverWithRecoveryCode(dataDir, rawCode, preferred) {
228
+ // An existing config is a recovery of this vault, not device enrollment.
229
+ // Check the local keystore/environment before creating a durable claim so a
230
+ // repeated click cannot spend a code when this machine is already unlocked.
231
+ const current = await readConfig(dataDir).catch((error) => {
232
+ if (isSealkeepError(error) && error.code === "vault_not_initialized")
233
+ return null;
234
+ throw error;
235
+ });
236
+ const held = current
237
+ ? await resolveRecoveryPhrase(dataDir, current.vaultId, undefined, process.env, preferred)
238
+ : null;
239
+ const heldForVault = current && held !== null && (() => {
240
+ try {
241
+ return equalHex(current.recovery.phraseCheck, phraseCheck(canonicalPhrase(held)));
242
+ }
243
+ catch {
244
+ return false;
245
+ }
246
+ })();
247
+ const ref = pendingClaimRef(rawCode);
248
+ const backend = await chooseBackend(dataDir, preferred);
249
+ let pending = parsePendingClaim(await backend.get(ref));
250
+ if (current) {
251
+ if (pending?.phase === "stored") {
252
+ if (pending.vaultId !== current.vaultId) {
253
+ fail("recovery_phrase_mismatch", "The pending recovery belongs to a different vault. Nothing was changed.");
254
+ }
255
+ if (!heldForVault) {
256
+ fail("recovery_phrase_missing", "This machine stored the recovered phrase but it is no longer available. Restore the machine keystore, then retry.");
257
+ }
258
+ await cloudToken(dataDir, preferred);
259
+ await consumeClaim(dataDir, pending, preferred);
260
+ await backend.delete(ref).catch(() => undefined);
261
+ return { vaultId: current.vaultId, remaining: pending.remaining };
262
+ }
263
+ if (heldForVault) {
264
+ fail("invalid_argument", "This vault is already unlocked on this machine. Nothing was changed.");
265
+ }
266
+ await cloudToken(dataDir, preferred);
267
+ }
268
+ if (!pending) {
269
+ pending = {
270
+ version: 2,
271
+ claimId: randomBytes(CLAIM_BYTES).toString("hex"),
272
+ recipientId: recoveryCodeRecipientId(rawCode),
273
+ phase: "claimed",
274
+ };
275
+ // This contains only opaque identifiers, never the code, phrase, key wrap,
276
+ // or decrypted payload. It is enough to retry one ambiguous network result.
277
+ await backend.set(ref, JSON.stringify(pending));
278
+ }
279
+ if (!current)
280
+ await assertFreshPersonalVaultTarget(dataDir, preferred);
281
+ let awarded;
282
+ try {
283
+ awarded = await redeemCloudRecoveryCode(dataDir, pending.recipientId, pending.claimId, preferred);
284
+ }
285
+ catch (redeemError) {
286
+ // A definitive missing/conflicting capsule was not awarded to this claim.
287
+ // Retrying cannot turn it into an available code, and must not expose a
288
+ // generic internal error for the normal wrong-account/wrong-code case.
289
+ if (isUnavailableRedeem(redeemError)) {
290
+ await backend.delete(ref).catch(() => undefined);
291
+ throwUnavailableRedeem(redeemError);
292
+ }
293
+ // Redeem is idempotent for the same claim, so a lost response is resolved
294
+ // by repeating that exact reservation. Claim status is deliberately not
295
+ // used here: it is the post-consume receipt, and using the same read for
296
+ // both phases would make it incapable of proving final consumption.
297
+ try {
298
+ awarded = await redeemCloudRecoveryCode(dataDir, pending.recipientId, pending.claimId, preferred);
299
+ }
300
+ catch {
301
+ throwUnavailableRedeem(redeemError);
302
+ }
303
+ }
304
+ const payload = openWithCode(awarded, rawCode);
305
+ if (current) {
306
+ // Re-read identity after the network round trip. A concurrent replacement
307
+ // must not cause a phrase for the old vault to be stored under the new one.
308
+ const latest = await readConfig(dataDir);
309
+ let recoveredPhrase;
310
+ try {
311
+ recoveredPhrase = canonicalPhrase(payload.recoveryPhrase);
312
+ }
313
+ catch {
314
+ fail("recovery_phrase_mismatch", "That backup code does not open this vault. Nothing was changed locally.");
315
+ }
316
+ if (latest.vaultId !== current.vaultId || payload.vaultId !== latest.vaultId
317
+ || !equalHex(latest.recovery.phraseCheck, phraseCheck(recoveredPhrase))) {
318
+ fail("recovery_phrase_mismatch", "That backup code does not open this vault. Nothing was changed locally.");
319
+ }
320
+ // Existing archives/configuration remain untouched; only the convenience
321
+ // copy in this machine's selected secret backend is restored.
322
+ await rememberRecoveryPhrase(dataDir, latest.vaultId, recoveredPhrase, preferred);
323
+ pending = { ...pending, version: 2, phase: "stored", vaultId: latest.vaultId, remaining: awarded.remaining };
324
+ await backend.set(ref, JSON.stringify(pending));
325
+ await consumeClaim(dataDir, pending, preferred);
326
+ await backend.delete(ref).catch(() => undefined);
327
+ return { vaultId: latest.vaultId, remaining: awarded.remaining };
328
+ }
329
+ await adoptPersonalVaultMaterial(dataDir, payload, preferred);
330
+ pending = { ...pending, version: 2, phase: "stored", vaultId: payload.vaultId, remaining: awarded.remaining };
331
+ await backend.set(ref, JSON.stringify(pending));
332
+ await consumeClaim(dataDir, pending, preferred);
333
+ await backend.delete(ref).catch(() => undefined);
334
+ return {
335
+ vaultId: payload.vaultId,
336
+ remaining: awarded.remaining,
337
+ };
338
+ }
@@ -2,6 +2,7 @@ import { createInterface } from "node:readline/promises";
2
2
  import { matchesPhraseCheck } from "./crypto.js";
3
3
  import { canonicalPhrase, inspectPhrase, isMnemonic, PHRASE_WORDS } from "./mnemonic.js";
4
4
  import { readConfig } from "./vault.js";
5
+ import { providerLabel } from "./branding.js";
5
6
  /**
6
7
  * The recovery kit.
7
8
  *
@@ -20,6 +21,8 @@ import { readConfig } from "./vault.js";
20
21
  */
21
22
  export function recoveryKit(config, options = {}) {
22
23
  const when = (options.now ?? new Date()).toISOString().slice(0, 10);
24
+ const ownStorage = config.remoteStorage?.provider === "vaultline" ? null : config.remoteStorage;
25
+ const managedStorage = Boolean(options.managed || config.remoteStorage?.provider === "vaultline");
23
26
  const blanks = Array.from({ length: PHRASE_WORDS }, (_, index) => ` ${String(index + 1).padStart(2, " ")}. ______________`);
24
27
  // Column-major, `rows` apart — the same layout the on-screen grid uses. The
25
28
  // offsets were once +6/+12, hard-wired for a 12-word phrase; at 24 words that
@@ -31,15 +34,15 @@ export function recoveryKit(config, options = {}) {
31
34
  for (let row = 0; row < rows; row += 1) {
32
35
  columns.push([blanks[row], blanks[row + rows], blanks[row + rows * 2]].filter(Boolean).join(" "));
33
36
  }
34
- return `VAULTLINE RECOVERY KIT
37
+ return `SEALKEEP RECOVERY KIT
35
38
  ${"=".repeat(58)}
36
39
 
37
40
  Vault ${config.vaultId}
38
41
  Created ${when}
39
- Storage ${config.remoteStorage
40
- ? `${config.remoteStorage.provider}://${config.remoteStorage.bucket}/${config.remoteStorage.prefix}`
41
- : options.managed
42
- ? `Sealkeep Cloud, account ${options.managed.email}`
42
+ Storage ${ownStorage
43
+ ? `${providerLabel(ownStorage.provider)} · ${ownStorage.bucket}/${ownStorage.prefix}`
44
+ : managedStorage
45
+ ? `Sealkeep Cloud${options.managed?.email ? `, account ${options.managed.email}` : ""}`
43
46
  : "this machine only"}
44
47
  Devices ${(config.recipients ?? []).length} registered key(s) besides the phrase
45
48
  ${config.storageMode === "plain" ? `
@@ -61,11 +64,11 @@ TO RESTORE ON A NEW MACHINE
61
64
  ${"-".repeat(58)}
62
65
  1. Install Sealkeep.
63
66
  2. Run: sealkeep setup --recovery-phrase "<the words above>"
64
- ${config.remoteStorage ? ` 3. Point it at your storage:
65
- sealkeep storage configure --provider ${config.remoteStorage.provider} \\
66
- --bucket ${config.remoteStorage.bucket} --prefix ${config.remoteStorage.prefix}
67
+ ${ownStorage ? ` 3. Point it at your storage:
68
+ sealkeep storage configure --provider ${ownStorage.provider} \\
69
+ --bucket ${ownStorage.bucket} --prefix ${ownStorage.prefix}
67
70
  4. Restore a session: sealkeep recover <archive-id> ./restored.jsonl`
68
- : options.managed ? ` 3. Sign in: sealkeep cloud login --email ${options.managed.email}
71
+ : managedStorage ? ` 3. Sign in to Sealkeep Cloud${options.managed?.email ? `: sealkeep cloud login --email ${options.managed.email}` : " from your account page"}
69
72
  4. See what is stored: sealkeep cloud archives
70
73
  5. Read one back: sealkeep open <ref> ./restored.jsonl --recovery-phrase "<the words above>"
71
74
 
@@ -1,43 +1,46 @@
1
1
  /**
2
- * Rehydration: the transparent answer to "resume is bound to a file we moved".
2
+ * Explicit rehydration prepares a reclaimed native transcript for a later use.
3
3
  *
4
- * The failure class this exists for is structural. Agents keep a catalog that
5
- * binds a session id to a transcript path; Sealkeep's whole point is that the
6
- * file can leave the disk once a sealed copy is safe. Those two facts collide
7
- * the moment someone resumes an old session — unless the file comes BACK
8
- * before the agent reads it. So: the agent's own SessionStart hook calls this,
9
- * and when the path is missing but the vault holds it, the newest archive of
10
- * that session is restored natively — exact path, exact bytes, delta chains
11
- * resolved, chunks fetched from the bucket if the local blob was offloaded.
12
- * The resume proceeds as if the file had never been away — paging, not loss.
4
+ * Generated agent configs do not call this automatically: Codex may already
5
+ * hold the pointer inode open, and downloading a multi-GB transcript on resume
6
+ * would defeat reclaim. The pointer instead names MCP search and the explicit
7
+ * safe restore command. This compatibility action remains fail-closed for
8
+ * callers that deliberately invoke it.
13
9
  *
14
- * Contract with the agent: NEVER break the session. Every outcome — including
15
- * "this vault has no phrase available" and "that session was never archived" —
16
- * is a calm JSON line and exit 0. A hook that can fail a session start would
17
- * be worse than the problem it solves.
10
+ * Contract with the agent: NEVER break session start. Every outcome — including
11
+ * "this vault has no phrase available" and a concurrent append — becomes a calm
12
+ * JSON receipt internally and exit 0 at the hook boundary.
18
13
  */
19
14
  export type RehydrateOutcome = {
20
15
  rehydrated: true;
21
16
  archiveId: string;
22
17
  output: string;
23
18
  bytes: number;
19
+ pointerBackup?: string;
24
20
  } | {
25
21
  rehydrated: false;
26
22
  reason: "already-present" | "no-target" | "not-archived" | "no-phrase" | "restore-failed";
27
23
  note?: string;
28
24
  };
25
+ export type RehydrateOperations = {
26
+ /** Deterministic race seam: production callers never provide it. */
27
+ beforeCommit?: (input: {
28
+ archiveId: string;
29
+ output: string;
30
+ managed: boolean;
31
+ }) => Promise<void>;
32
+ };
33
+ type RehydrateOptions = {
34
+ home?: string;
35
+ operations?: RehydrateOperations;
36
+ };
29
37
  export declare function rehydrateSession(dataDir: string, target: {
30
38
  path?: string;
31
39
  sessionId?: string;
32
- }, phrase: string | undefined, options?: {
33
- home?: string;
34
- }): Promise<RehydrateOutcome>;
35
- /**
36
- * Lenient hook-payload reading, distinct from `hookEventFromStdin` on purpose:
37
- * that parser refuses when the transcript is unreadable, and an ABSENT
38
- * transcript is this feature's entire subject.
39
- */
40
+ }, phrase: string | undefined, options?: RehydrateOptions): Promise<RehydrateOutcome>;
41
+ /** Lenient because an absent transcript is this feature's subject. */
40
42
  export declare function rehydrateTargetFromPayload(stdin: string): {
41
43
  path?: string;
42
44
  sessionId?: string;
43
45
  };
46
+ export {};