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.
- package/package.json +76 -10
- package/site/content/docs/deployment/docker-swarm.mdx +48 -111
- package/site/content/docs/deployment/docker.mdx +43 -36
- package/site/content/docs/deployment/index.mdx +9 -9
- package/site/content/docs/deployment/kubernetes.mdx +4 -4
- package/site/content/docs/elements/ai.mdx +7 -7
- package/site/content/docs/elements/channel.mdx +9 -7
- package/site/content/docs/elements/clock.mdx +8 -8
- package/site/content/docs/elements/flow.mdx +46 -47
- package/site/content/docs/elements/gate.mdx +6 -5
- package/site/content/docs/elements/signal.mdx +2 -2
- package/site/content/docs/elements/store.mdx +71 -78
- package/site/content/docs/elements/vault.mdx +89 -70
- package/site/content/docs/get-started/basic-usage.mdx +8 -8
- package/site/content/docs/get-started/installation.mdx +36 -36
- package/site/content/docs/get-started/introduction.mdx +11 -0
- package/site/content/docs/get-started/why.mdx +2 -2
- package/site/content/docs/plugins/anonymous.mdx +1 -1
- package/site/content/docs/plugins/magic-link.mdx +11 -11
- package/site/content/docs/plugins/otp.mdx +2 -2
- package/site/content/docs/plugins/passkey.mdx +1 -1
- package/site/content/docs/plugins/two-factor.mdx +1 -1
- package/site/content/docs/plugins/username.mdx +1 -1
- package/site/content/docs/recipes/caddy.mdx +1 -1
- package/site/content/docs/recipes/cockroachdb.mdx +2 -2
- package/site/content/docs/recipes/dragonfly.mdx +1 -1
- package/site/content/docs/recipes/index.mdx +1 -2
- package/site/content/docs/recipes/llama-cpp.mdx +21 -23
- package/site/content/docs/recipes/mailpit.mdx +11 -11
- package/site/content/docs/recipes/meilisearch.mdx +10 -10
- package/site/content/docs/recipes/meta.json +0 -1
- package/site/content/docs/recipes/ollama.mdx +1 -1
- package/site/content/docs/recipes/pgdog.mdx +12 -9
- package/site/content/docs/recipes/postgres.mdx +3 -3
- package/site/content/docs/recipes/redis.mdx +2 -2
- package/site/content/docs/recipes/rustfs.mdx +10 -7
- package/site/content/docs/recipes/sglang.mdx +1 -1
- package/site/content/docs/recipes/supabase-docker.mdx +1 -1
- package/site/content/docs/recipes/timescale.mdx +2 -2
- package/site/content/docs/recipes/valkey.mdx +1 -1
- package/site/content/docs/recipes/vllm.mdx +1 -1
- package/site/content/docs/recipes/yugabytedb.mdx +2 -2
- package/site/content/docs/reference/cli.md +61 -38
- package/site/content/docs/reference/configuration.mdx +145 -70
- package/site/content/docs/reference/environment-variables.mdx +36 -28
- package/site/content/docs/reference/errors.mdx +28 -29
- package/site/content/docs/reference/fx.mdx +11 -4
- package/site/content/docs/reference/i18n.mdx +17 -6
- package/site/content/docs/reference/index.mdx +5 -0
- package/site/content/docs/reference/meta.json +1 -0
- package/site/content/docs/reference/migrating-environments.mdx +158 -0
- package/site/content/docs/reference/plugins.mdx +2 -2
- package/src/auth/gate-auth.test.ts +12 -10
- package/src/cli/ai-setup/ai-setup.test.ts +27 -15
- package/src/cli/ai-setup/apply.ts +111 -15
- package/src/cli/ai-setup/catalog.ts +3 -3
- package/src/cli/ai-setup/index.ts +1 -1
- package/src/cli/ai-setup/prompts.ts +1 -1
- package/src/cli/ai-setup/recommend.test.ts +1 -1
- package/src/cli/ask-seed.test.ts +3 -3
- package/src/cli/build.ts +1 -1
- package/src/cli/db-auto-push.test.ts +6 -3
- package/src/cli/db-auto-push.ts +7 -5
- package/src/cli/db-seed.ts +7 -8
- package/src/cli/db.test.ts +70 -71
- package/src/cli/db.ts +86 -12
- package/src/cli/dev-controller.test.ts +35 -0
- package/src/cli/dev-controller.ts +298 -0
- package/src/cli/dev-controls.ts +11 -65
- package/src/cli/dev-db-push.test.ts +47 -15
- package/src/cli/dev-schema-sync.test.ts +11 -11
- package/src/cli/dev-schema-sync.ts +10 -10
- package/src/cli/dev-session-lock.test.ts +106 -0
- package/src/cli/dev-session-lock.ts +209 -0
- package/src/cli/dev.test.ts +166 -121
- package/src/cli/dev.ts +138 -192
- package/src/cli/doc-staleness.test.ts +3 -6
- package/src/cli/docker-clean.test.ts +33 -1
- package/src/cli/docker-clean.ts +28 -33
- package/src/cli/docker-cli.test.ts +12 -11
- package/src/cli/docker.ts +95 -20
- package/src/cli/doctor.test.ts +40 -0
- package/src/cli/doctor.ts +55 -15
- package/src/cli/drizzle-env.test.ts +11 -13
- package/src/cli/drizzle-env.ts +18 -11
- package/src/cli/ensure-drizzle-config.ts +11 -14
- package/src/cli/hero-meta.test.ts +15 -15
- package/src/cli/hero-meta.ts +10 -9
- package/src/cli/index.ts +36 -6
- package/src/cli/load-config.images.test.ts +16 -12
- package/src/cli/load-config.source.test.ts +50 -0
- package/src/cli/load-config.ts +34 -28
- package/src/cli/meilisearch-local.ts +1 -2
- package/src/cli/mode.ts +7 -58
- package/src/cli/registry.ts +175 -19
- package/src/cli/resolve-dev-sql-env.test.ts +8 -16
- package/src/cli/resolve-dev-sql-env.ts +13 -15
- package/src/cli/safe-defaults.test.ts +28 -10
- package/src/cli/schema.ts +65 -16
- package/src/cli/test.ts +58 -0
- package/src/cli/tui/App.tsx +252 -0
- package/src/cli/tui/DevLive.tsx +116 -0
- package/src/cli/tui/components/ConfirmDialog.tsx +48 -0
- package/src/cli/tui/components/HeaderBar.tsx +67 -0
- package/src/cli/tui/components/LogStream.tsx +47 -0
- package/src/cli/tui/components/PanelCard.tsx +39 -0
- package/src/cli/tui/components/SelectRow.tsx +37 -0
- package/src/cli/tui/components/SlashPalette.tsx +172 -0
- package/src/cli/tui/components/Spinner.tsx +32 -0
- package/src/cli/tui/components/StatusBar.tsx +51 -0
- package/src/cli/tui/components/StatusDot.tsx +37 -0
- package/src/cli/tui/components/TabBar.tsx +49 -0
- package/src/cli/tui/components/Wordmark.tsx +43 -0
- package/src/cli/tui/format.ts +38 -0
- package/src/cli/tui/keys.test.ts +18 -0
- package/src/cli/tui/keys.ts +26 -0
- package/src/cli/tui/launch.test.ts +18 -0
- package/src/cli/tui/launch.ts +57 -0
- package/src/cli/tui/panels/Dashboard.tsx +201 -0
- package/src/cli/tui/panels/Database.tsx +198 -0
- package/src/cli/tui/panels/Dev.tsx +257 -0
- package/src/cli/tui/panels/Docker.tsx +357 -0
- package/src/cli/tui/panels/Navigator.test.ts +22 -0
- package/src/cli/tui/panels/Navigator.tsx +213 -0
- package/src/cli/tui/slash-catalog.test.ts +31 -0
- package/src/cli/tui/slash-catalog.ts +184 -0
- package/src/cli/tui/slash-run.ts +54 -0
- package/src/cli/tui/state/ports.ts +95 -0
- package/src/cli/tui/theme.ts +24 -0
- package/src/cli/vault-cmd.test.ts +402 -0
- package/src/cli/vault-cmd.ts +727 -69
- package/src/cli/vault-secure-input.test.ts +56 -0
- package/src/cli/vault-secure-input.ts +124 -0
- package/src/compiler/effects-infer.ts +7 -0
- package/src/compiler/fixtures/skyport/oke.config.ts +3 -3
- package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +1 -1
- package/src/compiler/fixtures/skyport.expected.json +1 -1
- package/src/compiler-entry.ts +7 -0
- package/src/config/define-config.test.ts +111 -47
- package/src/config/driver-defaults.test.ts +16 -23
- package/src/config/driver-defaults.ts +33 -63
- package/src/config/index.ts +169 -144
- package/src/config/resolve-driver.test.ts +14 -19
- package/src/console/server/app.ts +4 -2
- package/src/console/server/console.test.ts +58 -1
- package/src/console/server/flows.ts +22 -2
- package/src/console/server/index.ts +3 -0
- package/src/console/server/operator-db.test.ts +55 -13
- package/src/console/server/operator-db.ts +305 -263
- package/src/console/server/serve.ts +16 -7
- package/src/console/server/state.ts +31 -9
- package/src/console/server/vault.test.ts +120 -5
- package/src/console/server/vault.ts +208 -12
- package/src/console/ui/dist/assets/{index-D-9KFce5.js → index--Y0Uaz-m.js} +2 -2
- package/src/console/ui/dist/assets/panel-vault-BkZdJPX8.js +1 -0
- package/src/console/ui/dist/assets/style-C3L349iZ.css +3 -0
- package/src/console/ui/dist/index.html +2 -2
- package/src/console/ui/shell/client.ts +16 -0
- package/src/console/ui/shell/panels/vault/VaultPanel.tsx +54 -1
- package/src/console/ui/vault/VaultA11yView.tsx +33 -1
- package/src/console/ui/vault/backend.test.ts +75 -0
- package/src/console/ui/vault/backend.ts +117 -0
- package/src/console/ui/vault/fixture.ts +21 -2
- package/src/console/ui/vault/index.ts +11 -1
- package/src/console/ui/vault/types.ts +26 -0
- package/src/docker/compose.ts +164 -61
- package/src/docker/derive.ts +106 -12
- package/src/docker/docker.test.ts +156 -110
- package/src/docker/helpers.ts +98 -0
- package/src/docker/images-config.test.ts +3 -8
- package/src/docker/index.ts +30 -5
- package/src/docker/recipes/index.ts +0 -3
- package/src/docker/recipes/llama-cpp.ts +4 -4
- package/src/docker/recipes/pgdog.ts +9 -2
- package/src/docker/resources.test.ts +71 -0
- package/src/docker/resources.ts +176 -0
- package/src/docker/stack-id.test.ts +11 -4
- package/src/docker/stack-id.ts +42 -32
- package/src/docker/stack.integration.test.ts +4 -15
- package/src/docker/types.ts +35 -3
- package/src/drivers/ai-ollama.integration.test.ts +2 -2
- package/src/drivers/conformance.test.ts +11 -12
- package/src/drivers/drizzle-dialect.test.ts +0 -4
- package/src/drivers/drizzle-dialect.ts +7 -13
- package/src/drivers/index.ts +24 -7
- package/src/drivers/journal-postgres.test.ts +3 -1
- package/src/drivers/journal-postgres.ts +4 -2
- package/src/drivers/meilisearch.ts +1 -1
- package/src/drivers/pglite.ts +28 -5
- package/src/drivers/types.ts +9 -11
- package/src/drivers/vault-aws-secrets-manager.test.ts +159 -0
- package/src/drivers/vault-aws-secrets-manager.ts +333 -0
- package/src/drivers/vault-builtin.test.ts +110 -0
- package/src/drivers/vault-builtin.ts +184 -0
- package/src/drivers/vault-driver-removal.test.ts +13 -0
- package/src/drivers/vault-managed.test.ts +95 -0
- package/src/drivers/vault-managed.ts +101 -25
- package/src/drivers/vault-types.ts +25 -5
- package/src/elements/ai/runtime.ts +1 -1
- package/src/elements/clock/chaos.test.ts +4 -1
- package/src/elements/clock/durable.ts +57 -4
- package/src/elements/clock.test.ts +13 -14
- package/src/elements/gate/constants.ts +18 -0
- package/src/elements/gate.test.ts +1 -1
- package/src/elements/gate.ts +2 -2
- package/src/elements/store/domain-ddl.test.ts +5 -12
- package/src/elements/store/emit-drizzle.ts +44 -19
- package/src/elements/store/index-boot.test.ts +15 -116
- package/src/elements/store/load-plugin-tables.ts +1 -1
- package/src/elements/store/resource-list-docs.fixture.ts +12 -6
- package/src/elements/store/resource-list-docs.test.ts +64 -44
- package/src/elements/store/resource.test.ts +17 -10
- package/src/elements/store/runtime.ts +9 -12
- package/src/elements/store/schema-decl.test.ts +16 -32
- package/src/elements/store/seed.test.ts +1 -2
- package/src/elements/store/seed.ts +7 -8
- package/src/elements/store/sql-condition.test.ts +2 -2
- package/src/elements/store/sql-session.test.ts +8 -10
- package/src/elements/store/sql-session.ts +10 -2
- package/src/elements/store.test.ts +13 -13
- package/src/elements/vault/audit.ts +233 -0
- package/src/elements/vault/boot-chain.test.ts +78 -0
- package/src/elements/vault/boot-chain.ts +43 -39
- package/src/elements/vault/builtin-adapter.test.ts +479 -0
- package/src/elements/vault/builtin-adapter.ts +1184 -0
- package/src/elements/vault/crypto.test.ts +237 -0
- package/src/elements/vault/crypto.ts +431 -0
- package/src/elements/vault/declare.ts +164 -3
- package/src/elements/vault/errors.ts +56 -0
- package/src/elements/vault/kms-unseal.test.ts +76 -0
- package/src/elements/vault/kms-unseal.ts +181 -0
- package/src/elements/vault/path.ts +90 -0
- package/src/elements/vault/resilience.test.ts +102 -0
- package/src/elements/vault/resilience.ts +107 -0
- package/src/elements/vault/runtime.ts +36 -4
- package/src/elements/vault/security-checklist.test.ts +130 -0
- package/src/elements/vault/storage.ts +448 -0
- package/src/elements/vault/types.ts +288 -0
- package/src/elements/vault/unseal.ts +150 -0
- package/src/elements/vault.test.ts +72 -4
- package/src/elements/vault.ts +91 -2
- package/src/full.ts +248 -0
- package/src/http.ts +41 -0
- package/src/i18n/interpolate-simple.ts +37 -0
- package/src/i18n/messages.ts +51 -4
- package/src/i18n-entry.ts +7 -0
- package/src/index.ts +35 -145
- package/src/journal-entry.ts +20 -0
- package/src/kernel/adopt-barrel-fresh.test.ts +25 -10
- package/src/kernel/app.ts +40 -42
- package/src/kernel/auto-registry.test.ts +2 -2
- package/src/kernel/boot-bind/ai.test.ts +5 -5
- package/src/kernel/boot-bind/ai.ts +2 -1
- package/src/kernel/boot-bind/channel.test.ts +6 -6
- package/src/kernel/boot-bind/channel.ts +3 -10
- package/src/kernel/boot-bind/clock.ts +2 -2
- package/src/kernel/boot-bind/gate.ts +5 -9
- package/src/kernel/boot-bind/honor-config.test.ts +42 -42
- package/src/kernel/boot-bind/journal.ts +2 -2
- package/src/kernel/boot-bind/store.test.ts +43 -44
- package/src/kernel/boot-bind/store.ts +16 -41
- package/src/kernel/boot-bind/vault.ts +16 -1
- package/src/kernel/boot.test.ts +12 -4
- package/src/kernel/boot.ts +31 -19
- package/src/kernel/budget-entry.ts +1 -1
- package/src/kernel/compensate.test.ts +288 -1
- package/src/kernel/compensate.ts +126 -0
- package/src/kernel/concurrency.test.ts +10 -9
- package/src/kernel/effects-stamping.test.ts +27 -2
- package/src/kernel/element-registries.ts +5 -3
- package/src/kernel/flow.ts +4 -2
- package/src/kernel/fx.test.ts +119 -3
- package/src/kernel/fx.ts +215 -40
- package/src/kernel/index.ts +14 -0
- package/src/kernel/journal.ts +130 -7
- package/src/kernel/pipeline.test.ts +20 -6
- package/src/kernel/redacted.test.ts +35 -0
- package/src/kernel/redacted.ts +24 -2
- package/src/kernel/router/create-default.ts +16 -0
- package/src/kernel/router/create-edge.ts +18 -0
- package/src/kernel/router/create.ts +23 -0
- package/src/kernel/router/index.ts +23 -0
- package/src/kernel/router/linear.ts +88 -0
- package/src/kernel/router/regexp.ts +188 -0
- package/src/kernel/router/smart.ts +120 -0
- package/src/kernel/router/trie.ts +108 -0
- package/src/kernel/router/types.ts +56 -0
- package/src/kernel/router.ts +18 -532
- package/src/kernel-entry.ts +43 -0
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +1 -1
- package/src/plugins/config-source.test.ts +7 -7
- package/src/release/build-lib.ts +58 -0
- package/src/release/exports.test.ts +4 -2
- package/src/release/exports.ts +30 -4
- package/src/release/measure.ts +101 -22
- package/src/runs/duckdb.ts +7 -2
- package/src/shared/optional-peer.ts +41 -0
- package/src/term.test.ts +3 -44
- package/src/test/create-test-app.ts +12 -0
- package/src/test/provisions.integration.test.ts +1 -1
- package/src/test/reset-element-registries.ts +2 -0
- package/src/upgrade/codemods.test.ts +7 -20
- package/src/upgrade/codemods.ts +93 -60
- package/site/content/docs/recipes/openbao.mdx +0 -141
- package/src/cli/ask-dev-mode.ts +0 -40
- package/src/cli/dev-mode.test.ts +0 -45
- package/src/cli/dev-mode.ts +0 -78
- package/src/cli/openbao-bootstrap.test.ts +0 -150
- package/src/cli/openbao-bootstrap.ts +0 -321
- package/src/cli/openbao-restart.integration.test.ts +0 -145
- package/src/console/ui/dist/assets/panel-vault-Za1GyJfM.js +0 -1
- package/src/console/ui/dist/assets/style-C8MxEWPd.css +0 -3
- package/src/docker/recipes/openbao.ts +0 -47
- package/src/drivers/libsql.ts +0 -179
- package/src/drivers/sqlite.ts +0 -44
- package/src/drivers/vault-openbao.test.ts +0 -97
- 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
|
+
}
|