sealkeep 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/ARCHITECTURE.md +201 -0
  2. package/CHANGELOG.md +218 -0
  3. package/CONTROL_PLANE.md +86 -0
  4. package/LICENSE +34 -0
  5. package/README.md +249 -0
  6. package/THIRD_PARTY.md +22 -0
  7. package/THREAT_MODEL.md +107 -0
  8. package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
  9. package/dist/packages/vaultline-crypto/src/aead.js +24 -0
  10. package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
  11. package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
  12. package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
  13. package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
  14. package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
  15. package/dist/packages/vaultline-crypto/src/format.js +43 -0
  16. package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
  17. package/dist/packages/vaultline-crypto/src/index.js +5 -0
  18. package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
  19. package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
  20. package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
  21. package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
  22. package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
  23. package/dist/packages/vaultline-crypto/src/stream.js +477 -0
  24. package/dist/site/index.html +1542 -0
  25. package/dist/site.zip +0 -0
  26. package/dist/src/activity.d.ts +22 -0
  27. package/dist/src/activity.js +52 -0
  28. package/dist/src/adapters.d.ts +212 -0
  29. package/dist/src/adapters.js +533 -0
  30. package/dist/src/audit.d.ts +24 -0
  31. package/dist/src/audit.js +41 -0
  32. package/dist/src/autopilot.d.ts +77 -0
  33. package/dist/src/autopilot.js +148 -0
  34. package/dist/src/bip39-wordlist.d.ts +15 -0
  35. package/dist/src/bip39-wordlist.js +272 -0
  36. package/dist/src/branding.d.ts +31 -0
  37. package/dist/src/branding.js +31 -0
  38. package/dist/src/chunk-store.d.ts +142 -0
  39. package/dist/src/chunk-store.js +502 -0
  40. package/dist/src/cli.d.ts +2 -0
  41. package/dist/src/cli.js +2035 -0
  42. package/dist/src/cloud.d.ts +434 -0
  43. package/dist/src/cloud.js +851 -0
  44. package/dist/src/control-plane/auth.d.ts +62 -0
  45. package/dist/src/control-plane/auth.js +123 -0
  46. package/dist/src/control-plane/server.d.ts +31 -0
  47. package/dist/src/control-plane/server.js +263 -0
  48. package/dist/src/control-plane/store.d.ts +101 -0
  49. package/dist/src/control-plane/store.js +82 -0
  50. package/dist/src/control-plane-cli.d.ts +2 -0
  51. package/dist/src/control-plane-cli.js +37 -0
  52. package/dist/src/control-plane-server.d.ts +10 -0
  53. package/dist/src/control-plane-server.js +11 -0
  54. package/dist/src/control-plane.d.ts +78 -0
  55. package/dist/src/control-plane.js +61 -0
  56. package/dist/src/crypto.d.ts +56 -0
  57. package/dist/src/crypto.js +132 -0
  58. package/dist/src/daemon.d.ts +52 -0
  59. package/dist/src/daemon.js +142 -0
  60. package/dist/src/dashboard-cli.d.ts +2 -0
  61. package/dist/src/dashboard-cli.js +20 -0
  62. package/dist/src/disk.d.ts +110 -0
  63. package/dist/src/disk.js +169 -0
  64. package/dist/src/doctor.d.ts +11 -0
  65. package/dist/src/doctor.js +198 -0
  66. package/dist/src/enroll.d.ts +27 -0
  67. package/dist/src/enroll.js +136 -0
  68. package/dist/src/errors.d.ts +26 -0
  69. package/dist/src/errors.js +23 -0
  70. package/dist/src/heartbeat.d.ts +89 -0
  71. package/dist/src/heartbeat.js +120 -0
  72. package/dist/src/index-sync.d.ts +53 -0
  73. package/dist/src/index-sync.js +147 -0
  74. package/dist/src/leakscan.d.ts +48 -0
  75. package/dist/src/leakscan.js +222 -0
  76. package/dist/src/local-api.d.ts +132 -0
  77. package/dist/src/local-api.js +1757 -0
  78. package/dist/src/managed-chunks.d.ts +55 -0
  79. package/dist/src/managed-chunks.js +108 -0
  80. package/dist/src/mcp-install.d.ts +52 -0
  81. package/dist/src/mcp-install.js +140 -0
  82. package/dist/src/mcp.d.ts +1 -0
  83. package/dist/src/mcp.js +59 -0
  84. package/dist/src/migrate.d.ts +35 -0
  85. package/dist/src/migrate.js +88 -0
  86. package/dist/src/mnemonic.d.ts +60 -0
  87. package/dist/src/mnemonic.js +134 -0
  88. package/dist/src/net.d.ts +2 -0
  89. package/dist/src/net.js +16 -0
  90. package/dist/src/notify.d.ts +46 -0
  91. package/dist/src/notify.js +84 -0
  92. package/dist/src/offload.d.ts +117 -0
  93. package/dist/src/offload.js +331 -0
  94. package/dist/src/onboarding.d.ts +10 -0
  95. package/dist/src/onboarding.js +44 -0
  96. package/dist/src/packages.d.ts +126 -0
  97. package/dist/src/packages.js +114 -0
  98. package/dist/src/passkey.d.ts +26 -0
  99. package/dist/src/passkey.js +54 -0
  100. package/dist/src/password-lock.d.ts +19 -0
  101. package/dist/src/password-lock.js +156 -0
  102. package/dist/src/paths.d.ts +9 -0
  103. package/dist/src/paths.js +24 -0
  104. package/dist/src/providers/gcs.d.ts +133 -0
  105. package/dist/src/providers/gcs.js +235 -0
  106. package/dist/src/providers/gdrive.d.ts +156 -0
  107. package/dist/src/providers/gdrive.js +335 -0
  108. package/dist/src/providers/index.d.ts +45 -0
  109. package/dist/src/providers/index.js +74 -0
  110. package/dist/src/providers/s3.d.ts +174 -0
  111. package/dist/src/providers/s3.js +345 -0
  112. package/dist/src/providers/sigv4.d.ts +78 -0
  113. package/dist/src/providers/sigv4.js +112 -0
  114. package/dist/src/queue.d.ts +185 -0
  115. package/dist/src/queue.js +286 -0
  116. package/dist/src/recovery.d.ts +40 -0
  117. package/dist/src/recovery.js +132 -0
  118. package/dist/src/rehydrate.d.ts +43 -0
  119. package/dist/src/rehydrate.js +66 -0
  120. package/dist/src/restore.d.ts +34 -0
  121. package/dist/src/restore.js +80 -0
  122. package/dist/src/retention.d.ts +251 -0
  123. package/dist/src/retention.js +446 -0
  124. package/dist/src/rotate.d.ts +47 -0
  125. package/dist/src/rotate.js +95 -0
  126. package/dist/src/search.d.ts +147 -0
  127. package/dist/src/search.js +677 -0
  128. package/dist/src/secrets.d.ts +86 -0
  129. package/dist/src/secrets.js +220 -0
  130. package/dist/src/service.d.ts +73 -0
  131. package/dist/src/service.js +197 -0
  132. package/dist/src/share.d.ts +34 -0
  133. package/dist/src/share.js +68 -0
  134. package/dist/src/spool.d.ts +97 -0
  135. package/dist/src/spool.js +213 -0
  136. package/dist/src/start-tui.d.ts +17 -0
  137. package/dist/src/start-tui.js +113 -0
  138. package/dist/src/start.d.ts +75 -0
  139. package/dist/src/start.js +101 -0
  140. package/dist/src/storage-setup.d.ts +49 -0
  141. package/dist/src/storage-setup.js +222 -0
  142. package/dist/src/storage-targets.d.ts +40 -0
  143. package/dist/src/storage-targets.js +147 -0
  144. package/dist/src/stream-to-cloud.d.ts +76 -0
  145. package/dist/src/stream-to-cloud.js +820 -0
  146. package/dist/src/sync-rules.d.ts +85 -0
  147. package/dist/src/sync-rules.js +125 -0
  148. package/dist/src/trash.d.ts +15 -0
  149. package/dist/src/trash.js +63 -0
  150. package/dist/src/tui.d.ts +18 -0
  151. package/dist/src/tui.js +179 -0
  152. package/dist/src/types.d.ts +191 -0
  153. package/dist/src/types.js +3 -0
  154. package/dist/src/ui-server.d.ts +187 -0
  155. package/dist/src/ui-server.js +293 -0
  156. package/dist/src/ui.d.ts +41 -0
  157. package/dist/src/ui.js +102 -0
  158. package/dist/src/update.d.ts +30 -0
  159. package/dist/src/update.js +56 -0
  160. package/dist/src/upload.d.ts +46 -0
  161. package/dist/src/upload.js +80 -0
  162. package/dist/src/vault.d.ts +208 -0
  163. package/dist/src/vault.js +812 -0
  164. package/dist/src/watcher.d.ts +34 -0
  165. package/dist/src/watcher.js +121 -0
  166. package/dist/src/worker.d.ts +52 -0
  167. package/dist/src/worker.js +190 -0
  168. package/package.json +65 -0
  169. package/web/app.js +1372 -0
  170. package/web/index.html +476 -0
  171. package/web/rail.js +308 -0
  172. package/web/retention.html +17 -0
  173. package/web/rules-view.js +249 -0
  174. package/web/sessions-view.js +448 -0
  175. package/web/sessions.html +17 -0
  176. package/web/setup-api.js +181 -0
  177. package/web/setup-logic.js +394 -0
  178. package/web/setup.html +419 -0
  179. package/web/setup.js +697 -0
  180. package/web/style.css +990 -0
package/README.md ADDED
@@ -0,0 +1,249 @@
1
+ <div align="center">
2
+
3
+ # ⌑ Sealkeep
4
+
5
+ **Keep your agent history, not the disk usage.**
6
+
7
+ Codex and Claude Code write down every session you have. Sealkeep seals them on your
8
+ machine, verifies a copy landed in your own bucket, and only then lets the local file go.
9
+
10
+ </div>
11
+
12
+ ```sh
13
+ npm install -g sealkeep
14
+ sealkeep autopilot
15
+ ```
16
+
17
+ That is the whole setup. It creates your vault, shows your recovery phrase once, finds the
18
+ sessions already on this machine, and installs a background service that starts at login.
19
+
20
+ From then on you do nothing. New sessions are sealed as they finish. Once you point it at a
21
+ bucket, verified copies go up and old local files move to your trash on their own.
22
+
23
+ ```sh
24
+ sealkeep autopilot status # is it alive, and what has it done
25
+ sealkeep autopilot off # stop it — every archive stays
26
+ ```
27
+
28
+ It tells you it is working rather than leaving you to wonder. The service writes a
29
+ heartbeat every cycle, so `status`, the dashboard, and the local API all answer
30
+ "is it running, when did it last check, and what has it sealed" from the same fact.
31
+ It also raises a native notification when disk is reclaimed or something needs a
32
+ decision — never for routine sealing, which would just train you to ignore it.
33
+ Silence it entirely with `VAULTLINE_NOTIFICATIONS=off`.
34
+
35
+ Prefer to drive it yourself? `sealkeep quickstart` does the same setup without installing
36
+ a service, and you run `sealkeep queue run` when you feel like it.
37
+
38
+ ---
39
+
40
+ ## Why
41
+
42
+ Transcripts are the only record of *how* a change happened — the dead ends, the constraint
43
+ you found on the third attempt, the reason that function is strange. They also contain your
44
+ source, your prompts, and whatever you pasted at 2am.
45
+
46
+ So the two obvious options are both bad: let them fill the disk, or `rm -rf` the reasoning
47
+ behind six months of work. Sealkeep is the third option.
48
+
49
+ ## How it works
50
+
51
+ The order is the safety property. Nothing is reclaimed before it is verified, and nothing is
52
+ verified before it is sealed.
53
+
54
+ **1. Catch** — your agent's session hook records that a transcript is ready, then exits. It
55
+ holds no secret, makes no network call, and finishes in milliseconds, so it can never stall
56
+ your agent.
57
+
58
+ **2. Seal** — a worker encrypts the session with a key generated for that archive alone.
59
+ That key is wrapped once for your recovery phrase and once per device you trust, so revoking
60
+ a laptop rewraps keys rather than re-encrypting your data.
61
+
62
+ **3. Verify** — the sealed archive goes to your bucket, and Sealkeep reads it back. Byte
63
+ count and checksum must match what was computed locally, or the archive is not marked
64
+ durable and your local file stays exactly where it is.
65
+
66
+ **4. Reclaim** — only a verified, idle, out-of-grace-period session becomes eligible, and
67
+ then it moves to your trash. There is no code path in Sealkeep that deletes a file.
68
+
69
+ Autopilot runs all four on a loop. To unlock the vault without a human present, it keeps
70
+ your phrase in the OS keystore — macOS Keychain, Freedesktop Secret Service, or Windows
71
+ DPAPI. That is a real trade-off, stated plainly: anything running as you can read it once
72
+ you are logged in. Opt out with `--no-remember` and supply
73
+ `VAULTLINE_RECOVERY_PHRASE` to the service yourself. Either way, the phrase you wrote down
74
+ stays your disaster-recovery path.
75
+
76
+ ## Your recovery phrase is 24 words
77
+
78
+ Not a base64 string. Words survive bad handwriting, can be read aloud, and carry a
79
+ checksum — so a misread word is caught and corrected rather than silently locking you out.
80
+
81
+ ```
82
+ acorn oxide lynx atom voyage wool candle umber steel radar east clock
83
+ iris brass stork maple ridge ember quilt fable onyx cedar dune cricket
84
+ ```
85
+
86
+ ```sh
87
+ sealkeep recovery kit --out ~/sealkeep-kit.txt # printable sheet, blanks for the words
88
+ sealkeep recovery verify # type it back to prove your copy works
89
+ ```
90
+
91
+ Case and spacing do not matter when you type it back. A wrong word is named with
92
+ suggestions: *Word 1 "acron" is not in the list. Did you mean acorn, arrow?*
93
+
94
+ ## Setting up a bucket, without the usual afternoon
95
+
96
+ The step people give up on is cloud permissions. So Sealkeep writes it out — the console
97
+ link, the commands with your own bucket and prefix already filled in, and a policy that
98
+ grants the least it can.
99
+
100
+ ```sh
101
+ sealkeep storage setup --provider s3 --bucket my-archives --prefix accounts/me
102
+ sealkeep storage setup --provider r2 --bucket sessions --prefix me --account-id <id>
103
+ sealkeep storage setup --provider gcs --bucket sessions --prefix me --project <project>
104
+ ```
105
+
106
+ For S3 that is an IAM policy scoped to `bucket/prefix/*` with no delete permission and no
107
+ wildcard resource. For GCS it is a service account with an IAM condition pinned to your
108
+ prefix. Nothing runs automatically — you read each step and paste it yourself, which is the
109
+ only honest way to hand someone an IAM policy.
110
+
111
+ ## What we can see
112
+
113
+ The control plane coordinates devices, quotas, and upload permissions.
114
+
115
+ | It holds | It never receives |
116
+ | --- | --- |
117
+ | Account and device identifiers | Transcript content — no endpoint accepts it |
118
+ | Byte counts and timestamps | Archive keys — generated and wrapped on your machine |
119
+ | Checksums of sealed archives | Your recovery phrase — shown once, stored nowhere |
120
+ | Encrypted manifests it cannot read | Device private keys — only public halves are registered |
121
+
122
+ > If you lose your recovery kit and every device you registered, your archives are
123
+ > unreadable. Support cannot get them back. That is not a gap in the product — it is the product.
124
+
125
+ ## What your bucket sees
126
+
127
+ One archive is one folder of fixed-size encrypted chunks plus a small sealed
128
+ envelope. By default every path segment — project, date, session — is an HMAC
129
+ under a key derived from your phrase, so the bucket's file listing is noise:
130
+ no project names, no dates, no session ids. A sealed sidecar in each folder
131
+ carries the readable identity, so any machine with the phrase rebuilds the
132
+ friendly `project/date/session` view from the bucket alone. Prefer legible
133
+ paths in your own bucket? Set `remoteNaming: "readable"` and the same layout
134
+ is written in plain words.
135
+
136
+ Search works the same way: the content index is sealed with the archive
137
+ machinery and synced beside your data, so every machine of the vault searches
138
+ your whole history — and the provider never sees a token of it.
139
+
140
+ The layout also answers the worst day: a transcript too large to seal on a
141
+ nearly-full disk streams straight into its chunk folder — peak local cost is
142
+ one chunk — instead of waiting for space. Google Drive is the one exception:
143
+ it stores archives as single objects, and says so.
144
+
145
+ ## Commands
146
+
147
+ | | |
148
+ | --- | --- |
149
+ | `sealkeep autopilot` | Set it up once and let it run itself |
150
+ | `sealkeep autopilot status` · `off` | Check on it, or stop it |
151
+ | `sealkeep quickstart` | Same setup, no background service |
152
+ | `sealkeep status` · `doctor` | What is archived; whether this machine is healthy |
153
+ | `sealkeep queue run` | Encrypt everything waiting |
154
+ | `sealkeep daemon` | Watch, seal, upload, and reclaim continuously |
155
+ | `sealkeep search <query>` | Metadata search; `--content` searches inside sessions |
156
+ | `sealkeep recover <id> <dest>` | Restore original bytes; `--native` puts them back where they came from |
157
+ | `sealkeep recover <filename>` | The file an agent's resume just complained about, back in place |
158
+ | `sealkeep archive <path> --stream` | Seal straight to the bucket as a chunk folder, resumable mid-upload |
159
+ | `sealkeep share <id>` | A sealed bundle under a one-time passcode — the phrase never travels |
160
+ | `sealkeep mcp install` | Register the vault as a tool server with Claude Code and Codex |
161
+ | `sealkeep retention apply` | Dry run; `--confirm` moves sources to the trash |
162
+ | `sealkeep desktop` · `tui` | The dashboard, in a browser or the terminal |
163
+ | `sealkeep audit` | Everything Sealkeep has done to your files |
164
+
165
+ Every command takes `--json`. Run `sealkeep help` for the full list.
166
+
167
+ ## Your agents know the vault is there
168
+
169
+ `sealkeep mcp install` registers a local MCP server with Claude Code and
170
+ Codex, so the agent itself can search your sealed history, check vault
171
+ status, and restore a session it needs — six tools, all local, nothing new
172
+ exposed to the network.
173
+
174
+ And the failure this whole product could have caused is handled where it
175
+ would happen: resuming a session whose transcript was archived and reclaimed.
176
+ A `SessionStart` hook notices the missing file and restores it — exact path,
177
+ exact bytes — before the agent reads it, so the resume proceeds as if the
178
+ file had never left. If anything is ever missing anyway, `sealkeep recover
179
+ <filename>` with the filename from the agent's error puts it back. Archiving
180
+ must never cost you a resume.
181
+
182
+ ## Your storage
183
+
184
+ S3, Cloudflare R2, Google Cloud Storage, Google Drive, or anything
185
+ S3-compatible like MinIO. Credentials go into your OS keychain — macOS
186
+ Keychain, Freedesktop Secret Service, or Windows DPAPI — never into a config
187
+ file in your home directory. On the paid tier, Sealkeep runs the storage
188
+ instead: quotas and uploads are brokered by the control plane, and your keys
189
+ and phrase still never leave your machine.
190
+
191
+ ```sh
192
+ sealkeep storage configure --provider r2 --bucket encrypted-sessions --prefix accounts/me
193
+ echo '{"accessKeyId":"…","secretAccessKey":"…"}' | sealkeep storage credentials set
194
+ VAULTLINE_ENABLE_SIGNER=1 sealkeep upload --all
195
+ ```
196
+
197
+ Or skip storage entirely and keep everything local. Sealkeep still seals every archive; it
198
+ just has nowhere to copy it.
199
+
200
+ ## Encryption
201
+
202
+ Format v2: a random per-archive key encrypts the session in chunks under ChaCha20-Poly1305
203
+ (or AES-256-GCM), and that key is wrapped separately for each recipient. Every chunk is
204
+ authenticated over its own position, the archive's identity, and the total chunk count — so
205
+ reordering, truncation, splicing, and suite downgrade are all detectable rather than silent.
206
+
207
+ The format lives in [`packages/vaultline-crypto/`](./packages/vaultline-crypto/README.md)
208
+ with deterministic test vectors and a tamper suite that flips every byte of a stored archive
209
+ in turn. Assumptions and limits are in [`THREAT_MODEL.md`](./THREAT_MODEL.md).
210
+
211
+ **It has not been independently audited.** Until it is, this is authenticated encryption
212
+ built on published primitives — not an audited end-to-end-encryption product.
213
+
214
+ ## Status
215
+
216
+ | Area | State |
217
+ | --- | --- |
218
+ | Capture and sealing | **Shipping** — durable queue, crash-safe, deduplicating, resumable streaming |
219
+ | Restore | **Shipping** — byte-identical, verified twice, never overwrites; rehydrates on agent resume |
220
+ | Retention | **Shipping** — trash-staged, never deletes |
221
+ | Search | **Shipping** — sealed content index, synced across your machines as ciphertext |
222
+ | Agent integration | **Shipping** — MCP server for Claude Code and Codex, session hooks |
223
+ | Encryption format | **Unaudited** — vectors and tamper suite, no third-party audit yet |
224
+ | Cloud providers | **GCS live in production**; S3, R2, and Drive verified against signature vectors and emulators, without a long production soak yet |
225
+ | Signed binaries | **Not yet** — npm provenance and a signed manifest; no OS code-signing certificate |
226
+ | Distribution | **Published** — `npm install -g sealkeep` |
227
+
228
+ ## Self-hosting the control plane
229
+
230
+ ```sh
231
+ docker build -t vaultline-control-plane .
232
+ docker run -p 8787:8787 -v vaultline-data:/data vaultline-control-plane
233
+ ```
234
+
235
+ Devices authenticate with Ed25519 request signatures, leases are single-use and expiring,
236
+ and revocation blocks new leases without touching archives. See
237
+ [`CONTROL_PLANE.md`](./CONTROL_PLANE.md) and [`docs/RUNBOOK.md`](./docs/RUNBOOK.md).
238
+
239
+ ## Development
240
+
241
+ ```sh
242
+ npm test # the full suite — crypto vectors, a tamper suite, provider emulators, dashboard rendering
243
+ npm run build
244
+ ```
245
+
246
+ Architecture is in [`ARCHITECTURE.md`](./ARCHITECTURE.md); designs and operations live in
247
+ [`docs/`](./docs/README.md), including the not-yet-built
248
+ [sharing and spaces design](./docs/sharing-and-spaces.md). The marketing page in
249
+ [`site/`](./site/index.html) is a single self-contained file.
package/THIRD_PARTY.md ADDED
@@ -0,0 +1,22 @@
1
+ # Third-party components
2
+
3
+ Vaultline ships with the following third-party components, each under its own
4
+ licence (see §8 of LICENSE). Nothing else in the published package is
5
+ third-party code.
6
+
7
+ ## Runtime dependencies
8
+
9
+ - **zod** — TypeScript schema validation.
10
+ Licence: MIT. https://github.com/colinhacks/zod
11
+ - **@modelcontextprotocol/sdk** — Model Context Protocol SDK, used by
12
+ `vaultline mcp`.
13
+ Licence: MIT. https://github.com/modelcontextprotocol/typescript-sdk
14
+
15
+ ## Vendored data
16
+
17
+ - **BIP-39 English word list** (`src/bip39-wordlist.ts`) — the 2048-word list
18
+ exactly as frozen by the BIP-39 specification, vendored from
19
+ https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt so the
20
+ supply chain carries no extra dependency. The build verifies it against the
21
+ specification's published SHA-256 digest. The list is distributed under the
22
+ terms stated in the bitcoin/bips repository for BIP-0039.
@@ -0,0 +1,107 @@
1
+ # Sealkeep Threat Model
2
+
3
+ **Scope:** the `vaultline-crypto` archive envelope (format v2) and the client that produces it.
4
+ **Status:** written by the implementing engineers. **Not yet independently audited.** See "Open gates".
5
+
6
+ ## 1. What the system protects
7
+
8
+ Agent transcripts contain prompts, source code, credentials pasted into sessions, and
9
+ business context. The asset is the *plaintext* of an archived transcript, plus the
10
+ secrets that unlock it: the recovery phrase and device private keys.
11
+
12
+ ## 2. Trust assumptions
13
+
14
+ | Party | Trusted for | Not trusted for |
15
+ | --- | --- | --- |
16
+ | Local client process | Plaintext handling, key generation | — |
17
+ | Local OS user account | Reading its own files | — |
18
+ | Control plane | Availability, metadata integrity, access control | Confidentiality of transcripts |
19
+ | Object store (S3/R2/GCS) | Durability | Confidentiality, integrity |
20
+ | Network | Nothing | Confidentiality, integrity |
21
+
22
+ The client is the only component that ever holds plaintext or an archive key.
23
+
24
+ ## 3. Adversaries and outcomes
25
+
26
+ ### 3.1 Malicious or breached control plane
27
+
28
+ Sees: account and device identifiers, archive ids, byte counts, timestamps, ciphertext hashes,
29
+ object keys, and opaque encrypted manifests. Cannot decrypt: it never receives an archive key,
30
+ recovery phrase, or device private key. It *can* deny service and it *can* lie about metadata,
31
+ so the client verifies checksums locally and refuses reclamation on any mismatch.
32
+
33
+ ### 3.2 Stolen object from the bucket
34
+
35
+ An attacker with the ciphertext holds AEAD output under a random 256-bit per-archive key.
36
+ Recovering plaintext requires breaking ChaCha20-Poly1305 or AES-256-GCM, or obtaining a
37
+ recipient secret. Chunk headers leak archive size and chunk count; the source path is in the
38
+ local record, not in the stored object.
39
+
40
+ ### 3.3 Archive tampering
41
+
42
+ Every chunk is authenticated with associated data binding format version, suite, archive id,
43
+ chunk index, total chunk count, chunk plaintext length, and a final-chunk flag. Wrapped keys
44
+ are separately bound to archive id and suite. Consequently these are all detected:
45
+
46
+ | Attack | Detected by | Test |
47
+ | --- | --- | --- |
48
+ | Bit flip in ciphertext or tag | AEAD tag | `detects a flipped bit anywhere in the stored ciphertext` |
49
+ | Chunk reordering | Index in chunk AAD | `rejects a reordered archive even when the headers are rewritten` |
50
+ | Truncation | Final flag plus total chunk count | `rejects truncation of the final chunk` |
51
+ | Splicing chunks between archives | Archive id in chunk AAD | `rejects a chunk spliced in from another archive` |
52
+ | Suite downgrade | Suite in wrap AAD and chunk AAD | `rejects a suite downgrade` |
53
+ | Rewriting a chunk hash to match damage | AEAD tag behind the hash check | `rejects a chunk whose recorded hash was rewritten` |
54
+
55
+ ### 3.4 Compromised local user account
56
+
57
+ Out of scope for confidentiality: an attacker running as the user can read the plaintext
58
+ transcripts directly from the agent's own directories. Sealkeep reduces blast radius by
59
+ keeping the recovery phrase out of files (it is passed by environment to one command), storing
60
+ its own state at mode `600`, and never writing the phrase to logs, queue jobs, or the API.
61
+
62
+ ### 3.5 Lost or stolen device
63
+
64
+ Each device is a separate X25519 recipient. Removing the recipient and running `sealkeep rewrap`
65
+ re-wraps every archive key without that device, without re-encrypting data. Revocation is
66
+ therefore fast, but it is **not retroactive**: an attacker who copied both the ciphertext and the
67
+ device key before revocation retains access to that copy. Control-plane revocation additionally
68
+ blocks new leases.
69
+
70
+ ### 3.6 Replayed or stolen upload lease
71
+
72
+ Leases are short-lived, single-object, and checked by `assertLeaseUsable`, which tests expiry
73
+ before anything else. A stolen lease can at worst write one object the client already intended
74
+ to write; it cannot read, and it cannot choose the object key, which the server allocates.
75
+
76
+ ### 3.7 Malicious MCP prompt or tool invocation
77
+
78
+ The MCP server is local but model-driven. Search returns metadata only. Archive and recover
79
+ require the recovery phrase from the local environment, recover refuses to overwrite by default,
80
+ and native restore refuses any path outside the adapter's own transcript roots.
81
+
82
+ ## 4. Cryptographic design
83
+
84
+ - **Suite:** ChaCha20-Poly1305 (default) or AES-256-GCM, both from OpenSSL via Node. No custom primitive.
85
+ - **Archive key:** 32 random bytes per archive, never reused, zeroized after use where the runtime allows.
86
+ - **Nonces:** 4-byte per-archive random prefix plus a 64-bit big-endian chunk counter. Unique per chunk by construction; unique across archives because the key is fresh per archive.
87
+ - **Phrase recipients:** scrypt (N=2^15, r=8, p=1 by default; recorded per archive so parameters can be raised later), then AEAD key wrap.
88
+ - **Key recipients:** ephemeral-static X25519, HKDF-SHA256 over the shared secret with both public keys as salt, then AEAD key wrap.
89
+ - **Integrity:** per-chunk SHA-256 recorded for cheap corruption detection, AEAD tags for authenticity, whole-plaintext SHA-256 in the manifest checked after decryption.
90
+
91
+ ## 5. Known limitations
92
+
93
+ 1. **Length and timing leak.** Ciphertext size reveals plaintext size to within one chunk; no padding is applied.
94
+ 2. **Metadata in the local record.** Source path, agent, and byte count are stored in cleartext in the local vault directory. Private-metadata mode is not implemented.
95
+ 3. **No forward secrecy for the phrase.** Anyone who learns the recovery phrase can open every archive it was a recipient for, including old ones.
96
+ 4. **Zeroization is best effort.** JavaScript may retain copies of buffers the implementation cannot reach.
97
+ 5. **scrypt parameters are modest** so that a hook-driven flow stays responsive; they are recorded per archive and can be raised without breaking existing archives.
98
+ 6. **No streaming.** Archives are encrypted from an in-memory buffer, which bounds practical archive size to available memory.
99
+
100
+ ## 6. Open gates before an end-to-end-encryption claim
101
+
102
+ - [ ] Independent third-party cryptographic audit of `vaultline-crypto` and this document.
103
+ - [ ] Publication of the package under a public repository with the test vectors.
104
+ - [ ] Review of the key lifecycle by someone outside the implementing team.
105
+
106
+ Until all three are met, Sealkeep documentation must describe the envelope as
107
+ "authenticated encryption implemented against published primitives", not as audited E2EE.
@@ -0,0 +1,12 @@
1
+ import { type CipherGCM, type DecipherGCM } from "node:crypto";
2
+ import { type Suite } from "./format.js";
3
+ /**
4
+ * Both suites take a 12-byte nonce, a 16-byte tag, and plain associated data.
5
+ *
6
+ * `@types/node` groups `chacha20-poly1305` with the CCM ciphers, whose typed
7
+ * `setAAD` demands a plaintext length that ChaCha20-Poly1305 does not use. The
8
+ * literal suite is narrowed here so the right runtime overload is selected, and the
9
+ * result is presented with the GCM-shaped interface both ciphers actually have.
10
+ */
11
+ export declare function aeadCipher(suite: Suite, key: Buffer, nonce: Buffer): CipherGCM;
12
+ export declare function aeadDecipher(suite: Suite, key: Buffer, nonce: Buffer): DecipherGCM;
@@ -0,0 +1,24 @@
1
+ import { createCipheriv, createDecipheriv } from "node:crypto";
2
+ import { TAG_BYTES } from "./format.js";
3
+ /**
4
+ * Both suites take a 12-byte nonce, a 16-byte tag, and plain associated data.
5
+ *
6
+ * `@types/node` groups `chacha20-poly1305` with the CCM ciphers, whose typed
7
+ * `setAAD` demands a plaintext length that ChaCha20-Poly1305 does not use. The
8
+ * literal suite is narrowed here so the right runtime overload is selected, and the
9
+ * result is presented with the GCM-shaped interface both ciphers actually have.
10
+ */
11
+ export function aeadCipher(suite, key, nonce) {
12
+ const options = { authTagLength: TAG_BYTES };
13
+ const cipher = suite === "chacha20-poly1305"
14
+ ? createCipheriv("chacha20-poly1305", key, nonce, options)
15
+ : createCipheriv("aes-256-gcm", key, nonce, options);
16
+ return cipher;
17
+ }
18
+ export function aeadDecipher(suite, key, nonce) {
19
+ const options = { authTagLength: TAG_BYTES };
20
+ const decipher = suite === "chacha20-poly1305"
21
+ ? createDecipheriv("chacha20-poly1305", key, nonce, options)
22
+ : createDecipheriv("aes-256-gcm", key, nonce, options);
23
+ return decipher;
24
+ }
@@ -0,0 +1,39 @@
1
+ import { type Envelope } from "./format.js";
2
+ import { type Unlock } from "./recipients.js";
3
+ /**
4
+ * Random access into a gzip-chunk archive: the primitive behind "fetch the
5
+ * chunks, not the archive".
6
+ *
7
+ * Under gzip-chunk every chunk compresses and encrypts on its own and records
8
+ * the RAW byte range it covers, so a question like "bytes 40–60 MB of that
9
+ * session" resolves to chunk indices and a single stored byte range — without
10
+ * downloading, decrypting, or decompressing anything else. A ranged GET plus
11
+ * this module is a partial restore; a loop over all chunks is a full one, in
12
+ * one chunk of memory.
13
+ */
14
+ export type ChunkWindow = {
15
+ /** First and last chunk index covering the requested raw range. */
16
+ firstIndex: number;
17
+ lastIndex: number;
18
+ /** The byte range of the STORED object holding those chunks — what a ranged GET fetches. */
19
+ storedOffset: number;
20
+ storedLength: number;
21
+ /** Where the requested raw range sits inside the decompressed window. */
22
+ rawStartInWindow: number;
23
+ rawLengthInWindow: number;
24
+ };
25
+ /** True when an envelope supports random access at all. */
26
+ export declare function supportsChunkAccess(envelope: Envelope): boolean;
27
+ /**
28
+ * Maps a raw (original-file) byte range to the chunk window that covers it.
29
+ * Legacy whole-body-gzip archives cannot answer this — a slice of their stream
30
+ * is undecodable alone — and the error says to restore whole instead.
31
+ */
32
+ export declare function chunkWindowForRange(envelope: Envelope, rawStart: number, rawEnd: number): ChunkWindow;
33
+ /**
34
+ * Opens the chunks inside a fetched stored slice and returns exactly the raw
35
+ * bytes the window was asked for. Every chunk is hash-checked and
36
+ * authenticated before a byte of it is trusted — a ranged read keeps the
37
+ * whole-archive guarantees, chunk by chunk.
38
+ */
39
+ export declare function openChunkWindow(envelope: Envelope, window: ChunkWindow, storedSlice: Buffer, unlock: Unlock): Buffer;
@@ -0,0 +1,93 @@
1
+ import { createHash } from "node:crypto";
2
+ import { gunzipSync } from "node:zlib";
3
+ import { aeadDecipher } from "./aead.js";
4
+ import { chunkAad, TAG_BYTES } from "./format.js";
5
+ import { CryptoError, unwrapArchiveKey, zeroize } from "./recipients.js";
6
+ const sha256 = (input) => createHash("sha256").update(input).digest("hex");
7
+ /** True when an envelope supports random access at all. */
8
+ export function supportsChunkAccess(envelope) {
9
+ return envelope.manifest.compression === "gzip-chunk" && envelope.chunks.every((chunk) => typeof chunk.rawBytes === "number");
10
+ }
11
+ /**
12
+ * Maps a raw (original-file) byte range to the chunk window that covers it.
13
+ * Legacy whole-body-gzip archives cannot answer this — a slice of their stream
14
+ * is undecodable alone — and the error says to restore whole instead.
15
+ */
16
+ export function chunkWindowForRange(envelope, rawStart, rawEnd) {
17
+ if (!supportsChunkAccess(envelope)) {
18
+ throw new CryptoError("envelope_malformed", "This archive predates chunk-independent compression; restore it whole instead of by range.");
19
+ }
20
+ const total = envelope.manifest.originalBytes ?? 0;
21
+ if (rawStart < 0 || rawEnd <= rawStart || rawStart >= Math.max(total, 1)) {
22
+ throw new CryptoError("invalid_range", `Range ${rawStart}..${rawEnd} is not a range of this ${total}-byte archive`);
23
+ }
24
+ const end = Math.min(rawEnd, total);
25
+ let rawCursor = 0;
26
+ let storedCursor = 0;
27
+ let firstIndex = -1;
28
+ let lastIndex = -1;
29
+ let storedOffset = 0;
30
+ let storedLength = 0;
31
+ let rawStartInWindow = 0;
32
+ for (const chunk of envelope.chunks) {
33
+ const rawFrom = rawCursor;
34
+ const rawTo = rawCursor + (chunk.rawBytes ?? 0);
35
+ const overlaps = rawTo > rawStart && rawFrom < end;
36
+ if (overlaps && firstIndex === -1) {
37
+ firstIndex = chunk.index;
38
+ storedOffset = storedCursor;
39
+ rawStartInWindow = rawStart - rawFrom;
40
+ }
41
+ if (overlaps) {
42
+ lastIndex = chunk.index;
43
+ storedLength += chunk.storedBytes;
44
+ }
45
+ rawCursor = rawTo;
46
+ storedCursor += chunk.storedBytes;
47
+ }
48
+ return { firstIndex, lastIndex, storedOffset, storedLength, rawStartInWindow, rawLengthInWindow: end - rawStart };
49
+ }
50
+ /**
51
+ * Opens the chunks inside a fetched stored slice and returns exactly the raw
52
+ * bytes the window was asked for. Every chunk is hash-checked and
53
+ * authenticated before a byte of it is trusted — a ranged read keeps the
54
+ * whole-archive guarantees, chunk by chunk.
55
+ */
56
+ export function openChunkWindow(envelope, window, storedSlice, unlock) {
57
+ if (storedSlice.length !== window.storedLength) {
58
+ throw new CryptoError("ciphertext_length_mismatch", `The fetched slice is ${storedSlice.length} bytes; the window needs ${window.storedLength}`);
59
+ }
60
+ const archiveKey = unwrapArchiveKey(envelope.wrappedKeys, envelope.suite, envelope.archiveId, unlock);
61
+ try {
62
+ const parts = [];
63
+ let offset = 0;
64
+ for (let index = window.firstIndex; index <= window.lastIndex; index += 1) {
65
+ const chunk = envelope.chunks[index];
66
+ const stored = storedSlice.subarray(offset, offset + chunk.storedBytes);
67
+ offset += chunk.storedBytes;
68
+ if (sha256(stored) !== chunk.sha256)
69
+ throw new CryptoError("chunk_hash_mismatch", `Chunk ${chunk.index} does not match its recorded hash`);
70
+ const decipher = aeadDecipher(envelope.suite, archiveKey, Buffer.from(chunk.nonce, "base64"));
71
+ decipher.setAAD(chunkAad({ archiveId: envelope.archiveId, suite: envelope.suite, index: chunk.index, totalChunks: envelope.chunks.length, plaintextBytes: chunk.plaintextBytes, final: chunk.final }));
72
+ decipher.setAuthTag(stored.subarray(stored.length - TAG_BYTES));
73
+ let packed;
74
+ try {
75
+ packed = Buffer.concat([decipher.update(stored.subarray(0, stored.length - TAG_BYTES)), decipher.final()]);
76
+ }
77
+ catch {
78
+ throw new CryptoError("chunk_auth_failed", `Chunk ${chunk.index} failed authentication`);
79
+ }
80
+ try {
81
+ parts.push(gunzipSync(packed));
82
+ }
83
+ catch {
84
+ throw new CryptoError("plaintext_hash_mismatch", `Chunk ${chunk.index} failed decompression after authentication`);
85
+ }
86
+ }
87
+ const raw = Buffer.concat(parts);
88
+ return raw.subarray(window.rawStartInWindow, window.rawStartInWindow + window.rawLengthInWindow);
89
+ }
90
+ finally {
91
+ zeroize(archiveKey);
92
+ }
93
+ }
@@ -0,0 +1,71 @@
1
+ import { type KeyObject } from "node:crypto";
2
+ import { type Unlock } from "./recipients.js";
3
+ import { type Envelope, type ScryptParams, type Suite, type WrappedKey } from "./format.js";
4
+ export type Recipient = {
5
+ kind: "phrase";
6
+ phrase: string;
7
+ scrypt?: ScryptParams;
8
+ } | {
9
+ kind: "x25519";
10
+ publicKey: KeyObject | Buffer;
11
+ };
12
+ /** Supplied only by the test-vector generator so a randomized format can have known answers. */
13
+ export type DeterministicSeed = {
14
+ archiveKey: Buffer;
15
+ noncePrefix: Buffer;
16
+ wrapSalts: Buffer[];
17
+ wrapNonces: Buffer[];
18
+ ephemeralPrivateKeys?: KeyObject[];
19
+ };
20
+ export type EncryptOptions = {
21
+ recipients: Recipient[];
22
+ suite?: Suite;
23
+ compression?: "none" | "gzip" | "gzip-chunk";
24
+ chunkBytes?: number;
25
+ archiveId?: string;
26
+ createdAt?: string;
27
+ adapter?: {
28
+ agent: string;
29
+ version: string;
30
+ };
31
+ deterministic?: DeterministicSeed;
32
+ };
33
+ /** Exported so `stream.ts` produces byte-for-byte the same wraps as the buffered seal. */
34
+ export declare function wrapAll(archiveKey: Buffer, recipients: readonly Recipient[], suite: Suite, archiveId: string, seed?: DeterministicSeed): WrappedKey[];
35
+ /**
36
+ * Encrypts one archive.
37
+ *
38
+ * A random per-archive key encrypts the plaintext in chunks; that key is then
39
+ * wrapped separately for each recipient. Adding or removing a recipient therefore
40
+ * never requires re-encrypting the data.
41
+ */
42
+ export declare function encryptArchive(plaintext: Buffer, options: EncryptOptions): {
43
+ envelope: Envelope;
44
+ ciphertext: Buffer;
45
+ };
46
+ /**
47
+ * Structural checks that must pass before any key is used.
48
+ *
49
+ * Takes a length rather than the bytes so the streaming reader, which never
50
+ * holds the ciphertext, is gated by exactly the same rules.
51
+ */
52
+ export declare function validateEnvelope(envelope: Envelope, ciphertextBytes: number): void;
53
+ /**
54
+ * Decrypts an archive after verifying its structure and every stored chunk hash.
55
+ * Every chunk is authenticated against associated data that pins its position and
56
+ * the total chunk count, so a reordered or truncated archive fails rather than
57
+ * returning partial plaintext.
58
+ */
59
+ export declare function decryptArchive(envelope: Envelope, ciphertext: Buffer, unlock: Unlock): Buffer;
60
+ /**
61
+ * Rotates the recipient set without touching ciphertext. `replace` is how a lost
62
+ * device is cut off; `add` is how a new device or extra recovery key is granted.
63
+ */
64
+ export declare function rewrapArchive(envelope: Envelope, unlock: Unlock, recipients: readonly Recipient[], options?: {
65
+ mode?: "replace" | "add";
66
+ }): Envelope;
67
+ /** Recipient identifiers present on an archive. Useful for auditing who can open it. */
68
+ export declare function recipientIds(envelope: Envelope): {
69
+ phrase: string[];
70
+ keys: string[];
71
+ };