okengine 0.10.3 → 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 (318) hide show
  1. package/package.json +76 -10
  2. package/site/content/docs/deployment/docker-swarm.mdx +48 -111
  3. package/site/content/docs/deployment/docker.mdx +43 -36
  4. package/site/content/docs/deployment/index.mdx +9 -9
  5. package/site/content/docs/deployment/kubernetes.mdx +4 -4
  6. package/site/content/docs/elements/ai.mdx +7 -7
  7. package/site/content/docs/elements/channel.mdx +9 -7
  8. package/site/content/docs/elements/clock.mdx +8 -8
  9. package/site/content/docs/elements/flow.mdx +46 -47
  10. package/site/content/docs/elements/gate.mdx +6 -5
  11. package/site/content/docs/elements/signal.mdx +2 -2
  12. package/site/content/docs/elements/store.mdx +71 -78
  13. package/site/content/docs/elements/vault.mdx +89 -70
  14. package/site/content/docs/get-started/basic-usage.mdx +8 -8
  15. package/site/content/docs/get-started/installation.mdx +36 -36
  16. package/site/content/docs/get-started/introduction.mdx +11 -0
  17. package/site/content/docs/get-started/why.mdx +2 -2
  18. package/site/content/docs/plugins/anonymous.mdx +1 -1
  19. package/site/content/docs/plugins/magic-link.mdx +11 -11
  20. package/site/content/docs/plugins/otp.mdx +2 -2
  21. package/site/content/docs/plugins/passkey.mdx +1 -1
  22. package/site/content/docs/plugins/two-factor.mdx +1 -1
  23. package/site/content/docs/plugins/username.mdx +1 -1
  24. package/site/content/docs/recipes/caddy.mdx +1 -1
  25. package/site/content/docs/recipes/cockroachdb.mdx +2 -2
  26. package/site/content/docs/recipes/dragonfly.mdx +1 -1
  27. package/site/content/docs/recipes/index.mdx +1 -2
  28. package/site/content/docs/recipes/llama-cpp.mdx +21 -23
  29. package/site/content/docs/recipes/mailpit.mdx +11 -11
  30. package/site/content/docs/recipes/meilisearch.mdx +10 -10
  31. package/site/content/docs/recipes/meta.json +0 -1
  32. package/site/content/docs/recipes/ollama.mdx +1 -1
  33. package/site/content/docs/recipes/pgdog.mdx +12 -9
  34. package/site/content/docs/recipes/postgres.mdx +3 -3
  35. package/site/content/docs/recipes/redis.mdx +2 -2
  36. package/site/content/docs/recipes/rustfs.mdx +10 -7
  37. package/site/content/docs/recipes/sglang.mdx +1 -1
  38. package/site/content/docs/recipes/supabase-docker.mdx +1 -1
  39. package/site/content/docs/recipes/timescale.mdx +2 -2
  40. package/site/content/docs/recipes/valkey.mdx +1 -1
  41. package/site/content/docs/recipes/vllm.mdx +1 -1
  42. package/site/content/docs/recipes/yugabytedb.mdx +2 -2
  43. package/site/content/docs/reference/cli.md +61 -38
  44. package/site/content/docs/reference/configuration.mdx +145 -70
  45. package/site/content/docs/reference/environment-variables.mdx +36 -28
  46. package/site/content/docs/reference/errors.mdx +28 -29
  47. package/site/content/docs/reference/fx.mdx +11 -4
  48. package/site/content/docs/reference/i18n.mdx +17 -6
  49. package/site/content/docs/reference/index.mdx +5 -0
  50. package/site/content/docs/reference/meta.json +1 -0
  51. package/site/content/docs/reference/migrating-environments.mdx +158 -0
  52. package/site/content/docs/reference/plugins.mdx +2 -2
  53. package/src/auth/gate-auth.test.ts +12 -10
  54. package/src/cli/ai-setup/ai-setup.test.ts +27 -15
  55. package/src/cli/ai-setup/apply.ts +111 -15
  56. package/src/cli/ai-setup/catalog.ts +3 -3
  57. package/src/cli/ai-setup/index.ts +1 -1
  58. package/src/cli/ai-setup/prompts.ts +1 -1
  59. package/src/cli/ai-setup/recommend.test.ts +1 -1
  60. package/src/cli/ask-seed.test.ts +3 -3
  61. package/src/cli/build.ts +1 -1
  62. package/src/cli/db-auto-push.test.ts +6 -3
  63. package/src/cli/db-auto-push.ts +7 -5
  64. package/src/cli/db-seed.ts +7 -8
  65. package/src/cli/db.test.ts +70 -71
  66. package/src/cli/db.ts +86 -12
  67. package/src/cli/dev-controller.test.ts +35 -0
  68. package/src/cli/dev-controller.ts +298 -0
  69. package/src/cli/dev-controls.ts +11 -65
  70. package/src/cli/dev-db-push.test.ts +47 -15
  71. package/src/cli/dev-schema-sync.test.ts +11 -11
  72. package/src/cli/dev-schema-sync.ts +10 -10
  73. package/src/cli/dev-session-lock.test.ts +106 -0
  74. package/src/cli/dev-session-lock.ts +209 -0
  75. package/src/cli/dev.test.ts +166 -121
  76. package/src/cli/dev.ts +138 -192
  77. package/src/cli/doc-staleness.test.ts +3 -6
  78. package/src/cli/docker-clean.test.ts +33 -1
  79. package/src/cli/docker-clean.ts +28 -33
  80. package/src/cli/docker-cli.test.ts +12 -11
  81. package/src/cli/docker.ts +95 -20
  82. package/src/cli/doctor.test.ts +40 -0
  83. package/src/cli/doctor.ts +55 -15
  84. package/src/cli/drizzle-env.test.ts +11 -13
  85. package/src/cli/drizzle-env.ts +18 -11
  86. package/src/cli/ensure-drizzle-config.ts +11 -14
  87. package/src/cli/hero-meta.test.ts +15 -15
  88. package/src/cli/hero-meta.ts +10 -9
  89. package/src/cli/index.ts +36 -6
  90. package/src/cli/load-config.images.test.ts +16 -12
  91. package/src/cli/load-config.source.test.ts +50 -0
  92. package/src/cli/load-config.ts +34 -28
  93. package/src/cli/meilisearch-local.ts +1 -2
  94. package/src/cli/mode.ts +7 -58
  95. package/src/cli/registry.ts +175 -19
  96. package/src/cli/resolve-dev-sql-env.test.ts +8 -16
  97. package/src/cli/resolve-dev-sql-env.ts +13 -15
  98. package/src/cli/safe-defaults.test.ts +28 -10
  99. package/src/cli/schema.ts +65 -16
  100. package/src/cli/test.ts +58 -0
  101. package/src/cli/tui/App.tsx +252 -0
  102. package/src/cli/tui/DevLive.tsx +116 -0
  103. package/src/cli/tui/components/ConfirmDialog.tsx +48 -0
  104. package/src/cli/tui/components/HeaderBar.tsx +67 -0
  105. package/src/cli/tui/components/LogStream.tsx +47 -0
  106. package/src/cli/tui/components/PanelCard.tsx +39 -0
  107. package/src/cli/tui/components/SelectRow.tsx +37 -0
  108. package/src/cli/tui/components/SlashPalette.tsx +172 -0
  109. package/src/cli/tui/components/Spinner.tsx +32 -0
  110. package/src/cli/tui/components/StatusBar.tsx +51 -0
  111. package/src/cli/tui/components/StatusDot.tsx +37 -0
  112. package/src/cli/tui/components/TabBar.tsx +49 -0
  113. package/src/cli/tui/components/Wordmark.tsx +43 -0
  114. package/src/cli/tui/format.ts +38 -0
  115. package/src/cli/tui/keys.test.ts +18 -0
  116. package/src/cli/tui/keys.ts +26 -0
  117. package/src/cli/tui/launch.test.ts +18 -0
  118. package/src/cli/tui/launch.ts +57 -0
  119. package/src/cli/tui/panels/Dashboard.tsx +201 -0
  120. package/src/cli/tui/panels/Database.tsx +198 -0
  121. package/src/cli/tui/panels/Dev.tsx +257 -0
  122. package/src/cli/tui/panels/Docker.tsx +357 -0
  123. package/src/cli/tui/panels/Navigator.test.ts +22 -0
  124. package/src/cli/tui/panels/Navigator.tsx +213 -0
  125. package/src/cli/tui/slash-catalog.test.ts +31 -0
  126. package/src/cli/tui/slash-catalog.ts +184 -0
  127. package/src/cli/tui/slash-run.ts +54 -0
  128. package/src/cli/tui/state/ports.ts +95 -0
  129. package/src/cli/tui/theme.ts +24 -0
  130. package/src/cli/vault-cmd.test.ts +402 -0
  131. package/src/cli/vault-cmd.ts +727 -69
  132. package/src/cli/vault-secure-input.test.ts +56 -0
  133. package/src/cli/vault-secure-input.ts +124 -0
  134. package/src/compiler/effects-infer.ts +7 -0
  135. package/src/compiler/fixtures/skyport/oke.config.ts +3 -3
  136. package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +1 -1
  137. package/src/compiler/fixtures/skyport.expected.json +1 -1
  138. package/src/compiler-entry.ts +7 -0
  139. package/src/config/define-config.test.ts +111 -47
  140. package/src/config/driver-defaults.test.ts +16 -23
  141. package/src/config/driver-defaults.ts +33 -63
  142. package/src/config/index.ts +169 -144
  143. package/src/config/resolve-driver.test.ts +14 -19
  144. package/src/console/server/app.ts +4 -2
  145. package/src/console/server/console.test.ts +58 -1
  146. package/src/console/server/flows.ts +22 -2
  147. package/src/console/server/index.ts +3 -0
  148. package/src/console/server/operator-db.test.ts +55 -13
  149. package/src/console/server/operator-db.ts +305 -263
  150. package/src/console/server/serve.ts +16 -7
  151. package/src/console/server/state.ts +31 -9
  152. package/src/console/server/vault.test.ts +120 -5
  153. package/src/console/server/vault.ts +208 -12
  154. package/src/console/ui/dist/assets/{index-D-9KFce5.js → index--Y0Uaz-m.js} +2 -2
  155. package/src/console/ui/dist/assets/panel-vault-BkZdJPX8.js +1 -0
  156. package/src/console/ui/dist/assets/style-C3L349iZ.css +3 -0
  157. package/src/console/ui/dist/index.html +2 -2
  158. package/src/console/ui/shell/client.ts +16 -0
  159. package/src/console/ui/shell/panels/vault/VaultPanel.tsx +54 -1
  160. package/src/console/ui/vault/VaultA11yView.tsx +33 -1
  161. package/src/console/ui/vault/backend.test.ts +75 -0
  162. package/src/console/ui/vault/backend.ts +117 -0
  163. package/src/console/ui/vault/fixture.ts +21 -2
  164. package/src/console/ui/vault/index.ts +11 -1
  165. package/src/console/ui/vault/types.ts +26 -0
  166. package/src/docker/compose.ts +164 -61
  167. package/src/docker/derive.ts +106 -12
  168. package/src/docker/docker.test.ts +156 -110
  169. package/src/docker/helpers.ts +98 -0
  170. package/src/docker/images-config.test.ts +3 -8
  171. package/src/docker/index.ts +30 -5
  172. package/src/docker/recipes/index.ts +0 -3
  173. package/src/docker/recipes/llama-cpp.ts +4 -4
  174. package/src/docker/recipes/pgdog.ts +9 -2
  175. package/src/docker/resources.test.ts +71 -0
  176. package/src/docker/resources.ts +176 -0
  177. package/src/docker/stack-id.test.ts +11 -4
  178. package/src/docker/stack-id.ts +42 -32
  179. package/src/docker/stack.integration.test.ts +4 -15
  180. package/src/docker/types.ts +35 -3
  181. package/src/drivers/ai-ollama.integration.test.ts +2 -2
  182. package/src/drivers/conformance.test.ts +11 -12
  183. package/src/drivers/drizzle-dialect.test.ts +0 -4
  184. package/src/drivers/drizzle-dialect.ts +7 -13
  185. package/src/drivers/index.ts +24 -7
  186. package/src/drivers/journal-postgres.test.ts +3 -1
  187. package/src/drivers/journal-postgres.ts +4 -2
  188. package/src/drivers/meilisearch.ts +1 -1
  189. package/src/drivers/pglite.ts +28 -5
  190. package/src/drivers/types.ts +9 -11
  191. package/src/drivers/vault-aws-secrets-manager.test.ts +159 -0
  192. package/src/drivers/vault-aws-secrets-manager.ts +333 -0
  193. package/src/drivers/vault-builtin.test.ts +110 -0
  194. package/src/drivers/vault-builtin.ts +184 -0
  195. package/src/drivers/vault-driver-removal.test.ts +13 -0
  196. package/src/drivers/vault-managed.test.ts +95 -0
  197. package/src/drivers/vault-managed.ts +101 -25
  198. package/src/drivers/vault-types.ts +25 -5
  199. package/src/elements/ai/runtime.ts +1 -1
  200. package/src/elements/clock/chaos.test.ts +4 -1
  201. package/src/elements/clock/durable.ts +57 -4
  202. package/src/elements/clock.test.ts +13 -14
  203. package/src/elements/gate/constants.ts +18 -0
  204. package/src/elements/gate.test.ts +1 -1
  205. package/src/elements/gate.ts +2 -2
  206. package/src/elements/store/domain-ddl.test.ts +5 -12
  207. package/src/elements/store/emit-drizzle.ts +44 -19
  208. package/src/elements/store/index-boot.test.ts +15 -116
  209. package/src/elements/store/load-plugin-tables.ts +1 -1
  210. package/src/elements/store/resource-list-docs.fixture.ts +12 -6
  211. package/src/elements/store/resource-list-docs.test.ts +64 -44
  212. package/src/elements/store/resource.test.ts +17 -10
  213. package/src/elements/store/runtime.ts +9 -12
  214. package/src/elements/store/schema-decl.test.ts +16 -32
  215. package/src/elements/store/seed.test.ts +1 -2
  216. package/src/elements/store/seed.ts +7 -8
  217. package/src/elements/store/sql-condition.test.ts +2 -2
  218. package/src/elements/store/sql-session.test.ts +8 -10
  219. package/src/elements/store/sql-session.ts +10 -2
  220. package/src/elements/store.test.ts +13 -13
  221. package/src/elements/vault/audit.ts +233 -0
  222. package/src/elements/vault/boot-chain.test.ts +78 -0
  223. package/src/elements/vault/boot-chain.ts +43 -39
  224. package/src/elements/vault/builtin-adapter.test.ts +479 -0
  225. package/src/elements/vault/builtin-adapter.ts +1184 -0
  226. package/src/elements/vault/crypto.test.ts +237 -0
  227. package/src/elements/vault/crypto.ts +431 -0
  228. package/src/elements/vault/declare.ts +164 -3
  229. package/src/elements/vault/errors.ts +56 -0
  230. package/src/elements/vault/kms-unseal.test.ts +76 -0
  231. package/src/elements/vault/kms-unseal.ts +181 -0
  232. package/src/elements/vault/path.ts +90 -0
  233. package/src/elements/vault/resilience.test.ts +102 -0
  234. package/src/elements/vault/resilience.ts +107 -0
  235. package/src/elements/vault/runtime.ts +36 -4
  236. package/src/elements/vault/security-checklist.test.ts +130 -0
  237. package/src/elements/vault/storage.ts +448 -0
  238. package/src/elements/vault/types.ts +288 -0
  239. package/src/elements/vault/unseal.ts +150 -0
  240. package/src/elements/vault.test.ts +72 -4
  241. package/src/elements/vault.ts +91 -2
  242. package/src/full.ts +248 -0
  243. package/src/http.ts +41 -0
  244. package/src/i18n/interpolate-simple.ts +37 -0
  245. package/src/i18n/messages.ts +51 -4
  246. package/src/i18n-entry.ts +7 -0
  247. package/src/index.ts +35 -145
  248. package/src/journal-entry.ts +20 -0
  249. package/src/kernel/adopt-barrel-fresh.test.ts +25 -10
  250. package/src/kernel/app.ts +40 -42
  251. package/src/kernel/auto-registry.test.ts +2 -2
  252. package/src/kernel/boot-bind/ai.test.ts +5 -5
  253. package/src/kernel/boot-bind/ai.ts +2 -1
  254. package/src/kernel/boot-bind/channel.test.ts +6 -6
  255. package/src/kernel/boot-bind/channel.ts +3 -10
  256. package/src/kernel/boot-bind/clock.ts +2 -2
  257. package/src/kernel/boot-bind/gate.ts +5 -9
  258. package/src/kernel/boot-bind/honor-config.test.ts +42 -42
  259. package/src/kernel/boot-bind/journal.ts +2 -2
  260. package/src/kernel/boot-bind/store.test.ts +43 -44
  261. package/src/kernel/boot-bind/store.ts +16 -41
  262. package/src/kernel/boot-bind/vault.ts +16 -1
  263. package/src/kernel/boot.test.ts +12 -4
  264. package/src/kernel/boot.ts +31 -19
  265. package/src/kernel/budget-entry.ts +1 -1
  266. package/src/kernel/compensate.test.ts +288 -1
  267. package/src/kernel/compensate.ts +126 -0
  268. package/src/kernel/concurrency.test.ts +10 -9
  269. package/src/kernel/effects-stamping.test.ts +27 -2
  270. package/src/kernel/element-registries.ts +5 -3
  271. package/src/kernel/flow.ts +4 -2
  272. package/src/kernel/fx.test.ts +119 -3
  273. package/src/kernel/fx.ts +215 -40
  274. package/src/kernel/index.ts +14 -0
  275. package/src/kernel/journal.ts +130 -7
  276. package/src/kernel/pipeline.test.ts +20 -6
  277. package/src/kernel/redacted.test.ts +35 -0
  278. package/src/kernel/redacted.ts +24 -2
  279. package/src/kernel/router/create-default.ts +16 -0
  280. package/src/kernel/router/create-edge.ts +18 -0
  281. package/src/kernel/router/create.ts +23 -0
  282. package/src/kernel/router/index.ts +23 -0
  283. package/src/kernel/router/linear.ts +88 -0
  284. package/src/kernel/router/regexp.ts +188 -0
  285. package/src/kernel/router/smart.ts +120 -0
  286. package/src/kernel/router/trie.ts +108 -0
  287. package/src/kernel/router/types.ts +56 -0
  288. package/src/kernel/router.ts +18 -532
  289. package/src/kernel-entry.ts +43 -0
  290. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  291. package/src/manifest/fixtures/skyport.manifest.json +1 -1
  292. package/src/plugins/config-source.test.ts +7 -7
  293. package/src/release/build-lib.ts +58 -0
  294. package/src/release/exports.test.ts +4 -2
  295. package/src/release/exports.ts +30 -4
  296. package/src/release/measure.ts +101 -22
  297. package/src/runs/duckdb.ts +7 -2
  298. package/src/shared/optional-peer.ts +41 -0
  299. package/src/term.test.ts +3 -44
  300. package/src/test/create-test-app.ts +12 -0
  301. package/src/test/provisions.integration.test.ts +1 -1
  302. package/src/test/reset-element-registries.ts +2 -0
  303. package/src/upgrade/codemods.test.ts +7 -20
  304. package/src/upgrade/codemods.ts +93 -60
  305. package/site/content/docs/recipes/openbao.mdx +0 -141
  306. package/src/cli/ask-dev-mode.ts +0 -40
  307. package/src/cli/dev-mode.test.ts +0 -45
  308. package/src/cli/dev-mode.ts +0 -78
  309. package/src/cli/openbao-bootstrap.test.ts +0 -150
  310. package/src/cli/openbao-bootstrap.ts +0 -321
  311. package/src/cli/openbao-restart.integration.test.ts +0 -145
  312. package/src/console/ui/dist/assets/panel-vault-Za1GyJfM.js +0 -1
  313. package/src/console/ui/dist/assets/style-C8MxEWPd.css +0 -3
  314. package/src/docker/recipes/openbao.ts +0 -47
  315. package/src/drivers/libsql.ts +0 -179
  316. package/src/drivers/sqlite.ts +0 -44
  317. package/src/drivers/vault-openbao.test.ts +0 -97
  318. package/src/drivers/vault-openbao.ts +0 -143
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Vault crypto — envelope round-trip, AAD binding, path safety, seal physics.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import {
7
+ buildAad,
8
+ constantTimeEqualBytes,
9
+ decryptSecret,
10
+ deriveBackupKek,
11
+ deriveKek,
12
+ deriveVerifyHash,
13
+ encryptSecret,
14
+ generateDek,
15
+ generateMasterKey,
16
+ importAesKey,
17
+ masterKeyFromBase64,
18
+ masterKeyToBase64,
19
+ unwrapDek,
20
+ wrapDek,
21
+ zeroBytes,
22
+ ALGORITHM,
23
+ GCM_IV_BYTES,
24
+ GCM_TAG_BYTES,
25
+ MASTER_KEY_BYTES,
26
+ } from "./crypto.ts";
27
+ import { canonicalizePath } from "./path.ts";
28
+ import { isVaultError, VaultError } from "./errors.ts";
29
+ import { createEnvUnsealer, createMemoryUnsealer } from "./unseal.ts";
30
+
31
+ const PATH = "prod/api/stripe";
32
+ const VALUE = "sk_live_do_not_log_me";
33
+
34
+ describe("vault crypto envelope", () => {
35
+ test("encrypt/decrypt round-trips a secret", async () => {
36
+ const dek = await importAesKey(generateDek());
37
+ const aad = buildAad(PATH, 1, ALGORITHM, 1);
38
+
39
+ const sealed = await encryptSecret(dek, VALUE, aad);
40
+ expect(sealed.iv).toHaveLength(GCM_IV_BYTES);
41
+ expect(sealed.tag).toHaveLength(GCM_TAG_BYTES);
42
+ expect(Buffer.from(sealed.ciphertext).toString("utf8")).not.toContain("sk_live");
43
+
44
+ expect(await decryptSecret(dek, sealed, aad)).toBe(VALUE);
45
+ });
46
+
47
+ test("a different DEK cannot decrypt", async () => {
48
+ const dek = await importAesKey(generateDek());
49
+ const other = await importAesKey(generateDek());
50
+ const aad = buildAad(PATH, 1, ALGORITHM, 1);
51
+ const sealed = await encryptSecret(dek, VALUE, aad);
52
+
53
+ await expect(decryptSecret(other, sealed, aad)).rejects.toThrow(VaultError);
54
+ const failure = await decryptSecret(other, sealed, aad).catch((e: unknown) => e);
55
+ expect(isVaultError(failure, "INVALID_KEY")).toBe(true);
56
+ });
57
+
58
+ test("AAD tampering fails — path, version, and kek generation are bound", async () => {
59
+ const dek = await importAesKey(generateDek());
60
+ const aad = buildAad(PATH, 1, ALGORITHM, 1);
61
+ const sealed = await encryptSecret(dek, VALUE, aad);
62
+
63
+ await expect(
64
+ decryptSecret(dek, sealed, buildAad("prod/api/other", 1, ALGORITHM, 1)),
65
+ ).rejects.toThrow(VaultError);
66
+ await expect(decryptSecret(dek, sealed, buildAad(PATH, 2, ALGORITHM, 1))).rejects.toThrow(
67
+ VaultError,
68
+ );
69
+ await expect(decryptSecret(dek, sealed, buildAad(PATH, 1, ALGORITHM, 2))).rejects.toThrow(
70
+ VaultError,
71
+ );
72
+ });
73
+
74
+ test("ciphertext tampering fails", async () => {
75
+ const dek = await importAesKey(generateDek());
76
+ const aad = buildAad(PATH, 1, ALGORITHM, 1);
77
+ const sealed = await encryptSecret(dek, VALUE, aad);
78
+ const flipped = Uint8Array.from(sealed.ciphertext);
79
+ flipped[0] = (flipped[0] ?? 0) ^ 0xff;
80
+
81
+ await expect(decryptSecret(dek, { ...sealed, ciphertext: flipped }, aad)).rejects.toThrow(
82
+ VaultError,
83
+ );
84
+ });
85
+
86
+ test("DEK wrap/unwrap round-trips under the KEK", async () => {
87
+ const masterKey = generateMasterKey();
88
+ const kek = await deriveKek(masterKey);
89
+ const dek = generateDek();
90
+ const aad = buildAad(PATH, 1, ALGORITHM, 1);
91
+
92
+ const wrapped = await wrapDek(kek, dek, aad);
93
+ const unwrapped = await unwrapDek(kek, wrapped, aad);
94
+ expect(constantTimeEqualBytes(unwrapped, dek)).toBe(true);
95
+
96
+ const otherKek = await deriveKek(generateMasterKey());
97
+ await expect(unwrapDek(otherKek, wrapped, aad)).rejects.toThrow(VaultError);
98
+ });
99
+
100
+ test("kek and backup kek are domain-separated", async () => {
101
+ const masterKey = generateMasterKey();
102
+ const kek = await deriveKek(masterKey);
103
+ const backupKek = await deriveBackupKek(masterKey);
104
+ const aad = buildAad(PATH, 1, ALGORITHM, 1);
105
+ const wrapped = await wrapDek(kek, generateDek(), aad);
106
+
107
+ await expect(unwrapDek(backupKek, wrapped, aad)).rejects.toThrow(VaultError);
108
+ });
109
+
110
+ test("verify hash is stable per key and differs across keys", async () => {
111
+ const masterKey = generateMasterKey();
112
+ const hash = await deriveVerifyHash(masterKey);
113
+
114
+ expect(hash).toMatch(/^[0-9a-f]{64}$/);
115
+ expect(await deriveVerifyHash(masterKey)).toBe(hash);
116
+ expect(await deriveVerifyHash(generateMasterKey())).not.toBe(hash);
117
+ expect(hash).not.toBe(Buffer.from(masterKey).toString("hex"));
118
+ });
119
+
120
+ test("master key base64 helpers round-trip and reject short keys", () => {
121
+ const masterKey = generateMasterKey();
122
+ const encoded = masterKeyToBase64(masterKey);
123
+ expect(constantTimeEqualBytes(masterKeyFromBase64(encoded), masterKey)).toBe(true);
124
+ expect(() => masterKeyFromBase64("dG9vLXNob3J0")).toThrow(VaultError);
125
+ expect(() => masterKeyFromBase64("")).toThrow(VaultError);
126
+ });
127
+
128
+ test("constantTimeEqualBytes and zeroBytes", () => {
129
+ const a = Uint8Array.from([1, 2, 3, 4]);
130
+ const b = Uint8Array.from([1, 2, 3, 4]);
131
+ const c = Uint8Array.from([1, 2, 3, 5]);
132
+ expect(constantTimeEqualBytes(a, b)).toBe(true);
133
+ expect(constantTimeEqualBytes(a, c)).toBe(false);
134
+ expect(constantTimeEqualBytes(a, Uint8Array.from([1, 2, 3]))).toBe(false);
135
+
136
+ zeroBytes(a);
137
+ expect([...a]).toEqual([0, 0, 0, 0]);
138
+ });
139
+ });
140
+
141
+ describe("vault path canonicalization", () => {
142
+ test("normalizes to a slash-joined key with no leading slash", () => {
143
+ expect(canonicalizePath("prod/api/stripe")).toBe("prod/api/stripe");
144
+ expect(canonicalizePath(" /prod//api///stripe/ ")).toBe("prod/api/stripe");
145
+ });
146
+
147
+ test("rejects traversal, NUL bytes, backslashes, and empties", () => {
148
+ for (const bad of ["", " ", "/", "prod/../root", "..", "prod/./api", "prod\\api"]) {
149
+ expect(() => canonicalizePath(bad)).toThrow(VaultError);
150
+ }
151
+ expect(() => canonicalizePath("prod/api\0/stripe")).toThrow(VaultError);
152
+ const failure = ((): unknown => {
153
+ try {
154
+ canonicalizePath("prod/../etc");
155
+ } catch (e: unknown) {
156
+ return e;
157
+ }
158
+ return undefined;
159
+ })();
160
+ expect(isVaultError(failure, "INVALID_PATH")).toBe(true);
161
+ });
162
+
163
+ test("rejects paths longer than 512 characters", () => {
164
+ expect(() => canonicalizePath("a".repeat(513))).toThrow(VaultError);
165
+ expect(canonicalizePath("a".repeat(512))).toHaveLength(512);
166
+ });
167
+ });
168
+
169
+ describe("vault unsealer", () => {
170
+ test("derives keys until sealed, then zeroes its master key", async () => {
171
+ const masterKey = generateMasterKey();
172
+ const unsealer = createEnvUnsealer(masterKey);
173
+
174
+ expect(unsealer.sealed).toBe(false);
175
+ expect(await unsealer.verifyHash()).toBe(await deriveVerifyHash(masterKey));
176
+
177
+ const kek = await unsealer.unwrapKek();
178
+ const aad = buildAad(PATH, 1, ALGORITHM, 1);
179
+ const wrapped = await wrapDek(kek, generateDek(), aad);
180
+
181
+ unsealer.seal();
182
+ expect(unsealer.sealed).toBe(true);
183
+ await expect(unsealer.unwrapKek()).rejects.toThrow(VaultError);
184
+ await expect(unsealer.unwrapBackupKek()).rejects.toThrow(VaultError);
185
+ await expect(unsealer.verifyHash()).rejects.toThrow(VaultError);
186
+
187
+ const failure = await unsealer.unwrapKek().catch((e: unknown) => e);
188
+ expect(isVaultError(failure, "SEALED")).toBe(true);
189
+
190
+ // A KEK derived from an all-zero key must not unwrap live material.
191
+ const zeroKek = await deriveKek(new Uint8Array(MASTER_KEY_BYTES));
192
+ await expect(unwrapDek(zeroKek, wrapped, aad)).rejects.toThrow(VaultError);
193
+ });
194
+
195
+ test("seal is idempotent and copies the caller's buffer", async () => {
196
+ const masterKey = generateMasterKey();
197
+ const unsealer = createMemoryUnsealer(masterKey);
198
+ zeroBytes(masterKey);
199
+
200
+ // Zeroing the caller's buffer must not affect the unsealer's copy.
201
+ expect(await unsealer.verifyHash()).not.toBe(
202
+ await deriveVerifyHash(new Uint8Array(MASTER_KEY_BYTES)),
203
+ );
204
+
205
+ unsealer.seal();
206
+ unsealer.seal();
207
+ expect(unsealer.sealed).toBe(true);
208
+ });
209
+
210
+ test("rejects a master key of the wrong length", () => {
211
+ expect(() => createMemoryUnsealer(new Uint8Array(16))).toThrow(VaultError);
212
+ });
213
+ });
214
+
215
+ describe("VaultError", () => {
216
+ test("carries a code and never a cause", () => {
217
+ const error = new VaultError("SEALED", "vault: sealed");
218
+ expect(error.code).toBe("SEALED");
219
+ expect(error.name).toBe("VaultError");
220
+ expect(error).toBeInstanceOf(Error);
221
+ expect("cause" in error && error.cause !== undefined).toBe(false);
222
+ });
223
+
224
+ test("decryption failures do not attach the underlying crypto error", async () => {
225
+ const dek = await importAesKey(generateDek());
226
+ const other = await importAesKey(generateDek());
227
+ const aad = buildAad(PATH, 1, ALGORITHM, 1);
228
+ const sealed = await encryptSecret(dek, VALUE, aad);
229
+
230
+ const failure = (await decryptSecret(other, sealed, aad).catch(
231
+ (e: unknown) => e,
232
+ )) as VaultError;
233
+ expect(isVaultError(failure, "INVALID_KEY")).toBe(true);
234
+ expect(failure.cause).toBeUndefined();
235
+ expect(failure.message).not.toContain(VALUE);
236
+ });
237
+ });
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Vault cryptography — Web Crypto only, no Node built-ins.
3
+ *
4
+ * Key hierarchy:
5
+ *
6
+ * ```text
7
+ * master key (32 bytes, never stored in cleartext)
8
+ * ├─ HKDF "oke-vault-verify-v1" → verify hash (detects a wrong key)
9
+ * ├─ HKDF "oke-vault-kek-v1" → KEK (wraps per-secret DEKs)
10
+ * └─ HKDF "oke-vault-backup-kek-v1" → backup KEK (wraps export bundles)
11
+ * ```
12
+ *
13
+ * Every secret gets its own random DEK. The DEK encrypts the value with
14
+ * AES-256-GCM; the KEK wraps the DEK with AES-256-GCM. Both bind the same
15
+ * AAD ({@link buildAad}) so a ciphertext cannot be replayed under a
16
+ * different path, version, algorithm, or KEK generation.
17
+ *
18
+ * Storage format: `iv` and `tag` are stored **separately** from
19
+ * `ciphertext`. Web Crypto returns `ciphertext || tag` as one buffer, so
20
+ * {@link encryptBytes} splits the trailing {@link GCM_TAG_BYTES} into `tag`
21
+ * and {@link decryptBytes} re-joins them before calling `subtle.decrypt`.
22
+ *
23
+ * @see src/auth/otp-seal.ts for the same HKDF-then-AES-GCM shape.
24
+ */
25
+
26
+ import { VaultError } from "./errors.ts";
27
+ import type { VaultAlgorithm } from "./types.ts";
28
+
29
+ /** Content-encryption algorithm persisted with every row. */
30
+ export const ALGORITHM: VaultAlgorithm = "aes-256-gcm";
31
+
32
+ /** HKDF `info` for the key-verification hash — bump on format change. */
33
+ export const HKDF_INFO_VERIFY = "oke-vault-verify-v1";
34
+
35
+ /** HKDF `info` for the key-encryption key — bump on format change. */
36
+ export const HKDF_INFO_KEK = "oke-vault-kek-v1";
37
+
38
+ /** HKDF `info` for the backup/export key-encryption key. */
39
+ export const HKDF_INFO_BACKUP_KEK = "oke-vault-backup-kek-v1";
40
+
41
+ /** Master key size in bytes (AES-256 keying material). */
42
+ export const MASTER_KEY_BYTES = 32;
43
+
44
+ /** Per-secret data-encryption key size in bytes. */
45
+ export const DEK_BYTES = 32;
46
+
47
+ /** AES-GCM nonce size in bytes (96-bit, the only NIST-recommended size). */
48
+ export const GCM_IV_BYTES = 12;
49
+
50
+ /** AES-GCM authentication tag size in bytes. */
51
+ export const GCM_TAG_BYTES = 16;
52
+
53
+ const encoder = new TextEncoder();
54
+ const decoder = new TextDecoder();
55
+ const EMPTY_SALT = new Uint8Array(0);
56
+
57
+ /** AES-GCM output split for storage: `iv`, `ciphertext`, `tag`. */
58
+ export interface SealedBytes {
59
+ /** Random 12-byte nonce. Unique per encryption. */
60
+ readonly iv: Uint8Array;
61
+ /** Ciphertext without the trailing authentication tag. */
62
+ readonly ciphertext: Uint8Array;
63
+ /** 16-byte GCM authentication tag. */
64
+ readonly tag: Uint8Array;
65
+ }
66
+
67
+ /**
68
+ * Generate a fresh 32-byte master key.
69
+ *
70
+ * The caller owns the bytes: persist them out of band, then
71
+ * {@link zeroBytes} the buffer.
72
+ */
73
+ export function generateMasterKey(): Uint8Array {
74
+ return crypto.getRandomValues(new Uint8Array(MASTER_KEY_BYTES));
75
+ }
76
+
77
+ /** Generate a fresh 32-byte per-secret data-encryption key. */
78
+ export function generateDek(): Uint8Array {
79
+ return crypto.getRandomValues(new Uint8Array(DEK_BYTES));
80
+ }
81
+
82
+ /**
83
+ * Derive the persisted verification hash for a master key.
84
+ *
85
+ * Stored in `oke_vault_master.key_hash`. An unseal attempt with the wrong
86
+ * key produces a different hash, so it is rejected before any decryption
87
+ * is attempted. The hash is a SHA-256 of HKDF-derived bytes, never of the
88
+ * master key itself.
89
+ *
90
+ * @param masterKey - Raw 32-byte master key
91
+ * @returns Lowercase hex SHA-256 digest
92
+ */
93
+ export async function deriveVerifyHash(masterKey: Uint8Array): Promise<string> {
94
+ assertMasterKey(masterKey);
95
+ const bits = await hkdfBits(masterKey, HKDF_INFO_VERIFY, 32);
96
+ const digest = await crypto.subtle.digest("SHA-256", bits);
97
+ return toHex(new Uint8Array(digest));
98
+ }
99
+
100
+ /**
101
+ * Derive the key-encryption key that wraps per-secret DEKs.
102
+ *
103
+ * @param masterKey - Raw 32-byte master key
104
+ */
105
+ export function deriveKek(masterKey: Uint8Array): Promise<CryptoKey> {
106
+ return deriveAesKey(masterKey, HKDF_INFO_KEK);
107
+ }
108
+
109
+ /**
110
+ * Derive the backup key-encryption key used for export bundles.
111
+ *
112
+ * Domain-separated from {@link deriveKek} so a leaked backup key cannot
113
+ * unwrap live DEKs.
114
+ *
115
+ * @param masterKey - Raw 32-byte master key
116
+ */
117
+ export function deriveBackupKek(masterKey: Uint8Array): Promise<CryptoKey> {
118
+ return deriveAesKey(masterKey, HKDF_INFO_BACKUP_KEK);
119
+ }
120
+
121
+ /**
122
+ * Build the additional-authenticated-data binding for a secret version.
123
+ *
124
+ * Fields are joined with NUL, which {@link import("./path.ts").canonicalizePath}
125
+ * forbids inside a path — so the encoding is unambiguous.
126
+ *
127
+ * @param path - Canonical path
128
+ * @param version - Secret version
129
+ * @param algorithm - Content-encryption algorithm
130
+ * @param kekVersion - KEK generation that wrapped the DEK
131
+ */
132
+ export function buildAad(
133
+ path: string,
134
+ version: number,
135
+ algorithm: VaultAlgorithm,
136
+ kekVersion: number,
137
+ ): Uint8Array {
138
+ return encoder.encode(
139
+ ["oke-vault-v1", path, String(version), algorithm, String(kekVersion)].join("\0"),
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Import raw key bytes as an AES-256-GCM key.
145
+ *
146
+ * @param raw - 32 key bytes (DEK or unwrapped KEK)
147
+ */
148
+ export async function importAesKey(raw: Uint8Array): Promise<CryptoKey> {
149
+ if (raw.byteLength !== DEK_BYTES) {
150
+ throw new VaultError("INVALID_KEY", `vault: AES key must be ${DEK_BYTES} bytes`);
151
+ }
152
+ return crypto.subtle.importKey("raw", toArrayBuffer(raw), { name: "AES-GCM" }, false, [
153
+ "encrypt",
154
+ "decrypt",
155
+ ]);
156
+ }
157
+
158
+ /**
159
+ * Encrypt arbitrary bytes under an AES-GCM key with bound AAD.
160
+ *
161
+ * @param key - AES-256-GCM key
162
+ * @param plaintext - Bytes to protect
163
+ * @param aad - Additional authenticated data from {@link buildAad}
164
+ */
165
+ export async function encryptBytes(
166
+ key: CryptoKey,
167
+ plaintext: Uint8Array,
168
+ aad: Uint8Array,
169
+ ): Promise<SealedBytes> {
170
+ const iv = crypto.getRandomValues(new Uint8Array(GCM_IV_BYTES));
171
+ const sealed = new Uint8Array(
172
+ await crypto.subtle.encrypt(
173
+ { name: "AES-GCM", iv, additionalData: toArrayBuffer(aad), tagLength: GCM_TAG_BYTES * 8 },
174
+ key,
175
+ toArrayBuffer(plaintext),
176
+ ),
177
+ );
178
+ const split = sealed.byteLength - GCM_TAG_BYTES;
179
+ return {
180
+ iv,
181
+ ciphertext: sealed.slice(0, split),
182
+ tag: sealed.slice(split),
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Decrypt bytes produced by {@link encryptBytes}.
188
+ *
189
+ * @param key - AES-256-GCM key
190
+ * @param sealed - Stored `iv` / `ciphertext` / `tag`
191
+ * @param aad - The exact AAD used at encryption time
192
+ * @throws VaultError `INVALID_KEY` on a wrong key, tampered ciphertext, or mismatched AAD
193
+ */
194
+ export async function decryptBytes(
195
+ key: CryptoKey,
196
+ sealed: SealedBytes,
197
+ aad: Uint8Array,
198
+ ): Promise<Uint8Array> {
199
+ if (sealed.iv.byteLength !== GCM_IV_BYTES) {
200
+ throw new VaultError("INVALID_KEY", `vault: iv must be ${GCM_IV_BYTES} bytes`);
201
+ }
202
+ if (sealed.tag.byteLength !== GCM_TAG_BYTES) {
203
+ throw new VaultError("INVALID_KEY", `vault: auth tag must be ${GCM_TAG_BYTES} bytes`);
204
+ }
205
+ const joined = new Uint8Array(sealed.ciphertext.byteLength + sealed.tag.byteLength);
206
+ joined.set(sealed.ciphertext, 0);
207
+ joined.set(sealed.tag, sealed.ciphertext.byteLength);
208
+ try {
209
+ const plain = await crypto.subtle.decrypt(
210
+ {
211
+ name: "AES-GCM",
212
+ iv: new Uint8Array(sealed.iv),
213
+ additionalData: toArrayBuffer(aad),
214
+ tagLength: GCM_TAG_BYTES * 8,
215
+ },
216
+ key,
217
+ toArrayBuffer(joined),
218
+ );
219
+ return new Uint8Array(plain);
220
+ } catch {
221
+ // Never surface the underlying error: it can echo key/ciphertext state.
222
+ throw new VaultError("INVALID_KEY", "vault: decryption failed (wrong key or tampered data)");
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Encrypt a secret value under its per-secret DEK.
228
+ *
229
+ * @param dek - Per-secret AES-256-GCM data-encryption key
230
+ * @param plaintext - Cleartext value
231
+ * @param aad - Additional authenticated data from {@link buildAad}
232
+ */
233
+ export function encryptSecret(
234
+ dek: CryptoKey,
235
+ plaintext: string,
236
+ aad: Uint8Array,
237
+ ): Promise<SealedBytes> {
238
+ return encryptBytes(dek, encoder.encode(plaintext), aad);
239
+ }
240
+
241
+ /**
242
+ * Decrypt a secret value stored by {@link encryptSecret}.
243
+ *
244
+ * @param dek - Per-secret AES-256-GCM data-encryption key
245
+ * @param sealed - Stored `iv` / `ciphertext` / `tag`
246
+ * @param aad - The exact AAD used at encryption time
247
+ */
248
+ export async function decryptSecret(
249
+ dek: CryptoKey,
250
+ sealed: SealedBytes,
251
+ aad: Uint8Array,
252
+ ): Promise<string> {
253
+ const bytes = await decryptBytes(dek, sealed, aad);
254
+ const text = decoder.decode(bytes);
255
+ zeroBytes(bytes);
256
+ return text;
257
+ }
258
+
259
+ /**
260
+ * Wrap a per-secret DEK under the KEK, bound to the same AAD.
261
+ *
262
+ * @param kek - Key-encryption key from {@link deriveKek}
263
+ * @param dek - Raw 32-byte data-encryption key
264
+ * @param aad - Additional authenticated data from {@link buildAad}
265
+ */
266
+ export function wrapDek(kek: CryptoKey, dek: Uint8Array, aad: Uint8Array): Promise<SealedBytes> {
267
+ if (dek.byteLength !== DEK_BYTES) {
268
+ throw new VaultError("INVALID_KEY", `vault: dek must be ${DEK_BYTES} bytes`);
269
+ }
270
+ return encryptBytes(kek, dek, aad);
271
+ }
272
+
273
+ /**
274
+ * Unwrap a DEK sealed by {@link wrapDek}.
275
+ *
276
+ * The caller must {@link zeroBytes} the result once it has been imported.
277
+ *
278
+ * @param kek - Key-encryption key from {@link deriveKek}
279
+ * @param sealed - Stored wrapped-DEK material
280
+ * @param aad - The exact AAD used at wrap time
281
+ */
282
+ export async function unwrapDek(
283
+ kek: CryptoKey,
284
+ sealed: SealedBytes,
285
+ aad: Uint8Array,
286
+ ): Promise<Uint8Array> {
287
+ const raw = await decryptBytes(kek, sealed, aad);
288
+ if (raw.byteLength !== DEK_BYTES) {
289
+ zeroBytes(raw);
290
+ throw new VaultError("INVALID_KEY", "vault: unwrapped dek has the wrong length");
291
+ }
292
+ return raw;
293
+ }
294
+
295
+ /**
296
+ * Overwrite a buffer in place.
297
+ *
298
+ * Best effort — the runtime may still hold copies — but it shortens the
299
+ * window in which key material sits in a reachable heap object.
300
+ *
301
+ * @param buf - Buffer to clear
302
+ */
303
+ export function zeroBytes(buf: Uint8Array): void {
304
+ buf.fill(0);
305
+ }
306
+
307
+ /**
308
+ * Compare two buffers without an early exit.
309
+ *
310
+ * Length inequality is reported immediately (lengths are not secret);
311
+ * equal-length contents are compared with a full XOR accumulation.
312
+ *
313
+ * @param a - Left buffer
314
+ * @param b - Right buffer
315
+ */
316
+ export function constantTimeEqualBytes(a: Uint8Array, b: Uint8Array): boolean {
317
+ if (a.byteLength !== b.byteLength) return false;
318
+ let diff = 0;
319
+ for (let i = 0; i < a.byteLength; i += 1) {
320
+ diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
321
+ }
322
+ return diff === 0;
323
+ }
324
+
325
+ /**
326
+ * Compare two hex/base64 strings without an early exit.
327
+ *
328
+ * @param a - Left string
329
+ * @param b - Right string
330
+ */
331
+ export function constantTimeEqualStrings(a: string, b: string): boolean {
332
+ return constantTimeEqualBytes(encoder.encode(a), encoder.encode(b));
333
+ }
334
+
335
+ /**
336
+ * Encode a master key as base64 for out-of-band storage.
337
+ *
338
+ * @param masterKey - Raw 32-byte master key
339
+ */
340
+ export function masterKeyToBase64(masterKey: Uint8Array): string {
341
+ assertMasterKey(masterKey);
342
+ return Buffer.from(masterKey).toString("base64");
343
+ }
344
+
345
+ /**
346
+ * Decode a base64 master key.
347
+ *
348
+ * @param encoded - Base64 text
349
+ * @throws VaultError `INVALID_KEY` when the decoded key is not 32 bytes
350
+ */
351
+ export function masterKeyFromBase64(encoded: string): Uint8Array {
352
+ const trimmed = encoded.trim();
353
+ if (trimmed.length === 0) {
354
+ throw new VaultError("INVALID_KEY", "vault: master key is empty");
355
+ }
356
+ const decoded = new Uint8Array(Buffer.from(trimmed, "base64"));
357
+ if (decoded.byteLength !== MASTER_KEY_BYTES) {
358
+ zeroBytes(decoded);
359
+ throw new VaultError("INVALID_KEY", `vault: master key must be ${MASTER_KEY_BYTES} bytes`);
360
+ }
361
+ return decoded;
362
+ }
363
+
364
+ /**
365
+ * Lowercase hex encoding.
366
+ *
367
+ * @param bytes - Buffer to encode
368
+ */
369
+ export function toHex(bytes: Uint8Array): string {
370
+ return Buffer.from(bytes).toString("hex");
371
+ }
372
+
373
+ /**
374
+ * Reject anything that is not a 32-byte master key.
375
+ *
376
+ * @param masterKey - Candidate
377
+ */
378
+ function assertMasterKey(masterKey: Uint8Array): void {
379
+ if (masterKey.byteLength !== MASTER_KEY_BYTES) {
380
+ throw new VaultError("INVALID_KEY", `vault: master key must be ${MASTER_KEY_BYTES} bytes`);
381
+ }
382
+ }
383
+
384
+ /**
385
+ * HKDF-SHA-256 raw output.
386
+ *
387
+ * @param masterKey - Input keying material
388
+ * @param info - Domain-separation label
389
+ * @param bytes - Output length in bytes
390
+ */
391
+ async function hkdfBits(masterKey: Uint8Array, info: string, bytes: number): Promise<ArrayBuffer> {
392
+ const baseKey = await crypto.subtle.importKey("raw", toArrayBuffer(masterKey), "HKDF", false, [
393
+ "deriveBits",
394
+ ]);
395
+ return crypto.subtle.deriveBits(
396
+ { name: "HKDF", hash: "SHA-256", salt: EMPTY_SALT, info: encoder.encode(info) },
397
+ baseKey,
398
+ bytes * 8,
399
+ );
400
+ }
401
+
402
+ /**
403
+ * HKDF-SHA-256 to a non-extractable AES-256-GCM key.
404
+ *
405
+ * @param masterKey - Raw 32-byte master key
406
+ * @param info - Domain-separation label
407
+ */
408
+ async function deriveAesKey(masterKey: Uint8Array, info: string): Promise<CryptoKey> {
409
+ assertMasterKey(masterKey);
410
+ const baseKey = await crypto.subtle.importKey("raw", toArrayBuffer(masterKey), "HKDF", false, [
411
+ "deriveKey",
412
+ ]);
413
+ return crypto.subtle.deriveKey(
414
+ { name: "HKDF", hash: "SHA-256", salt: EMPTY_SALT, info: encoder.encode(info) },
415
+ baseKey,
416
+ { name: "AES-GCM", length: 256 },
417
+ false,
418
+ ["encrypt", "decrypt"],
419
+ );
420
+ }
421
+
422
+ /**
423
+ * Copy into a real `ArrayBuffer` for Web Crypto `BufferSource` typing.
424
+ *
425
+ * @param view - Source bytes
426
+ */
427
+ function toArrayBuffer(view: Uint8Array): ArrayBuffer {
428
+ const copy = new Uint8Array(view.byteLength);
429
+ copy.set(view);
430
+ return copy.buffer;
431
+ }