okengine 0.10.2 → 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/ai/mcp.mdx +0 -1
- 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 -13
- package/site/content/docs/elements/channel.mdx +9 -13
- package/site/content/docs/elements/clock.mdx +8 -14
- package/site/content/docs/elements/flow.mdx +46 -54
- package/site/content/docs/elements/gate.mdx +6 -11
- package/site/content/docs/elements/signal.mdx +2 -8
- package/site/content/docs/elements/store.mdx +79 -93
- package/site/content/docs/elements/vault.mdx +89 -76
- package/site/content/docs/get-started/basic-usage.mdx +8 -14
- 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 +3 -4
- package/site/content/docs/index.mdx +0 -1
- package/site/content/docs/meta.json +0 -1
- 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 +14 -7
- 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 +30 -16
- 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 +8 -9
- 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 +246 -114
- package/src/cli/dev.ts +153 -205
- 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 +142 -9
- 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 +44 -10
- package/src/cli/load-config.source.test.ts +50 -0
- package/src/cli/load-config.ts +35 -29
- 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 +29 -11
- 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/extract.ts +12 -0
- package/src/compiler/fixtures/skyport/oke.config.ts +7 -5
- package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +1 -1
- package/src/compiler/fixtures/skyport.expected.json +1 -1
- package/src/compiler/generate-adopt.ts +27 -1
- package/src/compiler-entry.ts +7 -0
- package/src/config/define-config.test.ts +111 -47
- package/src/config/driver-defaults.test.ts +115 -0
- package/src/config/driver-defaults.ts +156 -0
- package/src/config/index.ts +246 -131
- 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 +163 -0
- 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 +5 -4
- package/src/kernel/boot-bind/clock.ts +5 -5
- package/src/kernel/boot-bind/gate.ts +6 -7
- package/src/kernel/boot-bind/honor-config.test.ts +42 -42
- package/src/kernel/boot-bind/journal.ts +5 -5
- package/src/kernel/boot-bind/signal.ts +3 -1
- package/src/kernel/boot-bind/store.test.ts +55 -37
- package/src/kernel/boot-bind/store.ts +24 -34
- package/src/kernel/boot-bind/vault.ts +19 -4
- 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/console/access.mdx +0 -29
- package/site/content/docs/console/ai.mdx +0 -35
- package/site/content/docs/console/architecture.mdx +0 -35
- package/site/content/docs/console/channels.mdx +0 -37
- package/site/content/docs/console/clock.mdx +0 -51
- package/site/content/docs/console/flows.mdx +0 -31
- package/site/content/docs/console/gates.mdx +0 -73
- package/site/content/docs/console/index.mdx +0 -54
- package/site/content/docs/console/manifest-diff.mdx +0 -34
- package/site/content/docs/console/meta.json +0 -24
- package/site/content/docs/console/overview.mdx +0 -40
- package/site/content/docs/console/plugins.mdx +0 -41
- package/site/content/docs/console/privacy.mdx +0 -32
- package/site/content/docs/console/runs.mdx +0 -50
- package/site/content/docs/console/signals.mdx +0 -40
- package/site/content/docs/console/store.mdx +0 -32
- package/site/content/docs/console/tenancy.mdx +0 -32
- package/site/content/docs/console/traces.mdx +0 -38
- package/site/content/docs/console/vault.mdx +0 -41
- 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,1184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in Vault adapter — encrypted-at-rest secrets over plain SQL.
|
|
3
|
+
*
|
|
4
|
+
* One master key (never stored) derives a KEK; every secret version gets its
|
|
5
|
+
* own DEK, wrapped by that KEK. Two AAD bindings guard the envelope:
|
|
6
|
+
*
|
|
7
|
+
* ```text
|
|
8
|
+
* value AAD = (path, version, algorithm, secrets.kek_version)
|
|
9
|
+
* DEK AAD = (path, version, algorithm, keys.kek_version)
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* They are equal at write time and diverge only during a master rotation:
|
|
13
|
+
* {@link BuiltinVaultAdapter.rotateMaster} re-wraps DEKs under a new KEK
|
|
14
|
+
* generation without touching the value ciphertext, so a rotation never has
|
|
15
|
+
* to decrypt-and-re-encrypt every secret. Reads try the active KEK first and
|
|
16
|
+
* fall back to the pending one, which makes an in-flight rewrap invisible to
|
|
17
|
+
* callers.
|
|
18
|
+
*
|
|
19
|
+
* Every operation appends one row to the tamper-evident audit chain. No
|
|
20
|
+
* cleartext value ever reaches the log, an error message, or a `VaultError`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createAuditSink, type AuditAction, type AuditSink } from "./audit.ts";
|
|
24
|
+
import {
|
|
25
|
+
ALGORITHM,
|
|
26
|
+
buildAad,
|
|
27
|
+
constantTimeEqualStrings,
|
|
28
|
+
decryptBytes,
|
|
29
|
+
decryptSecret,
|
|
30
|
+
deriveVerifyHash,
|
|
31
|
+
encryptBytes,
|
|
32
|
+
encryptSecret,
|
|
33
|
+
generateDek,
|
|
34
|
+
generateMasterKey,
|
|
35
|
+
importAesKey,
|
|
36
|
+
masterKeyFromBase64,
|
|
37
|
+
masterKeyToBase64,
|
|
38
|
+
unwrapDek,
|
|
39
|
+
wrapDek,
|
|
40
|
+
zeroBytes,
|
|
41
|
+
type SealedBytes,
|
|
42
|
+
} from "./crypto.ts";
|
|
43
|
+
import { VaultError } from "./errors.ts";
|
|
44
|
+
import { canonicalizePath, canonicalizePrefix } from "./path.ts";
|
|
45
|
+
import { createResilientSqlExec } from "./resilience.ts";
|
|
46
|
+
import {
|
|
47
|
+
createSqlAuditWriter,
|
|
48
|
+
ensureVaultTables,
|
|
49
|
+
purgeAuditBefore as purgeAuditRows,
|
|
50
|
+
purgeExpiredSecrets,
|
|
51
|
+
readAuditPage,
|
|
52
|
+
verifyAuditChain,
|
|
53
|
+
type AuditChainResult,
|
|
54
|
+
type AuditPageOptions,
|
|
55
|
+
type PurgeExpiredResult,
|
|
56
|
+
type SqlExec,
|
|
57
|
+
type VaultAuditRecord,
|
|
58
|
+
} from "./storage.ts";
|
|
59
|
+
import { createMemoryUnsealer, type Unsealer } from "./unseal.ts";
|
|
60
|
+
import type {
|
|
61
|
+
VaultActor,
|
|
62
|
+
VaultAdapter,
|
|
63
|
+
VaultAlgorithm,
|
|
64
|
+
VaultGetOptions,
|
|
65
|
+
VaultInitResult,
|
|
66
|
+
VaultListEntry,
|
|
67
|
+
VaultListOptions,
|
|
68
|
+
VaultSecret,
|
|
69
|
+
VaultSetOptions,
|
|
70
|
+
VaultStatus,
|
|
71
|
+
} from "./types.ts";
|
|
72
|
+
|
|
73
|
+
/** Default number of DEKs re-wrapped per {@link BuiltinVaultAdapter.rotateMaster} batch. */
|
|
74
|
+
export const DEFAULT_KEK_REWRAP_BATCH_SIZE = 100;
|
|
75
|
+
|
|
76
|
+
/** Leading magic line of an {@link BuiltinVaultAdapter.exportBackup} bundle. */
|
|
77
|
+
export const BACKUP_MAGIC = "oke-vault-backup-v1\n";
|
|
78
|
+
|
|
79
|
+
/** Synthetic path bound into the backup bundle's AAD. */
|
|
80
|
+
const BACKUP_AAD_PATH = "oke-vault-backup";
|
|
81
|
+
|
|
82
|
+
/** Options for {@link createBuiltinVaultAdapter}. */
|
|
83
|
+
export interface CreateBuiltinVaultOptions {
|
|
84
|
+
/** SQL surface holding the `oke_vault_*` tables. */
|
|
85
|
+
readonly db: SqlExec;
|
|
86
|
+
/**
|
|
87
|
+
* Pre-built unsealer. When supplied the adapter starts unsealed and
|
|
88
|
+
* {@link VaultAdapter.unseal} is not required.
|
|
89
|
+
*/
|
|
90
|
+
readonly unsealer?: Unsealer;
|
|
91
|
+
/** Audit destination. Defaults to a `db` sink over `oke_vault_audit`. */
|
|
92
|
+
readonly auditSink?: AuditSink;
|
|
93
|
+
/** DEKs re-wrapped per rotation batch. Defaults to {@link DEFAULT_KEK_REWRAP_BATCH_SIZE}. */
|
|
94
|
+
readonly kekRewrapBatchSize?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Progress of a master-key rotation. */
|
|
98
|
+
export interface VaultRotateMasterResult {
|
|
99
|
+
/** KEK generation the rewrap is migrating toward. */
|
|
100
|
+
readonly kekVersion: number;
|
|
101
|
+
/** DEKs still wrapped under an older generation after this batch. */
|
|
102
|
+
readonly remaining: number;
|
|
103
|
+
/**
|
|
104
|
+
* Base64 of a freshly generated master key — present only when
|
|
105
|
+
* {@link BuiltinVaultAdapter.rotateMaster} was called without one, and
|
|
106
|
+
* returned exactly once. Store it out of band before the rewrap finishes.
|
|
107
|
+
*/
|
|
108
|
+
readonly masterKey?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Built-in adapter surface: {@link VaultAdapter} plus operator lifecycle. */
|
|
112
|
+
export interface BuiltinVaultAdapter extends VaultAdapter {
|
|
113
|
+
/** Drop the in-memory master key; later reads fail with `SEALED`. */
|
|
114
|
+
seal(): Promise<void>;
|
|
115
|
+
/**
|
|
116
|
+
* Restore the in-memory master key after verifying it against
|
|
117
|
+
* `oke_vault_master.key_hash`.
|
|
118
|
+
*
|
|
119
|
+
* @param masterKey - Base64 text or raw 32 bytes
|
|
120
|
+
*/
|
|
121
|
+
unseal(masterKey: string | Uint8Array): Promise<void>;
|
|
122
|
+
/** Generate the master key and persist backend state exactly once. */
|
|
123
|
+
initialize(): Promise<VaultInitResult>;
|
|
124
|
+
/**
|
|
125
|
+
* Start a master-key rotation and re-wrap the first batch of DEKs.
|
|
126
|
+
*
|
|
127
|
+
* Both master keys stay in memory until the rewrap completes, so reads
|
|
128
|
+
* keep working throughout. Call {@link continueRotateMaster} until
|
|
129
|
+
* `remaining` reaches `0`.
|
|
130
|
+
*
|
|
131
|
+
* @param newMasterKey - Raw 32-byte replacement key; generated when omitted
|
|
132
|
+
*/
|
|
133
|
+
rotateMaster(newMasterKey?: Uint8Array): Promise<VaultRotateMasterResult>;
|
|
134
|
+
/**
|
|
135
|
+
* Re-wrap the next batch of an in-flight rotation.
|
|
136
|
+
*
|
|
137
|
+
* After a process restart the in-memory pending key is gone — pass the
|
|
138
|
+
* **new** master key (the one printed by {@link rotateMaster}) so the
|
|
139
|
+
* adapter can resume from `rewrap_checkpoint`. The vault must still be
|
|
140
|
+
* unsealed with the **old** master key for dual-read of unrewrapped DEKs.
|
|
141
|
+
*
|
|
142
|
+
* @param newMasterKey - Required after a cold resume; ignored when pending is live
|
|
143
|
+
*/
|
|
144
|
+
continueRotateMaster(newMasterKey?: string | Uint8Array): Promise<VaultRotateMasterResult>;
|
|
145
|
+
/**
|
|
146
|
+
* Delete audit rows older than `before`.
|
|
147
|
+
*
|
|
148
|
+
* @param before - Exclusive upper bound on `created_at`
|
|
149
|
+
* @returns Number of rows removed
|
|
150
|
+
*/
|
|
151
|
+
purgeAuditBefore(before: Date): Promise<number>;
|
|
152
|
+
/**
|
|
153
|
+
* Hard-delete secret rows with `expires_at` strictly before `before`.
|
|
154
|
+
*
|
|
155
|
+
* @param options - Cutoff and dry-run
|
|
156
|
+
*/
|
|
157
|
+
purgeExpired(options?: {
|
|
158
|
+
readonly before?: Date;
|
|
159
|
+
readonly dryRun?: boolean;
|
|
160
|
+
}): Promise<PurgeExpiredResult>;
|
|
161
|
+
/** Walk the audit chain and report the first broken row. */
|
|
162
|
+
verifyAudit(): Promise<AuditChainResult>;
|
|
163
|
+
/**
|
|
164
|
+
* Read the newest audit rows. Never requires an unsealed vault — audit
|
|
165
|
+
* rows describe operations, not values.
|
|
166
|
+
*
|
|
167
|
+
* @param options - Limit / path filter
|
|
168
|
+
*/
|
|
169
|
+
listAudit(options?: AuditPageOptions): Promise<readonly VaultAuditRecord[]>;
|
|
170
|
+
/** Export every live secret as one bundle encrypted under the backup KEK. */
|
|
171
|
+
exportBackup(): Promise<Uint8Array>;
|
|
172
|
+
/**
|
|
173
|
+
* Restore a bundle produced by {@link exportBackup}.
|
|
174
|
+
*
|
|
175
|
+
* Entries are replayed through the normal write path: each restored path
|
|
176
|
+
* receives a **new** version wrapped by a fresh DEK under the current KEK.
|
|
177
|
+
*
|
|
178
|
+
* @param blob - Bundle bytes
|
|
179
|
+
*/
|
|
180
|
+
importBackup(blob: Uint8Array): Promise<void>;
|
|
181
|
+
/** Active unsealer, or `null` while sealed. */
|
|
182
|
+
getUnsealer(): Unsealer | null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Numeric column as any supported driver may hand it back. */
|
|
186
|
+
type SqlNumber = number | string | bigint;
|
|
187
|
+
|
|
188
|
+
/** `oke_vault_secrets` row as returned by the driver. */
|
|
189
|
+
interface SecretRow {
|
|
190
|
+
id: string;
|
|
191
|
+
path: string;
|
|
192
|
+
encrypted_value: unknown;
|
|
193
|
+
iv: unknown;
|
|
194
|
+
auth_tag: unknown;
|
|
195
|
+
version: SqlNumber;
|
|
196
|
+
metadata: unknown;
|
|
197
|
+
algorithm: string;
|
|
198
|
+
kek_version: SqlNumber;
|
|
199
|
+
created_at: Date | string;
|
|
200
|
+
updated_at: Date | string;
|
|
201
|
+
expires_at: Date | string | null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** `oke_vault_keys` row joined with its secret's AAD inputs. */
|
|
205
|
+
interface KeyRow {
|
|
206
|
+
id: string;
|
|
207
|
+
encrypted_dek: unknown;
|
|
208
|
+
dek_iv: unknown;
|
|
209
|
+
dek_auth_tag: unknown;
|
|
210
|
+
kek_version: SqlNumber;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `oke_vault_status` singleton row. */
|
|
214
|
+
interface StatusRow {
|
|
215
|
+
sealed: boolean | number;
|
|
216
|
+
initialized: boolean | number;
|
|
217
|
+
master_key_present: boolean | number;
|
|
218
|
+
last_sealed_at: Date | string | null;
|
|
219
|
+
last_unsealed_at: Date | string | null;
|
|
220
|
+
seal_count: SqlNumber;
|
|
221
|
+
rewrap_checkpoint: string | null;
|
|
222
|
+
rewrap_target_kek_version: SqlNumber | null;
|
|
223
|
+
/** Verify-hash of the pending new master (for cold resume). */
|
|
224
|
+
rewrap_key_hash: string | null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** `oke_vault_master` row. */
|
|
228
|
+
interface MasterRow {
|
|
229
|
+
key_hash: string;
|
|
230
|
+
kek_version: SqlNumber;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** One secret in an export bundle. */
|
|
234
|
+
interface BackupEntry {
|
|
235
|
+
readonly path: string;
|
|
236
|
+
readonly value: string;
|
|
237
|
+
readonly version: number;
|
|
238
|
+
readonly metadata: Record<string, unknown>;
|
|
239
|
+
readonly expiresAt: string | null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Non-secret header of an export bundle. */
|
|
243
|
+
interface BackupMetadata {
|
|
244
|
+
readonly format: 1;
|
|
245
|
+
readonly createdAt: string;
|
|
246
|
+
readonly kekVersion: number;
|
|
247
|
+
readonly count: number;
|
|
248
|
+
readonly iv: string;
|
|
249
|
+
readonly tag: string;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Master key held for the target generation of an in-flight rewrap. */
|
|
253
|
+
interface PendingRotation {
|
|
254
|
+
readonly unsealer: Unsealer;
|
|
255
|
+
readonly kekVersion: number;
|
|
256
|
+
readonly verifyHash: string;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Create the built-in SQL-backed Vault adapter.
|
|
261
|
+
*
|
|
262
|
+
* The tables are created lazily on first use, so constructing an adapter
|
|
263
|
+
* never touches the database.
|
|
264
|
+
*
|
|
265
|
+
* @param opts - SQL surface, optional unsealer, audit sink, batch size
|
|
266
|
+
*/
|
|
267
|
+
export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): BuiltinVaultAdapter {
|
|
268
|
+
// Secret CRUD retries on connection blips. Audit appends stay on the raw
|
|
269
|
+
// surface so a retry never duplicates a hash-chain row.
|
|
270
|
+
const rawDb = opts.db;
|
|
271
|
+
const db = createResilientSqlExec(rawDb);
|
|
272
|
+
const batchSize = Math.max(1, opts.kekRewrapBatchSize ?? DEFAULT_KEK_REWRAP_BATCH_SIZE);
|
|
273
|
+
const audit = opts.auditSink ?? createAuditSink("db", { writer: createSqlAuditWriter(rawDb) });
|
|
274
|
+
|
|
275
|
+
let unsealer: Unsealer | null = opts.unsealer ?? null;
|
|
276
|
+
let pending: PendingRotation | null = null;
|
|
277
|
+
let ready: Promise<void> | undefined;
|
|
278
|
+
|
|
279
|
+
/** Create the `oke_vault_*` tables once per adapter. */
|
|
280
|
+
function ensureReady(): Promise<void> {
|
|
281
|
+
ready ??= ensureVaultTables(db);
|
|
282
|
+
return ready;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Active unsealer, or `SEALED`. */
|
|
286
|
+
function requireUnsealer(): Unsealer {
|
|
287
|
+
if (!unsealer || unsealer.sealed) {
|
|
288
|
+
throw new VaultError("SEALED", "vault: sealed — unseal with the master key first");
|
|
289
|
+
}
|
|
290
|
+
return unsealer;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Append one audit row, tolerating a sink that is temporarily down.
|
|
295
|
+
*
|
|
296
|
+
* An audit failure must not mask the operation's own outcome, so the
|
|
297
|
+
* append error is swallowed after the operation has already succeeded.
|
|
298
|
+
*
|
|
299
|
+
* @param action - What happened
|
|
300
|
+
* @param actor - Caller identity
|
|
301
|
+
* @param extra - Path and failure detail
|
|
302
|
+
*/
|
|
303
|
+
async function record(
|
|
304
|
+
action: AuditAction,
|
|
305
|
+
actor: VaultActor | undefined,
|
|
306
|
+
extra: {
|
|
307
|
+
path?: string;
|
|
308
|
+
success?: boolean;
|
|
309
|
+
errorCode?: VaultError["code"];
|
|
310
|
+
errorMessage?: string;
|
|
311
|
+
} = {},
|
|
312
|
+
): Promise<void> {
|
|
313
|
+
try {
|
|
314
|
+
await audit.append({
|
|
315
|
+
action,
|
|
316
|
+
actorType: actor?.type ?? "unknown",
|
|
317
|
+
...(actor?.id === undefined ? {} : { actorId: actor.id }),
|
|
318
|
+
...(actor?.requestId === undefined ? {} : { requestId: actor.requestId }),
|
|
319
|
+
...(extra.path === undefined ? {} : { path: extra.path }),
|
|
320
|
+
...(extra.errorCode === undefined ? {} : { errorCode: extra.errorCode }),
|
|
321
|
+
...(extra.errorMessage === undefined ? {} : { errorMessage: extra.errorMessage }),
|
|
322
|
+
success: extra.success ?? true,
|
|
323
|
+
});
|
|
324
|
+
} catch {
|
|
325
|
+
// Auditing is best-effort at the tail of an operation.
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Read the singleton status row, creating the tables when needed. */
|
|
330
|
+
async function readStatus(): Promise<StatusRow> {
|
|
331
|
+
await ensureReady();
|
|
332
|
+
const rows = await db.query<StatusRow>(
|
|
333
|
+
`SELECT sealed, initialized, master_key_present, last_sealed_at, last_unsealed_at,
|
|
334
|
+
seal_count, rewrap_checkpoint, rewrap_target_kek_version, rewrap_key_hash
|
|
335
|
+
FROM oke_vault_status WHERE id = 1`,
|
|
336
|
+
);
|
|
337
|
+
const row = rows[0];
|
|
338
|
+
if (!row) {
|
|
339
|
+
throw new VaultError("BACKEND_ERROR", "vault: status row is missing");
|
|
340
|
+
}
|
|
341
|
+
return row;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Read the master record, or `NOT_INITIALIZED`. */
|
|
345
|
+
async function readMaster(): Promise<MasterRow> {
|
|
346
|
+
await ensureReady();
|
|
347
|
+
const rows = await db.query<MasterRow>(
|
|
348
|
+
`SELECT key_hash, kek_version FROM oke_vault_master ORDER BY created_at ASC LIMIT 1`,
|
|
349
|
+
);
|
|
350
|
+
const row = rows[0];
|
|
351
|
+
if (!row) {
|
|
352
|
+
throw new VaultError("NOT_INITIALIZED", "vault: not initialized — run `oke vault init`");
|
|
353
|
+
}
|
|
354
|
+
return row;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Unwrap a DEK under the active KEK, falling back to a pending rotation's.
|
|
359
|
+
*
|
|
360
|
+
* @param sealed - Stored wrapped-DEK material
|
|
361
|
+
* @param aad - AAD bound at wrap time
|
|
362
|
+
*/
|
|
363
|
+
async function unwrapWithAnyKek(sealed: SealedBytes, aad: Uint8Array): Promise<Uint8Array> {
|
|
364
|
+
const keks = [await requireUnsealer().unwrapKek()];
|
|
365
|
+
if (pending) keks.push(await pending.unsealer.unwrapKek());
|
|
366
|
+
for (const kek of keks) {
|
|
367
|
+
try {
|
|
368
|
+
return await unwrapDek(kek, sealed, aad);
|
|
369
|
+
} catch {
|
|
370
|
+
// Try the other generation before giving up.
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
throw new VaultError("INVALID_KEY", "vault: unable to unwrap the data key");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Fetch a secret row by path.
|
|
378
|
+
*
|
|
379
|
+
* @param path - Canonical path
|
|
380
|
+
* @param version - Pinned version, or latest when omitted
|
|
381
|
+
*/
|
|
382
|
+
async function readSecretRow(path: string, version?: number): Promise<SecretRow | undefined> {
|
|
383
|
+
const rows =
|
|
384
|
+
version === undefined
|
|
385
|
+
? await db.query<SecretRow>(
|
|
386
|
+
`SELECT id, path, encrypted_value, iv, auth_tag, version, metadata, algorithm,
|
|
387
|
+
kek_version, created_at, updated_at, expires_at
|
|
388
|
+
FROM oke_vault_secrets
|
|
389
|
+
WHERE path = $1 AND deleted_at IS NULL
|
|
390
|
+
ORDER BY version DESC LIMIT 1`,
|
|
391
|
+
[path],
|
|
392
|
+
)
|
|
393
|
+
: await db.query<SecretRow>(
|
|
394
|
+
`SELECT id, path, encrypted_value, iv, auth_tag, version, metadata, algorithm,
|
|
395
|
+
kek_version, created_at, updated_at, expires_at
|
|
396
|
+
FROM oke_vault_secrets
|
|
397
|
+
WHERE path = $1 AND version = $2 AND deleted_at IS NULL
|
|
398
|
+
LIMIT 1`,
|
|
399
|
+
[path, version],
|
|
400
|
+
);
|
|
401
|
+
return rows[0];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Fetch the live wrapped DEK for a secret version.
|
|
406
|
+
*
|
|
407
|
+
* @param secretId - `oke_vault_secrets.id`
|
|
408
|
+
*/
|
|
409
|
+
async function readKeyRow(secretId: string): Promise<KeyRow> {
|
|
410
|
+
const rows = await db.query<KeyRow>(
|
|
411
|
+
`SELECT id, encrypted_dek, dek_iv, dek_auth_tag, kek_version
|
|
412
|
+
FROM oke_vault_keys WHERE secret_id = $1 ORDER BY created_at DESC LIMIT 1`,
|
|
413
|
+
[secretId],
|
|
414
|
+
);
|
|
415
|
+
const row = rows[0];
|
|
416
|
+
if (!row) {
|
|
417
|
+
throw new VaultError("BACKEND_ERROR", "vault: secret has no wrapped data key");
|
|
418
|
+
}
|
|
419
|
+
return row;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Decrypt one secret row into its cleartext value.
|
|
424
|
+
*
|
|
425
|
+
* @param row - Stored secret version
|
|
426
|
+
* @param keyRow - That version's live wrapped DEK
|
|
427
|
+
*/
|
|
428
|
+
async function decryptRow(row: SecretRow, keyRow: KeyRow): Promise<string> {
|
|
429
|
+
const path = row.path;
|
|
430
|
+
const version = toInt(row.version);
|
|
431
|
+
const algorithm = row.algorithm as VaultAlgorithm;
|
|
432
|
+
|
|
433
|
+
const dekAad = buildAad(path, version, algorithm, toInt(keyRow.kek_version));
|
|
434
|
+
const dekBytes = await unwrapWithAnyKek(
|
|
435
|
+
{
|
|
436
|
+
iv: toBytes(keyRow.dek_iv),
|
|
437
|
+
ciphertext: toBytes(keyRow.encrypted_dek),
|
|
438
|
+
tag: toBytes(keyRow.dek_auth_tag),
|
|
439
|
+
},
|
|
440
|
+
dekAad,
|
|
441
|
+
);
|
|
442
|
+
try {
|
|
443
|
+
const dek = await importAesKey(dekBytes);
|
|
444
|
+
return await decryptSecret(
|
|
445
|
+
dek,
|
|
446
|
+
{
|
|
447
|
+
iv: toBytes(row.iv),
|
|
448
|
+
ciphertext: toBytes(row.encrypted_value),
|
|
449
|
+
tag: toBytes(row.auth_tag),
|
|
450
|
+
},
|
|
451
|
+
buildAad(path, version, algorithm, toInt(row.kek_version)),
|
|
452
|
+
);
|
|
453
|
+
} finally {
|
|
454
|
+
zeroBytes(dekBytes);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Write a new version of `path` with a fresh DEK.
|
|
460
|
+
*
|
|
461
|
+
* @param path - Canonical path
|
|
462
|
+
* @param value - Cleartext value
|
|
463
|
+
* @param options - Metadata / expiry / actor
|
|
464
|
+
* @param action - Audit action to record (`set` or `rotate`)
|
|
465
|
+
*/
|
|
466
|
+
async function writeVersion(
|
|
467
|
+
path: string,
|
|
468
|
+
value: string,
|
|
469
|
+
options: VaultSetOptions | undefined,
|
|
470
|
+
action: Extract<AuditAction, "set" | "rotate">,
|
|
471
|
+
): Promise<VaultSecret> {
|
|
472
|
+
const master = await readMaster();
|
|
473
|
+
const kekVersion = toInt(master.kek_version);
|
|
474
|
+
const kek = await requireUnsealer().unwrapKek();
|
|
475
|
+
|
|
476
|
+
const versions = await db.query<{ next: SqlNumber | null }>(
|
|
477
|
+
`SELECT MAX(version) + 1 AS next FROM oke_vault_secrets WHERE path = $1`,
|
|
478
|
+
[path],
|
|
479
|
+
);
|
|
480
|
+
const version = versions[0]?.next == null ? 1 : toInt(versions[0].next);
|
|
481
|
+
const aad = buildAad(path, version, ALGORITHM, kekVersion);
|
|
482
|
+
const metadata = options?.metadata ?? {};
|
|
483
|
+
const expiresAt = resolveExpiry(options);
|
|
484
|
+
|
|
485
|
+
const dekBytes = generateDek();
|
|
486
|
+
let sealedValue: SealedBytes;
|
|
487
|
+
let wrappedDek: SealedBytes;
|
|
488
|
+
try {
|
|
489
|
+
sealedValue = await encryptSecret(await importAesKey(dekBytes), value, aad);
|
|
490
|
+
wrappedDek = await wrapDek(kek, dekBytes, aad);
|
|
491
|
+
} finally {
|
|
492
|
+
zeroBytes(dekBytes);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const inserted = await db.query<{
|
|
496
|
+
id: string;
|
|
497
|
+
created_at: Date | string;
|
|
498
|
+
updated_at: Date | string;
|
|
499
|
+
}>(
|
|
500
|
+
`INSERT INTO oke_vault_secrets
|
|
501
|
+
(path, encrypted_value, iv, auth_tag, version, metadata, algorithm, kek_version, expires_at)
|
|
502
|
+
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9)
|
|
503
|
+
RETURNING id, created_at, updated_at`,
|
|
504
|
+
[
|
|
505
|
+
path,
|
|
506
|
+
sealedValue.ciphertext,
|
|
507
|
+
sealedValue.iv,
|
|
508
|
+
sealedValue.tag,
|
|
509
|
+
version,
|
|
510
|
+
JSON.stringify(metadata),
|
|
511
|
+
ALGORITHM,
|
|
512
|
+
kekVersion,
|
|
513
|
+
expiresAt ?? null,
|
|
514
|
+
],
|
|
515
|
+
);
|
|
516
|
+
const row = inserted[0];
|
|
517
|
+
if (!row) {
|
|
518
|
+
throw new VaultError("BACKEND_ERROR", "vault: failed to persist the secret version");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
await db.execute(
|
|
522
|
+
`INSERT INTO oke_vault_keys
|
|
523
|
+
(secret_id, encrypted_dek, dek_iv, dek_auth_tag, algorithm, kek_version)
|
|
524
|
+
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
525
|
+
[row.id, wrappedDek.ciphertext, wrappedDek.iv, wrappedDek.tag, ALGORITHM, kekVersion],
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
await record(action, options?.actor, { path });
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
path,
|
|
532
|
+
value,
|
|
533
|
+
version,
|
|
534
|
+
metadata,
|
|
535
|
+
algorithm: ALGORITHM,
|
|
536
|
+
kekVersion,
|
|
537
|
+
createdAt: toDate(row.created_at),
|
|
538
|
+
updatedAt: toDate(row.updated_at),
|
|
539
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Re-wrap one batch of DEKs onto the pending KEK generation.
|
|
545
|
+
*
|
|
546
|
+
* @param rotation - In-flight rotation state
|
|
547
|
+
*/
|
|
548
|
+
async function rewrapBatch(rotation: PendingRotation): Promise<VaultRotateMasterResult> {
|
|
549
|
+
const target = rotation.kekVersion;
|
|
550
|
+
const nextKek = await rotation.unsealer.unwrapKek();
|
|
551
|
+
const rows = await db.query<KeyRow & { path: string; version: SqlNumber; algorithm: string }>(
|
|
552
|
+
`SELECT k.id, k.encrypted_dek, k.dek_iv, k.dek_auth_tag, k.kek_version,
|
|
553
|
+
s.path, s.version, k.algorithm
|
|
554
|
+
FROM oke_vault_keys k
|
|
555
|
+
JOIN oke_vault_secrets s ON s.id = k.secret_id
|
|
556
|
+
WHERE k.kek_version <> $1
|
|
557
|
+
ORDER BY k.id ASC
|
|
558
|
+
LIMIT $2`,
|
|
559
|
+
[target, batchSize],
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
let checkpoint: string | null = null;
|
|
563
|
+
for (const row of rows) {
|
|
564
|
+
const path = row.path;
|
|
565
|
+
const version = toInt(row.version);
|
|
566
|
+
const algorithm = row.algorithm as VaultAlgorithm;
|
|
567
|
+
const dekBytes = await unwrapWithAnyKek(
|
|
568
|
+
{
|
|
569
|
+
iv: toBytes(row.dek_iv),
|
|
570
|
+
ciphertext: toBytes(row.encrypted_dek),
|
|
571
|
+
tag: toBytes(row.dek_auth_tag),
|
|
572
|
+
},
|
|
573
|
+
buildAad(path, version, algorithm, toInt(row.kek_version)),
|
|
574
|
+
);
|
|
575
|
+
try {
|
|
576
|
+
const rewrapped = await wrapDek(
|
|
577
|
+
nextKek,
|
|
578
|
+
dekBytes,
|
|
579
|
+
buildAad(path, version, algorithm, target),
|
|
580
|
+
);
|
|
581
|
+
await db.execute(
|
|
582
|
+
`UPDATE oke_vault_keys
|
|
583
|
+
SET encrypted_dek = $1, dek_iv = $2, dek_auth_tag = $3, kek_version = $4, rotated_at = now()
|
|
584
|
+
WHERE id = $5`,
|
|
585
|
+
[rewrapped.ciphertext, rewrapped.iv, rewrapped.tag, target, row.id],
|
|
586
|
+
);
|
|
587
|
+
} finally {
|
|
588
|
+
zeroBytes(dekBytes);
|
|
589
|
+
}
|
|
590
|
+
checkpoint = row.id;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const left = await db.query<{ count: SqlNumber }>(
|
|
594
|
+
`SELECT COUNT(*) AS count FROM oke_vault_keys WHERE kek_version <> $1`,
|
|
595
|
+
[target],
|
|
596
|
+
);
|
|
597
|
+
const remaining = toInt(left[0]?.count ?? 0);
|
|
598
|
+
|
|
599
|
+
if (remaining === 0) {
|
|
600
|
+
// Commit the new generation: the pending key becomes the only master.
|
|
601
|
+
// Old master is sealed (zeroed) so it leaves process memory.
|
|
602
|
+
await db.execute(
|
|
603
|
+
`UPDATE oke_vault_master SET key_hash = $1, kek_version = $2, updated_at = now()`,
|
|
604
|
+
[rotation.verifyHash, target],
|
|
605
|
+
);
|
|
606
|
+
await db.execute(
|
|
607
|
+
`UPDATE oke_vault_status
|
|
608
|
+
SET rewrap_checkpoint = NULL, rewrap_target_kek_version = NULL,
|
|
609
|
+
rewrap_key_hash = NULL, updated_at = now()
|
|
610
|
+
WHERE id = 1`,
|
|
611
|
+
);
|
|
612
|
+
unsealer?.seal();
|
|
613
|
+
unsealer = rotation.unsealer;
|
|
614
|
+
pending = null;
|
|
615
|
+
} else {
|
|
616
|
+
await db.execute(
|
|
617
|
+
`UPDATE oke_vault_status
|
|
618
|
+
SET rewrap_checkpoint = $1, rewrap_target_kek_version = $2,
|
|
619
|
+
rewrap_key_hash = $3, updated_at = now()
|
|
620
|
+
WHERE id = 1`,
|
|
621
|
+
[checkpoint, target, rotation.verifyHash],
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
await record("rewrap", undefined, { success: true });
|
|
626
|
+
return { kekVersion: target, remaining };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const adapter: BuiltinVaultAdapter = {
|
|
630
|
+
id: "vault",
|
|
631
|
+
|
|
632
|
+
async initialize(): Promise<VaultInitResult> {
|
|
633
|
+
const status = await readStatus();
|
|
634
|
+
if (toBool(status.initialized)) {
|
|
635
|
+
throw new VaultError("ALREADY_INITIALIZED", "vault: already initialized");
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const masterKey = generateMasterKey();
|
|
639
|
+
try {
|
|
640
|
+
const verifyHash = await deriveVerifyHash(masterKey);
|
|
641
|
+
await db.execute(`INSERT INTO oke_vault_master (key_hash, kek_version) VALUES ($1, 1)`, [
|
|
642
|
+
verifyHash,
|
|
643
|
+
]);
|
|
644
|
+
await db.execute(
|
|
645
|
+
`UPDATE oke_vault_status
|
|
646
|
+
SET initialized = true, sealed = true, master_key_present = true, updated_at = now()
|
|
647
|
+
WHERE id = 1`,
|
|
648
|
+
);
|
|
649
|
+
await record("initialize", undefined);
|
|
650
|
+
return { masterKey: masterKeyToBase64(masterKey), verifyHash, kekVersion: 1 };
|
|
651
|
+
} finally {
|
|
652
|
+
zeroBytes(masterKey);
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
|
|
656
|
+
async unseal(masterKey: string | Uint8Array): Promise<void> {
|
|
657
|
+
const master = await readMaster();
|
|
658
|
+
const raw =
|
|
659
|
+
typeof masterKey === "string" ? masterKeyFromBase64(masterKey) : Uint8Array.from(masterKey);
|
|
660
|
+
try {
|
|
661
|
+
const verifyHash = await deriveVerifyHash(raw);
|
|
662
|
+
if (!constantTimeEqualStrings(verifyHash, master.key_hash)) {
|
|
663
|
+
await record("unseal", undefined, {
|
|
664
|
+
success: false,
|
|
665
|
+
errorCode: "INVALID_KEY",
|
|
666
|
+
errorMessage: "vault: master key does not match this vault",
|
|
667
|
+
});
|
|
668
|
+
throw new VaultError("INVALID_KEY", "vault: master key does not match this vault");
|
|
669
|
+
}
|
|
670
|
+
unsealer?.seal();
|
|
671
|
+
unsealer = createMemoryUnsealer(raw);
|
|
672
|
+
} finally {
|
|
673
|
+
zeroBytes(raw);
|
|
674
|
+
}
|
|
675
|
+
await db.execute(
|
|
676
|
+
`UPDATE oke_vault_status
|
|
677
|
+
SET sealed = false, last_unsealed_at = now(), updated_at = now()
|
|
678
|
+
WHERE id = 1`,
|
|
679
|
+
);
|
|
680
|
+
await record("unseal", undefined);
|
|
681
|
+
},
|
|
682
|
+
|
|
683
|
+
async seal(): Promise<void> {
|
|
684
|
+
unsealer?.seal();
|
|
685
|
+
unsealer = null;
|
|
686
|
+
pending?.unsealer.seal();
|
|
687
|
+
pending = null;
|
|
688
|
+
await db.execute(
|
|
689
|
+
`UPDATE oke_vault_status
|
|
690
|
+
SET sealed = true, last_sealed_at = now(), seal_count = seal_count + 1, updated_at = now()
|
|
691
|
+
WHERE id = 1`,
|
|
692
|
+
);
|
|
693
|
+
await record("seal", undefined);
|
|
694
|
+
},
|
|
695
|
+
|
|
696
|
+
async get(path: string, options?: VaultGetOptions): Promise<VaultSecret | undefined> {
|
|
697
|
+
const canonical = canonicalizePath(path);
|
|
698
|
+
await ensureReady();
|
|
699
|
+
requireUnsealer();
|
|
700
|
+
|
|
701
|
+
const row = await readSecretRow(canonical, options?.version);
|
|
702
|
+
if (!row) {
|
|
703
|
+
await record("get", options?.actor, {
|
|
704
|
+
path: canonical,
|
|
705
|
+
success: false,
|
|
706
|
+
errorCode: "SECRET_NOT_FOUND",
|
|
707
|
+
errorMessage: "vault: no such secret",
|
|
708
|
+
});
|
|
709
|
+
return undefined;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const expiresAt = row.expires_at === null ? undefined : toDate(row.expires_at);
|
|
713
|
+
if (expiresAt && expiresAt.getTime() <= Date.now() && options?.allowExpired !== true) {
|
|
714
|
+
await record("get", options?.actor, {
|
|
715
|
+
path: canonical,
|
|
716
|
+
success: false,
|
|
717
|
+
errorCode: "EXPIRED",
|
|
718
|
+
errorMessage: "vault: secret has expired",
|
|
719
|
+
});
|
|
720
|
+
throw new VaultError("EXPIRED", "vault: secret has expired");
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const keyRow = await readKeyRow(row.id);
|
|
724
|
+
const value = await decryptRow(row, keyRow);
|
|
725
|
+
await record("get", options?.actor, { path: canonical });
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
path: canonical,
|
|
729
|
+
value,
|
|
730
|
+
version: toInt(row.version),
|
|
731
|
+
metadata: toMetadata(row.metadata),
|
|
732
|
+
algorithm: row.algorithm as VaultAlgorithm,
|
|
733
|
+
kekVersion: toInt(keyRow.kek_version),
|
|
734
|
+
createdAt: toDate(row.created_at),
|
|
735
|
+
updatedAt: toDate(row.updated_at),
|
|
736
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
737
|
+
};
|
|
738
|
+
},
|
|
739
|
+
|
|
740
|
+
async set(path: string, value: string, options?: VaultSetOptions): Promise<VaultSecret> {
|
|
741
|
+
const canonical = canonicalizePath(path);
|
|
742
|
+
await ensureReady();
|
|
743
|
+
requireUnsealer();
|
|
744
|
+
return writeVersion(canonical, value, options, "set");
|
|
745
|
+
},
|
|
746
|
+
|
|
747
|
+
async rotate(path: string, value: string, options?: VaultSetOptions): Promise<VaultSecret> {
|
|
748
|
+
const canonical = canonicalizePath(path);
|
|
749
|
+
await ensureReady();
|
|
750
|
+
requireUnsealer();
|
|
751
|
+
return writeVersion(canonical, value, options, "rotate");
|
|
752
|
+
},
|
|
753
|
+
|
|
754
|
+
async delete(path: string, options?: { readonly actor?: VaultActor }): Promise<boolean> {
|
|
755
|
+
const canonical = canonicalizePath(path);
|
|
756
|
+
await ensureReady();
|
|
757
|
+
requireUnsealer();
|
|
758
|
+
const rows = await db.query<{ id: string }>(
|
|
759
|
+
`UPDATE oke_vault_secrets SET deleted_at = now(), updated_at = now()
|
|
760
|
+
WHERE path = $1 AND deleted_at IS NULL
|
|
761
|
+
RETURNING id`,
|
|
762
|
+
[canonical],
|
|
763
|
+
);
|
|
764
|
+
await record("delete", options?.actor, { path: canonical, success: rows.length > 0 });
|
|
765
|
+
return rows.length > 0;
|
|
766
|
+
},
|
|
767
|
+
|
|
768
|
+
async list(options?: VaultListOptions): Promise<readonly VaultListEntry[]> {
|
|
769
|
+
await ensureReady();
|
|
770
|
+
const prefix = canonicalizePrefix(options?.prefix);
|
|
771
|
+
const filters: string[] = [];
|
|
772
|
+
const params: unknown[] = [];
|
|
773
|
+
if (options?.includeDeleted !== true) filters.push("deleted_at IS NULL");
|
|
774
|
+
if (prefix !== undefined) {
|
|
775
|
+
params.push(prefix);
|
|
776
|
+
filters.push(`(path = $${params.length} OR path LIKE $${params.length} || '/%')`);
|
|
777
|
+
}
|
|
778
|
+
const where = filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "";
|
|
779
|
+
let limit = "";
|
|
780
|
+
if (options?.limit !== undefined && options.limit > 0) {
|
|
781
|
+
params.push(Math.floor(options.limit));
|
|
782
|
+
limit = `LIMIT $${params.length}`;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const rows = await db.query<{
|
|
786
|
+
path: string;
|
|
787
|
+
version: SqlNumber;
|
|
788
|
+
updated_at: Date | string;
|
|
789
|
+
expires_at: Date | string | null;
|
|
790
|
+
}>(
|
|
791
|
+
`SELECT DISTINCT ON (path) path, version, updated_at, expires_at
|
|
792
|
+
FROM oke_vault_secrets ${where}
|
|
793
|
+
ORDER BY path ASC, version DESC ${limit}`,
|
|
794
|
+
params,
|
|
795
|
+
);
|
|
796
|
+
|
|
797
|
+
await record("list", options?.actor);
|
|
798
|
+
return rows.map((row) => ({
|
|
799
|
+
path: row.path,
|
|
800
|
+
version: toInt(row.version),
|
|
801
|
+
updatedAt: toDate(row.updated_at),
|
|
802
|
+
...(row.expires_at === null ? {} : { expiresAt: toDate(row.expires_at) }),
|
|
803
|
+
}));
|
|
804
|
+
},
|
|
805
|
+
|
|
806
|
+
async status(): Promise<VaultStatus> {
|
|
807
|
+
const status = await readStatus();
|
|
808
|
+
const master = await db.query<MasterRow>(
|
|
809
|
+
`SELECT key_hash, kek_version FROM oke_vault_master ORDER BY created_at ASC LIMIT 1`,
|
|
810
|
+
);
|
|
811
|
+
const counted = await db.query<{ count: SqlNumber }>(
|
|
812
|
+
`SELECT COUNT(DISTINCT path) AS count FROM oke_vault_secrets WHERE deleted_at IS NULL`,
|
|
813
|
+
);
|
|
814
|
+
const target = status.rewrap_target_kek_version;
|
|
815
|
+
return {
|
|
816
|
+
sealed: unsealer === null || unsealer.sealed,
|
|
817
|
+
initialized: toBool(status.initialized),
|
|
818
|
+
masterKeyPresent: master.length > 0,
|
|
819
|
+
kekVersion: toInt(master[0]?.kek_version ?? 1),
|
|
820
|
+
secretCount: toInt(counted[0]?.count ?? 0),
|
|
821
|
+
sealCount: toInt(status.seal_count),
|
|
822
|
+
...(status.last_sealed_at === null ? {} : { lastSealedAt: toDate(status.last_sealed_at) }),
|
|
823
|
+
...(status.last_unsealed_at === null
|
|
824
|
+
? {}
|
|
825
|
+
: { lastUnsealedAt: toDate(status.last_unsealed_at) }),
|
|
826
|
+
...(status.rewrap_checkpoint === null
|
|
827
|
+
? {}
|
|
828
|
+
: { rewrapCheckpoint: status.rewrap_checkpoint }),
|
|
829
|
+
...(target === null ? {} : { rewrapTargetKekVersion: toInt(target) }),
|
|
830
|
+
};
|
|
831
|
+
},
|
|
832
|
+
|
|
833
|
+
async rotateMaster(newMasterKey?: Uint8Array): Promise<VaultRotateMasterResult> {
|
|
834
|
+
await ensureReady();
|
|
835
|
+
requireUnsealer();
|
|
836
|
+
if (pending) {
|
|
837
|
+
throw new VaultError("UNSUPPORTED", "vault: a master rotation is already in progress");
|
|
838
|
+
}
|
|
839
|
+
const master = await readMaster();
|
|
840
|
+
const target = toInt(master.kek_version) + 1;
|
|
841
|
+
|
|
842
|
+
const generated = newMasterKey === undefined;
|
|
843
|
+
const raw = newMasterKey ?? generateMasterKey();
|
|
844
|
+
let rotation: PendingRotation;
|
|
845
|
+
let encoded: string | undefined;
|
|
846
|
+
try {
|
|
847
|
+
rotation = {
|
|
848
|
+
unsealer: createMemoryUnsealer(raw),
|
|
849
|
+
kekVersion: target,
|
|
850
|
+
verifyHash: await deriveVerifyHash(raw),
|
|
851
|
+
};
|
|
852
|
+
if (generated) encoded = masterKeyToBase64(raw);
|
|
853
|
+
} finally {
|
|
854
|
+
if (generated) zeroBytes(raw);
|
|
855
|
+
}
|
|
856
|
+
pending = rotation;
|
|
857
|
+
|
|
858
|
+
await db.execute(
|
|
859
|
+
`UPDATE oke_vault_status
|
|
860
|
+
SET rewrap_target_kek_version = $1, rewrap_checkpoint = NULL,
|
|
861
|
+
rewrap_key_hash = $2, updated_at = now()
|
|
862
|
+
WHERE id = 1`,
|
|
863
|
+
[target, rotation.verifyHash],
|
|
864
|
+
);
|
|
865
|
+
const result = await rewrapBatch(rotation);
|
|
866
|
+
return encoded === undefined ? result : { ...result, masterKey: encoded };
|
|
867
|
+
},
|
|
868
|
+
|
|
869
|
+
async continueRotateMaster(
|
|
870
|
+
newMasterKey?: string | Uint8Array,
|
|
871
|
+
): Promise<VaultRotateMasterResult> {
|
|
872
|
+
await ensureReady();
|
|
873
|
+
requireUnsealer();
|
|
874
|
+
let rotation = pending;
|
|
875
|
+
if (!rotation) {
|
|
876
|
+
// Cold resume after process death: rebuild pending from the new key
|
|
877
|
+
// the operator saved when rotateMaster printed it.
|
|
878
|
+
const status = await readStatus();
|
|
879
|
+
const target = status.rewrap_target_kek_version;
|
|
880
|
+
const expectedHash = status.rewrap_key_hash;
|
|
881
|
+
if (target === null || expectedHash === null || expectedHash.length === 0) {
|
|
882
|
+
throw new VaultError("UNSUPPORTED", "vault: no master rotation is in progress");
|
|
883
|
+
}
|
|
884
|
+
if (newMasterKey === undefined) {
|
|
885
|
+
throw new VaultError(
|
|
886
|
+
"INVALID_KEY",
|
|
887
|
+
"vault: resume rotate-master with the new master key (--new-key / OKE_VAULT_NEW_MASTER_KEY)",
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
const raw =
|
|
891
|
+
typeof newMasterKey === "string"
|
|
892
|
+
? masterKeyFromBase64(newMasterKey)
|
|
893
|
+
: new Uint8Array(newMasterKey);
|
|
894
|
+
try {
|
|
895
|
+
const hash = await deriveVerifyHash(raw);
|
|
896
|
+
if (!constantTimeEqualStrings(hash, expectedHash)) {
|
|
897
|
+
throw new VaultError(
|
|
898
|
+
"INVALID_KEY",
|
|
899
|
+
"vault: new master key does not match in-flight rotation",
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
rotation = {
|
|
903
|
+
unsealer: createMemoryUnsealer(raw),
|
|
904
|
+
kekVersion: toInt(target),
|
|
905
|
+
verifyHash: hash,
|
|
906
|
+
};
|
|
907
|
+
pending = rotation;
|
|
908
|
+
} finally {
|
|
909
|
+
zeroBytes(raw);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
return rewrapBatch(rotation);
|
|
913
|
+
},
|
|
914
|
+
|
|
915
|
+
async purgeAuditBefore(before: Date): Promise<number> {
|
|
916
|
+
await ensureReady();
|
|
917
|
+
const removed = await purgeAuditRows(db, before);
|
|
918
|
+
await record("purge", undefined);
|
|
919
|
+
return removed;
|
|
920
|
+
},
|
|
921
|
+
|
|
922
|
+
async purgeExpired(options?: {
|
|
923
|
+
readonly before?: Date;
|
|
924
|
+
readonly dryRun?: boolean;
|
|
925
|
+
}): Promise<PurgeExpiredResult> {
|
|
926
|
+
await ensureReady();
|
|
927
|
+
requireUnsealer();
|
|
928
|
+
const before = options?.before ?? new Date();
|
|
929
|
+
const dryRun = options?.dryRun === true;
|
|
930
|
+
const result = await purgeExpiredSecrets(db, before, dryRun);
|
|
931
|
+
if (!dryRun) {
|
|
932
|
+
await record("purge", undefined, {
|
|
933
|
+
success: true,
|
|
934
|
+
errorMessage: `expired count=${result.count}`,
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
return result;
|
|
938
|
+
},
|
|
939
|
+
|
|
940
|
+
async verifyAudit(): Promise<AuditChainResult> {
|
|
941
|
+
await ensureReady();
|
|
942
|
+
return verifyAuditChain(db);
|
|
943
|
+
},
|
|
944
|
+
|
|
945
|
+
async listAudit(options?: AuditPageOptions): Promise<readonly VaultAuditRecord[]> {
|
|
946
|
+
await ensureReady();
|
|
947
|
+
return readAuditPage(db, options ?? {});
|
|
948
|
+
},
|
|
949
|
+
|
|
950
|
+
async exportBackup(): Promise<Uint8Array> {
|
|
951
|
+
await ensureReady();
|
|
952
|
+
const active = requireUnsealer();
|
|
953
|
+
const master = await readMaster();
|
|
954
|
+
const kekVersion = toInt(master.kek_version);
|
|
955
|
+
|
|
956
|
+
const rows = await db.query<SecretRow>(
|
|
957
|
+
`SELECT DISTINCT ON (path) id, path, encrypted_value, iv, auth_tag, version, metadata,
|
|
958
|
+
algorithm, kek_version, created_at, updated_at, expires_at
|
|
959
|
+
FROM oke_vault_secrets WHERE deleted_at IS NULL
|
|
960
|
+
ORDER BY path ASC, version DESC`,
|
|
961
|
+
);
|
|
962
|
+
const entries: BackupEntry[] = [];
|
|
963
|
+
for (const row of rows) {
|
|
964
|
+
entries.push({
|
|
965
|
+
path: row.path,
|
|
966
|
+
value: await decryptRow(row, await readKeyRow(row.id)),
|
|
967
|
+
version: toInt(row.version),
|
|
968
|
+
metadata: toMetadata(row.metadata),
|
|
969
|
+
expiresAt: row.expires_at === null ? null : toDate(row.expires_at).toISOString(),
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const payload = new TextEncoder().encode(JSON.stringify(entries));
|
|
974
|
+
let sealed: SealedBytes;
|
|
975
|
+
try {
|
|
976
|
+
sealed = await encryptBytes(
|
|
977
|
+
await active.unwrapBackupKek(),
|
|
978
|
+
payload,
|
|
979
|
+
buildAad(BACKUP_AAD_PATH, 1, ALGORITHM, kekVersion),
|
|
980
|
+
);
|
|
981
|
+
} finally {
|
|
982
|
+
zeroBytes(payload);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const metadata: BackupMetadata = {
|
|
986
|
+
format: 1,
|
|
987
|
+
createdAt: new Date().toISOString(),
|
|
988
|
+
kekVersion,
|
|
989
|
+
count: entries.length,
|
|
990
|
+
iv: toBase64(sealed.iv),
|
|
991
|
+
tag: toBase64(sealed.tag),
|
|
992
|
+
};
|
|
993
|
+
const header = new TextEncoder().encode(`${BACKUP_MAGIC}${JSON.stringify(metadata)}\n`);
|
|
994
|
+
const blob = new Uint8Array(header.byteLength + sealed.ciphertext.byteLength);
|
|
995
|
+
blob.set(header, 0);
|
|
996
|
+
blob.set(sealed.ciphertext, header.byteLength);
|
|
997
|
+
return blob;
|
|
998
|
+
},
|
|
999
|
+
|
|
1000
|
+
async importBackup(blob: Uint8Array): Promise<void> {
|
|
1001
|
+
await ensureReady();
|
|
1002
|
+
const active = requireUnsealer();
|
|
1003
|
+
const text = new TextDecoder().decode(blob.subarray(0, Math.min(blob.byteLength, 8192)));
|
|
1004
|
+
if (!text.startsWith(BACKUP_MAGIC)) {
|
|
1005
|
+
throw new VaultError("UNSUPPORTED", "vault: not an oke vault backup bundle");
|
|
1006
|
+
}
|
|
1007
|
+
const headerEnd = text.indexOf("\n", BACKUP_MAGIC.length);
|
|
1008
|
+
if (headerEnd < 0) {
|
|
1009
|
+
throw new VaultError("UNSUPPORTED", "vault: backup bundle header is truncated");
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
let metadata: BackupMetadata;
|
|
1013
|
+
try {
|
|
1014
|
+
metadata = JSON.parse(text.slice(BACKUP_MAGIC.length, headerEnd)) as BackupMetadata;
|
|
1015
|
+
} catch {
|
|
1016
|
+
throw new VaultError("UNSUPPORTED", "vault: backup bundle header is not valid JSON");
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const offset = new TextEncoder().encode(text.slice(0, headerEnd + 1)).byteLength;
|
|
1020
|
+
const plain = await decryptBytes(
|
|
1021
|
+
await active.unwrapBackupKek(),
|
|
1022
|
+
{
|
|
1023
|
+
iv: fromBase64(metadata.iv),
|
|
1024
|
+
ciphertext: blob.subarray(offset),
|
|
1025
|
+
tag: fromBase64(metadata.tag),
|
|
1026
|
+
},
|
|
1027
|
+
buildAad(BACKUP_AAD_PATH, 1, ALGORITHM, metadata.kekVersion),
|
|
1028
|
+
);
|
|
1029
|
+
|
|
1030
|
+
let entries: readonly BackupEntry[];
|
|
1031
|
+
try {
|
|
1032
|
+
entries = JSON.parse(new TextDecoder().decode(plain)) as readonly BackupEntry[];
|
|
1033
|
+
} catch {
|
|
1034
|
+
throw new VaultError("UNSUPPORTED", "vault: backup payload is not valid JSON");
|
|
1035
|
+
} finally {
|
|
1036
|
+
zeroBytes(plain);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
for (const entry of entries) {
|
|
1040
|
+
await writeVersion(
|
|
1041
|
+
canonicalizePath(entry.path),
|
|
1042
|
+
entry.value,
|
|
1043
|
+
{
|
|
1044
|
+
metadata: entry.metadata,
|
|
1045
|
+
...(entry.expiresAt === null ? {} : { expiresAt: new Date(entry.expiresAt) }),
|
|
1046
|
+
},
|
|
1047
|
+
"set",
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
},
|
|
1051
|
+
|
|
1052
|
+
getUnsealer(): Unsealer | null {
|
|
1053
|
+
return unsealer;
|
|
1054
|
+
},
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
return adapter;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Adapt a driver {@link import("../../drivers/types.ts").SqlConnection}-shaped
|
|
1062
|
+
* object to the {@link SqlExec} surface the Vault needs.
|
|
1063
|
+
*
|
|
1064
|
+
* Accepts either `exec` (the driver contract) or `execute`, so a thin custom
|
|
1065
|
+
* wrapper works without a shim.
|
|
1066
|
+
*
|
|
1067
|
+
* @param conn - Connection exposing `query` plus `exec` or `execute`
|
|
1068
|
+
*/
|
|
1069
|
+
export function sqlConnectionAsExec(conn: {
|
|
1070
|
+
query(sql: string, params?: readonly unknown[]): Promise<unknown[]>;
|
|
1071
|
+
exec?(sql: string, params?: readonly unknown[]): Promise<unknown>;
|
|
1072
|
+
execute?(sql: string, params?: readonly unknown[]): Promise<void>;
|
|
1073
|
+
}): SqlExec {
|
|
1074
|
+
return {
|
|
1075
|
+
async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
|
|
1076
|
+
return (await conn.query(sql, params ?? [])) as T[];
|
|
1077
|
+
},
|
|
1078
|
+
async execute(sql: string, params?: unknown[]): Promise<void> {
|
|
1079
|
+
if (conn.exec) {
|
|
1080
|
+
await conn.exec(sql, params ?? []);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
if (conn.execute) {
|
|
1084
|
+
await conn.execute(sql, params ?? []);
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
await conn.query(sql, params ?? []);
|
|
1088
|
+
},
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Resolve the absolute expiry for a write.
|
|
1094
|
+
*
|
|
1095
|
+
* @param options - Write options carrying `expiresAt` or `ttlMs`
|
|
1096
|
+
*/
|
|
1097
|
+
function resolveExpiry(options: VaultSetOptions | undefined): Date | undefined {
|
|
1098
|
+
if (options?.expiresAt) return options.expiresAt;
|
|
1099
|
+
if (options?.ttlMs !== undefined && options.ttlMs > 0) {
|
|
1100
|
+
return new Date(Date.now() + options.ttlMs);
|
|
1101
|
+
}
|
|
1102
|
+
return undefined;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Normalize a `bytea` column to bytes.
|
|
1107
|
+
*
|
|
1108
|
+
* Drivers return `Uint8Array`, a Node `Buffer`, or Postgres hex text
|
|
1109
|
+
* (`\x0a1b…`) depending on the transport.
|
|
1110
|
+
*
|
|
1111
|
+
* @param value - Raw column value
|
|
1112
|
+
*/
|
|
1113
|
+
function toBytes(value: unknown): Uint8Array {
|
|
1114
|
+
if (value instanceof Uint8Array) return value;
|
|
1115
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
1116
|
+
if (typeof value === "string") {
|
|
1117
|
+
return new Uint8Array(
|
|
1118
|
+
value.startsWith("\\x") ? Buffer.from(value.slice(2), "hex") : Buffer.from(value, "base64"),
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
throw new VaultError("BACKEND_ERROR", "vault: unexpected binary column encoding");
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Normalize a numeric column (drivers may return `bigint` counts as text).
|
|
1126
|
+
*
|
|
1127
|
+
* @param value - Raw column value
|
|
1128
|
+
*/
|
|
1129
|
+
function toInt(value: SqlNumber): number {
|
|
1130
|
+
return typeof value === "number" ? value : Number(value);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Normalize a boolean column.
|
|
1135
|
+
*
|
|
1136
|
+
* @param value - Raw column value
|
|
1137
|
+
*/
|
|
1138
|
+
function toBool(value: boolean | number): boolean {
|
|
1139
|
+
return value === true || value === 1;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Normalize a timestamp column to a `Date`.
|
|
1144
|
+
*
|
|
1145
|
+
* @param value - Raw column value
|
|
1146
|
+
*/
|
|
1147
|
+
function toDate(value: Date | string): Date {
|
|
1148
|
+
return value instanceof Date ? value : new Date(value);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Normalize a `jsonb` column to a plain object.
|
|
1153
|
+
*
|
|
1154
|
+
* @param value - Raw column value
|
|
1155
|
+
*/
|
|
1156
|
+
function toMetadata(value: unknown): Record<string, unknown> {
|
|
1157
|
+
if (typeof value === "string") {
|
|
1158
|
+
try {
|
|
1159
|
+
return JSON.parse(value) as Record<string, unknown>;
|
|
1160
|
+
} catch {
|
|
1161
|
+
return {};
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
if (value && typeof value === "object") return value as Record<string, unknown>;
|
|
1165
|
+
return {};
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Base64-encode bytes for a backup header.
|
|
1170
|
+
*
|
|
1171
|
+
* @param bytes - Buffer to encode
|
|
1172
|
+
*/
|
|
1173
|
+
function toBase64(bytes: Uint8Array): string {
|
|
1174
|
+
return Buffer.from(bytes).toString("base64");
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Decode base64 from a backup header.
|
|
1179
|
+
*
|
|
1180
|
+
* @param text - Base64 text
|
|
1181
|
+
*/
|
|
1182
|
+
function fromBase64(text: string): Uint8Array {
|
|
1183
|
+
return new Uint8Array(Buffer.from(text, "base64"));
|
|
1184
|
+
}
|