@cosmicdrift/kumiko-framework 0.1.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/README.md +159 -0
- package/package.json +91 -0
- package/src/__tests__/anonymous-access.integration.ts +325 -0
- package/src/__tests__/error-contract.integration.ts +435 -0
- package/src/__tests__/field-access.integration.ts +269 -0
- package/src/__tests__/full-stack.integration.ts +914 -0
- package/src/__tests__/ownership.integration.ts +449 -0
- package/src/__tests__/reference-data.integration.ts +198 -0
- package/src/__tests__/transition-guard.integration.ts +340 -0
- package/src/api/__tests__/api.test.ts +337 -0
- package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
- package/src/api/__tests__/batch.integration.ts +404 -0
- package/src/api/__tests__/body-limit.test.ts +88 -0
- package/src/api/__tests__/csrf-middleware.test.ts +97 -0
- package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
- package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
- package/src/api/__tests__/nested-write.integration.ts +213 -0
- package/src/api/__tests__/readiness.test.ts +76 -0
- package/src/api/__tests__/request-id-middleware.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +58 -0
- package/src/api/__tests__/sse-route.test.ts +112 -0
- package/src/api/anonymous-cookie.ts +60 -0
- package/src/api/api-constants.ts +64 -0
- package/src/api/auth-middleware.ts +418 -0
- package/src/api/auth-routes.ts +982 -0
- package/src/api/csrf-middleware.ts +77 -0
- package/src/api/index.ts +31 -0
- package/src/api/jwt.ts +66 -0
- package/src/api/observability-middleware.ts +89 -0
- package/src/api/readiness.ts +132 -0
- package/src/api/request-context.ts +49 -0
- package/src/api/request-id-middleware.ts +50 -0
- package/src/api/route-registrars.ts +195 -0
- package/src/api/routes.ts +135 -0
- package/src/api/server.ts +640 -0
- package/src/api/sse-broker.ts +71 -0
- package/src/api/sse-route.ts +62 -0
- package/src/api/tokens.ts +16 -0
- package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
- package/src/db/__tests__/compound-types.test.ts +114 -0
- package/src/db/__tests__/connection-options.test.ts +68 -0
- package/src/db/__tests__/cursor.test.ts +41 -0
- package/src/db/__tests__/db-helpers.test.ts +369 -0
- package/src/db/__tests__/dialect-instant.test.ts +50 -0
- package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
- package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
- package/src/db/__tests__/encryption.test.ts +39 -0
- package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
- package/src/db/__tests__/event-store-executor.integration.ts +235 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
- package/src/db/__tests__/located-timestamp.test.ts +184 -0
- package/src/db/__tests__/money.test.ts +199 -0
- package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
- package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
- package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
- package/src/db/__tests__/row-helpers.test.ts +59 -0
- package/src/db/__tests__/schema-migration.integration.ts +273 -0
- package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
- package/src/db/__tests__/table-builder-required.test.ts +216 -0
- package/src/db/__tests__/tenant-db.integration.ts +606 -0
- package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
- package/src/db/apply-entity-event.ts +188 -0
- package/src/db/assert-exists-in.ts +59 -0
- package/src/db/compound-types.ts +47 -0
- package/src/db/connection.ts +104 -0
- package/src/db/cursor.ts +83 -0
- package/src/db/dialect.ts +109 -0
- package/src/db/eagerload.ts +174 -0
- package/src/db/encryption.ts +39 -0
- package/src/db/event-store-executor.ts +906 -0
- package/src/db/index.ts +55 -0
- package/src/db/located-timestamp.ts +114 -0
- package/src/db/money.ts +120 -0
- package/src/db/pg-error.ts +46 -0
- package/src/db/reference-data.ts +77 -0
- package/src/db/row-helpers.ts +53 -0
- package/src/db/schema-inspection.ts +25 -0
- package/src/db/table-builder.ts +475 -0
- package/src/db/tenant-db.ts +434 -0
- package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
- package/src/engine/__tests__/boot-validator.test.ts +1865 -0
- package/src/engine/__tests__/build-app-schema.test.ts +154 -0
- package/src/engine/__tests__/claim-keys.test.ts +274 -0
- package/src/engine/__tests__/config-helpers.test.ts +236 -0
- package/src/engine/__tests__/effective-features.test.ts +86 -0
- package/src/engine/__tests__/engine.test.ts +1461 -0
- package/src/engine/__tests__/entity-handlers.test.ts +274 -0
- package/src/engine/__tests__/event-helpers.test.ts +68 -0
- package/src/engine/__tests__/extends-registrar.test.ts +159 -0
- package/src/engine/__tests__/factories-long-text.test.ts +84 -0
- package/src/engine/__tests__/factories-time.test.ts +158 -0
- package/src/engine/__tests__/field-predicates.test.ts +48 -0
- package/src/engine/__tests__/hook-phases.test.ts +132 -0
- package/src/engine/__tests__/identifiers.test.ts +35 -0
- package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
- package/src/engine/__tests__/nav.test.ts +267 -0
- package/src/engine/__tests__/ownership.test.ts +421 -0
- package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
- package/src/engine/__tests__/projection-helpers.test.ts +62 -0
- package/src/engine/__tests__/projection.test.ts +191 -0
- package/src/engine/__tests__/qualified-name.test.ts +264 -0
- package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
- package/src/engine/__tests__/run-in.test.ts +38 -0
- package/src/engine/__tests__/schema-builder.test.ts +380 -0
- package/src/engine/__tests__/screen.test.ts +408 -0
- package/src/engine/__tests__/state-machine.test.ts +148 -0
- package/src/engine/__tests__/system-user.test.ts +57 -0
- package/src/engine/__tests__/validation-hooks.test.ts +71 -0
- package/src/engine/access.ts +23 -0
- package/src/engine/boot-validator.ts +1528 -0
- package/src/engine/build-app-schema.ts +125 -0
- package/src/engine/config-helpers.ts +115 -0
- package/src/engine/constants.ts +85 -0
- package/src/engine/create-app.ts +98 -0
- package/src/engine/define-feature.ts +702 -0
- package/src/engine/define-handler.ts +78 -0
- package/src/engine/define-roles.ts +19 -0
- package/src/engine/effective-features.ts +87 -0
- package/src/engine/entity-handlers.ts +364 -0
- package/src/engine/event-helpers.ts +73 -0
- package/src/engine/factories.ts +328 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
- package/src/engine/feature-ast/extractors.ts +2562 -0
- package/src/engine/feature-ast/index.ts +105 -0
- package/src/engine/feature-ast/parse.ts +369 -0
- package/src/engine/feature-ast/patch.ts +525 -0
- package/src/engine/feature-ast/patcher.ts +518 -0
- package/src/engine/feature-ast/patterns.ts +434 -0
- package/src/engine/feature-ast/render.ts +602 -0
- package/src/engine/feature-ast/source-location.ts +45 -0
- package/src/engine/field-access.ts +120 -0
- package/src/engine/index.ts +254 -0
- package/src/engine/ownership.ts +337 -0
- package/src/engine/parse-ref-target.ts +22 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
- package/src/engine/pattern-library/index.ts +24 -0
- package/src/engine/pattern-library/library.ts +1117 -0
- package/src/engine/pattern-library/types.ts +255 -0
- package/src/engine/projection-helpers.ts +85 -0
- package/src/engine/qualified-name.ts +122 -0
- package/src/engine/read-claim.ts +31 -0
- package/src/engine/registry.ts +1325 -0
- package/src/engine/resolve-config-or-param.ts +153 -0
- package/src/engine/run-in.ts +29 -0
- package/src/engine/schema-builder.ts +175 -0
- package/src/engine/screen-filter-ops.ts +51 -0
- package/src/engine/state-machine.ts +70 -0
- package/src/engine/system-user.ts +32 -0
- package/src/engine/types/config.ts +306 -0
- package/src/engine/types/event-type-map.ts +37 -0
- package/src/engine/types/feature.ts +574 -0
- package/src/engine/types/fields.ts +422 -0
- package/src/engine/types/handlers.ts +742 -0
- package/src/engine/types/hooks.ts +142 -0
- package/src/engine/types/http-route.ts +54 -0
- package/src/engine/types/identifiers.ts +47 -0
- package/src/engine/types/index.ts +208 -0
- package/src/engine/types/nav.ts +46 -0
- package/src/engine/types/projection.ts +132 -0
- package/src/engine/types/relations.ts +51 -0
- package/src/engine/types/screen.ts +452 -0
- package/src/engine/types/workspace.ts +42 -0
- package/src/engine/validation.ts +33 -0
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
- package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
- package/src/entrypoint/index.ts +442 -0
- package/src/errors/__tests__/classes.test.ts +371 -0
- package/src/errors/__tests__/write-failures.test.ts +109 -0
- package/src/errors/classes.ts +249 -0
- package/src/errors/i18n/de.yaml +83 -0
- package/src/errors/i18n/en.yaml +80 -0
- package/src/errors/index.ts +41 -0
- package/src/errors/kumiko-error.ts +67 -0
- package/src/errors/reasons.ts +36 -0
- package/src/errors/serialize.ts +136 -0
- package/src/errors/transition-details.ts +30 -0
- package/src/errors/write-error-info.ts +123 -0
- package/src/errors/zod-bridge.ts +49 -0
- package/src/event-store/__tests__/admin-api.integration.ts +361 -0
- package/src/event-store/__tests__/event-store.integration.ts +584 -0
- package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
- package/src/event-store/__tests__/perf.integration.ts +255 -0
- package/src/event-store/__tests__/snapshot.integration.ts +267 -0
- package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
- package/src/event-store/__tests__/upcaster.integration.ts +460 -0
- package/src/event-store/admin-api.ts +257 -0
- package/src/event-store/archive.ts +106 -0
- package/src/event-store/errors.ts +35 -0
- package/src/event-store/event-store.ts +405 -0
- package/src/event-store/events-schema.ts +90 -0
- package/src/event-store/index.ts +50 -0
- package/src/event-store/snapshot.ts +210 -0
- package/src/event-store/upcaster-dead-letter.ts +119 -0
- package/src/event-store/upcaster.ts +147 -0
- package/src/files/__tests__/content-disposition.test.ts +123 -0
- package/src/files/__tests__/file-field-column.integration.ts +103 -0
- package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
- package/src/files/__tests__/file-handle.test.ts +122 -0
- package/src/files/__tests__/files.integration.ts +830 -0
- package/src/files/__tests__/storage-tracking.integration.ts +153 -0
- package/src/files/content-disposition.ts +55 -0
- package/src/files/file-handle.ts +63 -0
- package/src/files/file-ref-table.ts +22 -0
- package/src/files/file-routes.ts +353 -0
- package/src/files/in-memory-provider.ts +62 -0
- package/src/files/index.ts +29 -0
- package/src/files/local-provider.ts +35 -0
- package/src/files/storage-tracking.ts +60 -0
- package/src/files/types.ts +118 -0
- package/src/i18n/__tests__/i18n.test.ts +72 -0
- package/src/i18n/index.ts +29 -0
- package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
- package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
- package/src/jobs/__tests__/jobs.integration.ts +566 -0
- package/src/jobs/index.ts +2 -0
- package/src/jobs/job-runner.ts +574 -0
- package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
- package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
- package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
- package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
- package/src/lifecycle/index.ts +13 -0
- package/src/lifecycle/lifecycle.ts +160 -0
- package/src/lifecycle/signal-handlers.ts +62 -0
- package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
- package/src/logging/index.ts +3 -0
- package/src/logging/pino-logger.ts +64 -0
- package/src/logging/types.ts +7 -0
- package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
- package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
- package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
- package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
- package/src/migrations/index.ts +28 -0
- package/src/migrations/projection-detection.ts +149 -0
- package/src/migrations/rebuild-marker.ts +64 -0
- package/src/migrations/schema-drift.ts +395 -0
- package/src/observability/__tests__/console-provider.test.ts +67 -0
- package/src/observability/__tests__/metric-validator.test.ts +87 -0
- package/src/observability/__tests__/noop-provider.test.ts +82 -0
- package/src/observability/__tests__/observability.integration.ts +559 -0
- package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
- package/src/observability/__tests__/recording-meter.test.ts +101 -0
- package/src/observability/__tests__/recording-tracer.test.ts +110 -0
- package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
- package/src/observability/console-provider.ts +130 -0
- package/src/observability/context.ts +26 -0
- package/src/observability/fallback.ts +34 -0
- package/src/observability/ids.ts +25 -0
- package/src/observability/index.ts +79 -0
- package/src/observability/metric-validator.ts +86 -0
- package/src/observability/metrics-handle.ts +56 -0
- package/src/observability/noop-provider.ts +146 -0
- package/src/observability/prometheus-meter.ts +284 -0
- package/src/observability/recording-meter.ts +156 -0
- package/src/observability/recording-tracer.ts +198 -0
- package/src/observability/redis-wrapper.ts +132 -0
- package/src/observability/sensitive-filter.ts +108 -0
- package/src/observability/standard-metrics.ts +213 -0
- package/src/observability/types/index.ts +29 -0
- package/src/observability/types/metric.ts +56 -0
- package/src/observability/types/provider.ts +32 -0
- package/src/observability/types/span.ts +64 -0
- package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
- package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
- package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
- package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
- package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
- package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
- package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
- package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
- package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
- package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
- package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
- package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
- package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
- package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
- package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
- package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
- package/src/pipeline/append-event-core.ts +117 -0
- package/src/pipeline/auth-claims-resolver.ts +103 -0
- package/src/pipeline/cascade-handler.ts +113 -0
- package/src/pipeline/dispatcher.ts +1585 -0
- package/src/pipeline/distributed-lock.ts +37 -0
- package/src/pipeline/entity-cache.ts +113 -0
- package/src/pipeline/event-consumer-state.ts +108 -0
- package/src/pipeline/event-dedup.ts +23 -0
- package/src/pipeline/event-dispatcher.ts +1016 -0
- package/src/pipeline/event-retention.ts +154 -0
- package/src/pipeline/idempotency.ts +76 -0
- package/src/pipeline/index.ts +66 -0
- package/src/pipeline/lifecycle-pipeline.ts +409 -0
- package/src/pipeline/msp-rebuild.ts +242 -0
- package/src/pipeline/multi-stream-apply-context.ts +115 -0
- package/src/pipeline/projection-rebuild.ts +334 -0
- package/src/pipeline/projection-state.ts +72 -0
- package/src/pipeline/projections-runner.ts +56 -0
- package/src/pipeline/redis-keys.ts +11 -0
- package/src/pipeline/system-hooks.ts +190 -0
- package/src/random/__tests__/generate.test.ts +149 -0
- package/src/random/generate.ts +141 -0
- package/src/random/index.ts +8 -0
- package/src/random/words.ts +392 -0
- package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
- package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
- package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
- package/src/rate-limit/bucket.ts +36 -0
- package/src/rate-limit/index.ts +14 -0
- package/src/rate-limit/middleware.ts +152 -0
- package/src/rate-limit/resolver.ts +267 -0
- package/src/redis/__tests__/redis-options.test.ts +54 -0
- package/src/redis/index.ts +74 -0
- package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
- package/src/search/__tests__/search-adapter.test.ts +256 -0
- package/src/search/in-memory-adapter.ts +123 -0
- package/src/search/index.ts +12 -0
- package/src/search/meilisearch-adapter.ts +106 -0
- package/src/search/types.ts +39 -0
- package/src/secrets/__tests__/dek-cache.test.ts +213 -0
- package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
- package/src/secrets/__tests__/envelope.test.ts +74 -0
- package/src/secrets/__tests__/leak-guard.test.ts +92 -0
- package/src/secrets/__tests__/rotation.test.ts +149 -0
- package/src/secrets/dek-cache.ts +116 -0
- package/src/secrets/env-master-key-provider.ts +162 -0
- package/src/secrets/envelope.ts +55 -0
- package/src/secrets/index.ts +19 -0
- package/src/secrets/leak-guard.ts +87 -0
- package/src/secrets/rotation.ts +34 -0
- package/src/secrets/types.ts +107 -0
- package/src/stack/db.ts +104 -0
- package/src/stack/event-collector.ts +23 -0
- package/src/stack/index.ts +32 -0
- package/src/stack/redis.ts +44 -0
- package/src/stack/request-helper.ts +168 -0
- package/src/stack/table-helpers.ts +104 -0
- package/src/stack/test-stack.ts +357 -0
- package/src/stack/test-users.ts +37 -0
- package/src/testing/__tests__/e2e-generator.test.ts +230 -0
- package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
- package/src/testing/access-assertions.ts +15 -0
- package/src/testing/assertions.ts +35 -0
- package/src/testing/e2e-generator.ts +465 -0
- package/src/testing/expect-error.ts +25 -0
- package/src/testing/handler-context.ts +125 -0
- package/src/testing/http-cookies.ts +52 -0
- package/src/testing/index.ts +41 -0
- package/src/testing/late-bound.ts +39 -0
- package/src/testing/mutable-master-key-provider.ts +31 -0
- package/src/testing/observability-recorder.ts +54 -0
- package/src/testing/shared-entities.ts +49 -0
- package/src/testing/utils.ts +1 -0
- package/src/testing/wait-for.ts +31 -0
- package/src/time/__tests__/polyfill.test.ts +73 -0
- package/src/time/__tests__/tz-context.test.ts +121 -0
- package/src/time/index.ts +21 -0
- package/src/time/polyfill.ts +70 -0
- package/src/time/tz-context.ts +107 -0
- package/src/ui-types/app-schema.ts +57 -0
- package/src/ui-types/index.ts +65 -0
- package/src/utils/__tests__/assert.test.ts +17 -0
- package/src/utils/__tests__/env-parse.test.ts +54 -0
- package/src/utils/assert.ts +18 -0
- package/src/utils/env-parse.ts +16 -0
- package/src/utils/ids.ts +16 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/safe-json.ts +30 -0
- package/src/utils/serialization.ts +7 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { assertNoSecretLeak } from "../leak-guard";
|
|
3
|
+
import { createSecret } from "../types";
|
|
4
|
+
|
|
5
|
+
describe("assertNoSecretLeak — walks the response tree for branded values", () => {
|
|
6
|
+
test("plain data passes through silently", () => {
|
|
7
|
+
expect(() =>
|
|
8
|
+
assertNoSecretLeak({
|
|
9
|
+
id: "42",
|
|
10
|
+
title: "foo",
|
|
11
|
+
tags: ["a", "b"],
|
|
12
|
+
nested: { deep: { value: 12 } },
|
|
13
|
+
}),
|
|
14
|
+
).not.toThrow();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("throws when Secret<> sits at the root", () => {
|
|
18
|
+
const s = createSecret("plaintext-api-key");
|
|
19
|
+
expect(() => assertNoSecretLeak(s)).toThrow(/leaked.*at \$/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("throws when Secret<> is nested in an object", () => {
|
|
23
|
+
const payload = {
|
|
24
|
+
id: "42",
|
|
25
|
+
apiKey: createSecret("leak-me"),
|
|
26
|
+
};
|
|
27
|
+
expect(() => assertNoSecretLeak(payload)).toThrow(/leaked.*at \$\.apiKey/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("throws when Secret<> is inside an array", () => {
|
|
31
|
+
const payload = {
|
|
32
|
+
keys: [createSecret("one"), "two"],
|
|
33
|
+
};
|
|
34
|
+
expect(() => assertNoSecretLeak(payload)).toThrow(/leaked.*at \$\.keys\[0\]/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("reports path to the first offending node", () => {
|
|
38
|
+
const payload = {
|
|
39
|
+
outer: {
|
|
40
|
+
middle: [null, { deeper: createSecret("gotcha") }],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
expect(() => assertNoSecretLeak(payload)).toThrow(/leaked.*at \$\.outer\.middle\[1\]\.deeper/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("skips opaque class instances (Date, Buffer) so they don't false-positive", () => {
|
|
47
|
+
// Date and Buffer have internal slots the walker can't introspect — non-
|
|
48
|
+
// plain prototype, no entry-iterator we trust. Stopping here is the
|
|
49
|
+
// conservative call. Map and Set are NOT in this list (see next two tests):
|
|
50
|
+
// they have well-defined iteration and a toJSON-via-custom-serializer can
|
|
51
|
+
// expand them onto the wire, so we walk them.
|
|
52
|
+
const payload = {
|
|
53
|
+
createdAt: new Date("2024-01-01"),
|
|
54
|
+
binary: Buffer.from("hello"),
|
|
55
|
+
};
|
|
56
|
+
expect(() => assertNoSecretLeak(payload)).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("walks Set entries and throws when Secret<> sits inside", () => {
|
|
60
|
+
const payload = {
|
|
61
|
+
keys: new Set([createSecret("hidden")]),
|
|
62
|
+
};
|
|
63
|
+
expect(() => assertNoSecretLeak(payload)).toThrow(/leaked.*at \$\.keys\.<set\[0\]>/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("walks Map values and throws when Secret<> sits inside", () => {
|
|
67
|
+
const payload = {
|
|
68
|
+
registry: new Map([["api", createSecret("hidden")]]),
|
|
69
|
+
};
|
|
70
|
+
expect(() => assertNoSecretLeak(payload)).toThrow(/leaked.*at \$\.registry\.<map\[0\]\.val>/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("Set of plain values stays silent — walking doesn't false-positive", () => {
|
|
74
|
+
const payload = {
|
|
75
|
+
ids: new Set(["a", "b"]),
|
|
76
|
+
tags: new Map([["env", "prod"]]),
|
|
77
|
+
};
|
|
78
|
+
expect(() => assertNoSecretLeak(payload)).not.toThrow();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("stops at MAX_DEPTH so cyclic or pathologically-deep input can't hang", () => {
|
|
82
|
+
// Build a cyclic object — without the depth cap this would recurse forever.
|
|
83
|
+
const root: Record<string, unknown> = { id: "root" };
|
|
84
|
+
root["self"] = root;
|
|
85
|
+
expect(() => assertNoSecretLeak(root)).not.toThrow();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("undefined and null are no-ops", () => {
|
|
89
|
+
expect(() => assertNoSecretLeak(undefined)).not.toThrow();
|
|
90
|
+
expect(() => assertNoSecretLeak(null)).not.toThrow();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { createEnvMasterKeyProvider } from "../env-master-key-provider";
|
|
4
|
+
import { decryptValue, encryptValue } from "../envelope";
|
|
5
|
+
import { rewrapDek } from "../rotation";
|
|
6
|
+
|
|
7
|
+
// Shared KEK bytes across provider reconstructions — mimics ops setting
|
|
8
|
+
// the same env var across deploys. This is how we model "the KEK didn't
|
|
9
|
+
// change but CURRENT_VERSION did" in a single test process.
|
|
10
|
+
const KEK_V1 = randomBytes(32);
|
|
11
|
+
const KEK_V2 = randomBytes(32);
|
|
12
|
+
|
|
13
|
+
function providerWithCurrent(
|
|
14
|
+
v: number,
|
|
15
|
+
extraKeys: boolean = true,
|
|
16
|
+
): ReturnType<typeof createEnvMasterKeyProvider> {
|
|
17
|
+
const envVars: Record<string, string> = {
|
|
18
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: KEK_V1.toString("base64"),
|
|
19
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: String(v),
|
|
20
|
+
};
|
|
21
|
+
if (extraKeys) {
|
|
22
|
+
envVars["KUMIKO_SECRETS_MASTER_KEY_V2"] = KEK_V2.toString("base64");
|
|
23
|
+
}
|
|
24
|
+
return createEnvMasterKeyProvider({ env: envVars });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("rotation — the whole point of the multi-version keyring", () => {
|
|
28
|
+
test("a value encrypted with V1 stays readable after CURRENT flips to V2", async () => {
|
|
29
|
+
// Day 1: ops has only V1 deployed, current=1. We encrypt a secret.
|
|
30
|
+
const day1 = providerWithCurrent(1, /* extraKeys */ false);
|
|
31
|
+
const envelope = await encryptValue("my-stripe-key", day1);
|
|
32
|
+
expect(envelope.kekVersion).toBe(1);
|
|
33
|
+
|
|
34
|
+
// Day 3: ops deployed V2 ENV too, and has now flipped current=2.
|
|
35
|
+
// The keyring still contains V1 so old rows must keep reading.
|
|
36
|
+
const day3 = providerWithCurrent(2);
|
|
37
|
+
const back = await decryptValue(envelope, day3);
|
|
38
|
+
expect(back).toBe("my-stripe-key");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("after rotation, new writes land on the new version", async () => {
|
|
42
|
+
const day3 = providerWithCurrent(2);
|
|
43
|
+
const envelope = await encryptValue("fresh secret", day3);
|
|
44
|
+
// The point: without touching rewrap logic at all, simply having
|
|
45
|
+
// flipped CURRENT_VERSION means new values use V2.
|
|
46
|
+
expect(envelope.kekVersion).toBe(2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("rewrapDek migrates a V1-envelope to V2 without touching ciphertext", async () => {
|
|
50
|
+
// Encrypt under V1 (no V2 yet) so we have a pure V1 row.
|
|
51
|
+
const day1 = providerWithCurrent(1, /* extraKeys */ false);
|
|
52
|
+
const originalEnvelope = await encryptValue("sensitive", day1);
|
|
53
|
+
expect(originalEnvelope.kekVersion).toBe(1);
|
|
54
|
+
|
|
55
|
+
// Later, ops has rolled V2 and flipped current.
|
|
56
|
+
const day3 = providerWithCurrent(2);
|
|
57
|
+
const rotated = await rewrapDek(originalEnvelope, day3);
|
|
58
|
+
|
|
59
|
+
// Key property: DEK got re-wrapped, ciphertext untouched. This is
|
|
60
|
+
// WHY envelope rotation is cheap — we only rewrite a ~60-byte blob
|
|
61
|
+
// per row, never the potentially large ciphertext.
|
|
62
|
+
expect(rotated.kekVersion).toBe(2);
|
|
63
|
+
expect(rotated.ciphertext.equals(originalEnvelope.ciphertext)).toBe(true);
|
|
64
|
+
expect(rotated.iv.equals(originalEnvelope.iv)).toBe(true);
|
|
65
|
+
expect(rotated.authTag.equals(originalEnvelope.authTag)).toBe(true);
|
|
66
|
+
// The wrapped DEK DID change — it's now wrapped with KEK V2.
|
|
67
|
+
expect(rotated.encryptedDek.equals(originalEnvelope.encryptedDek)).toBe(false);
|
|
68
|
+
|
|
69
|
+
// Most important: the rotated envelope still decrypts correctly.
|
|
70
|
+
expect(await decryptValue(rotated, day3)).toBe("sensitive");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("rewrapDek is a no-op when the envelope is already current", async () => {
|
|
74
|
+
const day3 = providerWithCurrent(2);
|
|
75
|
+
const envelope = await encryptValue("already-v2", day3);
|
|
76
|
+
expect(envelope.kekVersion).toBe(2);
|
|
77
|
+
|
|
78
|
+
const rotated = await rewrapDek(envelope, day3);
|
|
79
|
+
// Same reference — no work was done.
|
|
80
|
+
expect(rotated).toBe(envelope);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("rewrap requires the OLD version to still be in the keyring", async () => {
|
|
84
|
+
// Encrypt under V1
|
|
85
|
+
const day1 = providerWithCurrent(1, /* extraKeys */ false);
|
|
86
|
+
const envelope = await encryptValue("stranded", day1);
|
|
87
|
+
|
|
88
|
+
// Day 5: ops RETIRED V1 — env no longer has KUMIKO_SECRETS_MASTER_KEY_V1,
|
|
89
|
+
// only V2. An old row with kekVersion=1 is now unreadable.
|
|
90
|
+
const dayRetired = createEnvMasterKeyProvider({
|
|
91
|
+
env: {
|
|
92
|
+
KUMIKO_SECRETS_MASTER_KEY_V2: KEK_V2.toString("base64"),
|
|
93
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
await expect(rewrapDek(envelope, dayRetired)).rejects.toThrow(/no KEK for version 1/);
|
|
97
|
+
// Lesson: ops MUST rotate all rows off V1 before deleting V1 from env.
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("full rotation drill: encrypt V1 → staging V1+V2 → promote to V2 → rewrap → retire V1", async () => {
|
|
101
|
+
// Simulates the canonical rotation sequence end-to-end.
|
|
102
|
+
|
|
103
|
+
// --- Day 1: only V1, CURRENT=1 ---
|
|
104
|
+
const day1 = providerWithCurrent(1, /* extraKeys */ false);
|
|
105
|
+
const envA = await encryptValue("value-A", day1);
|
|
106
|
+
const envB = await encryptValue("value-B", day1);
|
|
107
|
+
expect(envA.kekVersion).toBe(1);
|
|
108
|
+
expect(envB.kekVersion).toBe(1);
|
|
109
|
+
|
|
110
|
+
// --- Day 2: V1+V2 deployed, CURRENT still 1 (staging) ---
|
|
111
|
+
const day2 = providerWithCurrent(1, /* extraKeys */ true);
|
|
112
|
+
// Both old rows still read
|
|
113
|
+
expect(await decryptValue(envA, day2)).toBe("value-A");
|
|
114
|
+
expect(await decryptValue(envB, day2)).toBe("value-B");
|
|
115
|
+
// New writes: still V1 — crucial! CURRENT hasn't flipped.
|
|
116
|
+
const envC = await encryptValue("value-C", day2);
|
|
117
|
+
expect(envC.kekVersion).toBe(1);
|
|
118
|
+
|
|
119
|
+
// --- Day 3: CURRENT flipped to 2 ---
|
|
120
|
+
const day3 = providerWithCurrent(2, /* extraKeys */ true);
|
|
121
|
+
// Old rows still readable
|
|
122
|
+
expect(await decryptValue(envA, day3)).toBe("value-A");
|
|
123
|
+
// New writes now land on V2
|
|
124
|
+
const envD = await encryptValue("value-D", day3);
|
|
125
|
+
expect(envD.kekVersion).toBe(2);
|
|
126
|
+
|
|
127
|
+
// --- Day 4: rotation job migrates all V1 rows ---
|
|
128
|
+
const rotA = await rewrapDek(envA, day3);
|
|
129
|
+
const rotB = await rewrapDek(envB, day3);
|
|
130
|
+
const rotC = await rewrapDek(envC, day3);
|
|
131
|
+
expect(rotA.kekVersion).toBe(2);
|
|
132
|
+
expect(rotB.kekVersion).toBe(2);
|
|
133
|
+
expect(rotC.kekVersion).toBe(2);
|
|
134
|
+
expect(await decryptValue(rotA, day3)).toBe("value-A");
|
|
135
|
+
|
|
136
|
+
// --- Day 5: V1 retired from env ---
|
|
137
|
+
const day5 = createEnvMasterKeyProvider({
|
|
138
|
+
env: {
|
|
139
|
+
KUMIKO_SECRETS_MASTER_KEY_V2: KEK_V2.toString("base64"),
|
|
140
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
// All rotated rows still decrypt
|
|
144
|
+
expect(await decryptValue(rotA, day5)).toBe("value-A");
|
|
145
|
+
expect(await decryptValue(rotB, day5)).toBe("value-B");
|
|
146
|
+
expect(await decryptValue(rotC, day5)).toBe("value-C");
|
|
147
|
+
expect(await decryptValue(envD, day5)).toBe("value-D");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// In-memory cache for unwrapped DEKs. Cloud-KMS unwrap calls are expensive
|
|
2
|
+
// ($ + network). Caching for a short TTL amortises the cost across reads
|
|
3
|
+
// of the same secret. The TTL bounds the time plaintext DEKs live in the
|
|
4
|
+
// process — shorter is safer, longer is cheaper.
|
|
5
|
+
//
|
|
6
|
+
// Two bounds are enforced so the cache can't leak memory under adversarial
|
|
7
|
+
// or skewed workloads:
|
|
8
|
+
// - TTL per entry (default 5min): old DEKs expire even if never reused.
|
|
9
|
+
// - maxEntries (default 1000): LRU eviction kicks in on insert when full.
|
|
10
|
+
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import type { MasterKeyProvider } from "./types";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
15
|
+
const DEFAULT_MAX_ENTRIES = 1000;
|
|
16
|
+
|
|
17
|
+
export type DekCacheOptions = {
|
|
18
|
+
readonly ttlMs?: number;
|
|
19
|
+
// Cap on the number of cached DEKs. On overflow, least-recently-used
|
|
20
|
+
// entries are evicted (their bytes zeroed).
|
|
21
|
+
readonly maxEntries?: number;
|
|
22
|
+
// Injectable clock for deterministic tests.
|
|
23
|
+
readonly now?: () => number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type DekCache = {
|
|
27
|
+
// Unwrap via the provider, caching the result. Second call within TTL
|
|
28
|
+
// returns the cached DEK without hitting the provider.
|
|
29
|
+
unwrapDek(encryptedDek: Buffer, kekVersion: number, provider: MasterKeyProvider): Promise<Buffer>;
|
|
30
|
+
|
|
31
|
+
// Drop every entry. Call after KEK rotation so old cached DEKs (still
|
|
32
|
+
// valid, but referencing the old kekVersion) don't serve reads that
|
|
33
|
+
// could otherwise detect the version change.
|
|
34
|
+
clear(): void;
|
|
35
|
+
|
|
36
|
+
// Observability: how many entries are live right now (pre-TTL-prune).
|
|
37
|
+
size(): number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function createDekCache(opts: DekCacheOptions = {}): DekCache {
|
|
41
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
42
|
+
const maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
43
|
+
const now = opts.now ?? (() => Date.now());
|
|
44
|
+
// Key is a SHA-256 hash of (encryptedDek || kekVersion). Hashing avoids
|
|
45
|
+
// keeping the raw wrapped bytes as the Map key (which would pin them
|
|
46
|
+
// in memory) and ensures version is part of the identity — same
|
|
47
|
+
// encryptedDek at different versions cannot collide.
|
|
48
|
+
//
|
|
49
|
+
// JS Map keeps insertion order — we exploit that for LRU: on hit we
|
|
50
|
+
// delete+re-insert the entry, which moves it to the "most recent" end.
|
|
51
|
+
// On overflow the first key (oldest) gets evicted.
|
|
52
|
+
const entries = new Map<string, { dek: Buffer; expiresAt: number }>();
|
|
53
|
+
|
|
54
|
+
function cacheKey(encryptedDek: Buffer, kekVersion: number): string {
|
|
55
|
+
// kekVersion as 4 bytes — handles realistic rotation counts without
|
|
56
|
+
// truncating (1-byte would wrap at 256, which IS achievable over a
|
|
57
|
+
// decade of weekly rotations).
|
|
58
|
+
const versionBuf = Buffer.alloc(4);
|
|
59
|
+
versionBuf.writeUInt32BE(kekVersion);
|
|
60
|
+
return createHash("sha256").update(encryptedDek).update(versionBuf).digest("hex");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function evictOldestIfFull(): void {
|
|
64
|
+
// skip: under cap, no eviction needed.
|
|
65
|
+
if (entries.size < maxEntries) return;
|
|
66
|
+
// First key is the least-recently-inserted / least-recently-touched
|
|
67
|
+
// thanks to the delete+set dance below. iterator().next() is O(1).
|
|
68
|
+
const oldestKey = entries.keys().next().value;
|
|
69
|
+
// skip: Map was empty mid-check — no entry to evict.
|
|
70
|
+
if (oldestKey === undefined) return;
|
|
71
|
+
const oldest = entries.get(oldestKey);
|
|
72
|
+
if (oldest) oldest.dek.fill(0);
|
|
73
|
+
entries.delete(oldestKey);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
async unwrapDek(encryptedDek, kekVersion, provider) {
|
|
78
|
+
const key = cacheKey(encryptedDek, kekVersion);
|
|
79
|
+
const hit = entries.get(key);
|
|
80
|
+
if (hit && hit.expiresAt > now()) {
|
|
81
|
+
// Touch: re-insert to move to the "most recent" end of the Map.
|
|
82
|
+
// Without this the LRU would collapse into FIFO.
|
|
83
|
+
entries.delete(key);
|
|
84
|
+
entries.set(key, hit);
|
|
85
|
+
// Return a copy so callers that .fill(0) after use don't wipe the
|
|
86
|
+
// cached buffer for everyone else.
|
|
87
|
+
return Buffer.from(hit.dek);
|
|
88
|
+
}
|
|
89
|
+
// Prune the expired entry on miss to bound memory.
|
|
90
|
+
if (hit) {
|
|
91
|
+
hit.dek.fill(0);
|
|
92
|
+
entries.delete(key);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const dek = await provider.unwrapDek(encryptedDek, kekVersion);
|
|
96
|
+
evictOldestIfFull();
|
|
97
|
+
// Store a copy — caller can zero its own buffer after use.
|
|
98
|
+
entries.set(key, { dek: Buffer.from(dek), expiresAt: now() + ttlMs });
|
|
99
|
+
return dek;
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
clear() {
|
|
103
|
+
// Zero the DEK bytes before dropping references. Best-effort —
|
|
104
|
+
// Node can't guarantee secure erase, but clearing now prevents
|
|
105
|
+
// stale key material from lingering in heap snapshots.
|
|
106
|
+
for (const { dek } of entries.values()) {
|
|
107
|
+
dek.fill(0);
|
|
108
|
+
}
|
|
109
|
+
entries.clear();
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
size() {
|
|
113
|
+
return entries.size;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Default MasterKeyProvider for deployments without a dedicated KMS. Reads
|
|
2
|
+
// KEK material from env, supports a multi-version keyring so rotation runs
|
|
3
|
+
// without a maintenance window. See loadKeyring + resolveCurrentVersion at
|
|
4
|
+
// the bottom for the env contract.
|
|
5
|
+
|
|
6
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
7
|
+
import { InternalError } from "../errors";
|
|
8
|
+
import type { MasterKeyProvider } from "./types";
|
|
9
|
+
|
|
10
|
+
const ALGORITHM = "aes-256-gcm";
|
|
11
|
+
const KEK_LENGTH = 32; // AES-256
|
|
12
|
+
const IV_LENGTH = 12;
|
|
13
|
+
const TAG_LENGTH = 16;
|
|
14
|
+
|
|
15
|
+
export type EnvMasterKeyProviderOptions = {
|
|
16
|
+
// Env accessor (injectable for tests). Defaults to process.env.
|
|
17
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// A parsed keyring: version number → raw KEK bytes. Kept separate from
|
|
21
|
+
// the provider closure so loadKeyring is unit-testable in isolation.
|
|
22
|
+
export type Keyring = ReadonlyMap<number, Buffer>;
|
|
23
|
+
|
|
24
|
+
export function createEnvMasterKeyProvider(
|
|
25
|
+
opts: EnvMasterKeyProviderOptions = {},
|
|
26
|
+
): MasterKeyProvider {
|
|
27
|
+
const env = opts.env ?? process.env;
|
|
28
|
+
|
|
29
|
+
const keyring = loadKeyring(env);
|
|
30
|
+
const current = resolveCurrentVersion(env);
|
|
31
|
+
|
|
32
|
+
// --- Boot validation (after keyring + current are known) -------------
|
|
33
|
+
if (!keyring.has(current)) {
|
|
34
|
+
throw new InternalError({
|
|
35
|
+
message:
|
|
36
|
+
`[secrets] currentVersion=${current} not present in keyring ` +
|
|
37
|
+
`(have versions: ${[...keyring.keys()].sort().join(",")}). ` +
|
|
38
|
+
`Check KUMIKO_SECRETS_MASTER_KEY_V${current} is set.`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- KEK-wrap / unwrap boilerplate (AES-256-GCM) ---------------------
|
|
43
|
+
// The DEK is the plaintext here; we encrypt it with the KEK. Same
|
|
44
|
+
// algorithm as the value encryption, just a different role.
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
async wrapDek(dek) {
|
|
48
|
+
const kek = keyring.get(current);
|
|
49
|
+
if (!kek) {
|
|
50
|
+
// Should never reach here — boot validation above guarantees the
|
|
51
|
+
// current KEK exists. Defensive throw so a surprise at runtime is
|
|
52
|
+
// surfaceable instead of silently coercing to zero-bytes.
|
|
53
|
+
throw new InternalError({
|
|
54
|
+
message: `[secrets] missing KEK for current version ${current}`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const iv = randomBytes(IV_LENGTH);
|
|
58
|
+
const cipher = createCipheriv(ALGORITHM, kek, iv);
|
|
59
|
+
const wrapped = Buffer.concat([cipher.update(dek), cipher.final()]);
|
|
60
|
+
const tag = cipher.getAuthTag();
|
|
61
|
+
// Pack into a single buffer: iv || tag || ciphertext. The kekVersion
|
|
62
|
+
// is returned separately — it lives on the Envelope row, not inside
|
|
63
|
+
// encryptedDek, so ops can inspect it with `SELECT kekVersion FROM …`.
|
|
64
|
+
return {
|
|
65
|
+
encryptedDek: Buffer.concat([iv, tag, wrapped]),
|
|
66
|
+
kekVersion: current,
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async unwrapDek(encryptedDek, kekVersion) {
|
|
71
|
+
const kek = keyring.get(kekVersion);
|
|
72
|
+
if (!kek) {
|
|
73
|
+
throw new InternalError({
|
|
74
|
+
message:
|
|
75
|
+
`[secrets] no KEK for version ${kekVersion} — ` +
|
|
76
|
+
`keyring has [${[...keyring.keys()].sort().join(",")}]. ` +
|
|
77
|
+
`If you retired an old version, you must rotate rows off it first.`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const iv = encryptedDek.subarray(0, IV_LENGTH);
|
|
81
|
+
const tag = encryptedDek.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
|
82
|
+
const ciphertext = encryptedDek.subarray(IV_LENGTH + TAG_LENGTH);
|
|
83
|
+
const decipher = createDecipheriv(ALGORITHM, kek, iv);
|
|
84
|
+
decipher.setAuthTag(tag);
|
|
85
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
currentVersion() {
|
|
89
|
+
return current;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async isAvailable() {
|
|
93
|
+
// Env-backed provider is always "available" once boot passed — the
|
|
94
|
+
// check already verified keyring presence. Real KMS providers would
|
|
95
|
+
// ping the service here.
|
|
96
|
+
return true;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ENV schema (Decision 1 — per-version variables):
|
|
102
|
+
// KUMIKO_SECRETS_MASTER_KEY_V1=<base64 32 bytes>
|
|
103
|
+
// KUMIKO_SECRETS_MASTER_KEY_V2=<base64 32 bytes>
|
|
104
|
+
// ...
|
|
105
|
+
// Rationale: k8s secrets map 1:1 per env var, so ops rotate single versions
|
|
106
|
+
// without rewriting a JSON blob. A typo blasts only one key, not the ring.
|
|
107
|
+
const KEY_VAR_PATTERN = /^KUMIKO_SECRETS_MASTER_KEY_V(\d+)$/;
|
|
108
|
+
|
|
109
|
+
function loadKeyring(env: Readonly<Record<string, string | undefined>>): Keyring {
|
|
110
|
+
const keyring = new Map<number, Buffer>();
|
|
111
|
+
for (const [name, value] of Object.entries(env)) {
|
|
112
|
+
const match = name.match(KEY_VAR_PATTERN);
|
|
113
|
+
if (!match || !value) continue;
|
|
114
|
+
// biome-ignore lint/style/noNonNullAssertion: regex group 1 always present
|
|
115
|
+
const version = Number.parseInt(match[1]!, 10);
|
|
116
|
+
if (!Number.isFinite(version) || version < 1) {
|
|
117
|
+
throw new InternalError({
|
|
118
|
+
message: `[secrets] invalid KEK version in ${name} (must be positive integer)`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const kek = Buffer.from(value, "base64");
|
|
122
|
+
if (kek.length !== KEK_LENGTH) {
|
|
123
|
+
throw new InternalError({
|
|
124
|
+
message: `[secrets] ${name}: KEK must decode to exactly ${KEK_LENGTH} bytes (got ${kek.length})`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
keyring.set(version, kek);
|
|
128
|
+
}
|
|
129
|
+
if (keyring.size === 0) {
|
|
130
|
+
throw new InternalError({
|
|
131
|
+
message: "[secrets] no KEK found in environment — set at least KUMIKO_SECRETS_MASTER_KEY_V1",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return keyring;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// currentVersion resolution (Decision 2 — explicit env var):
|
|
138
|
+
// KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION=2
|
|
139
|
+
// Rationale: adding a new KEK to the keyring must NOT auto-promote it to
|
|
140
|
+
// "wrap with this one". Staging window — ops deploys V2 alongside V1, runs
|
|
141
|
+
// a canary rotation job, then flips CURRENT_VERSION only after validation.
|
|
142
|
+
// AWS KMS, GCP KMS, Azure Key Vault all separate "known-keys" from
|
|
143
|
+
// "active-key-id" for the same reason.
|
|
144
|
+
const CURRENT_VERSION_VAR = "KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION";
|
|
145
|
+
|
|
146
|
+
function resolveCurrentVersion(env: Readonly<Record<string, string | undefined>>): number {
|
|
147
|
+
const raw = env[CURRENT_VERSION_VAR];
|
|
148
|
+
if (!raw) {
|
|
149
|
+
throw new InternalError({
|
|
150
|
+
message:
|
|
151
|
+
`[secrets] ${CURRENT_VERSION_VAR} not set — explicit current-version ` +
|
|
152
|
+
"required so adding a new KEK to the env doesn't auto-promote it",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const version = Number.parseInt(raw, 10);
|
|
156
|
+
if (!Number.isFinite(version) || version < 1 || String(version) !== raw.trim()) {
|
|
157
|
+
throw new InternalError({
|
|
158
|
+
message: `[secrets] ${CURRENT_VERSION_VAR}="${raw}" must be a positive integer`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return version;
|
|
162
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Envelope encryption primitives. Combines a freshly-generated DEK + the
|
|
2
|
+
// central KEK (via MasterKeyProvider) into a self-contained Envelope that
|
|
3
|
+
// carries everything needed to decrypt later.
|
|
4
|
+
|
|
5
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
6
|
+
import type { Envelope, MasterKeyProvider } from "./types";
|
|
7
|
+
|
|
8
|
+
const ALGORITHM = "aes-256-gcm";
|
|
9
|
+
const DEK_LENGTH = 32; // AES-256
|
|
10
|
+
const IV_LENGTH = 12; // GCM standard nonce length
|
|
11
|
+
|
|
12
|
+
// Encrypts plaintext with a new random DEK, then wraps the DEK with the
|
|
13
|
+
// current KEK. The returned Envelope is safe to store at rest.
|
|
14
|
+
export async function encryptValue(
|
|
15
|
+
plaintext: string,
|
|
16
|
+
provider: MasterKeyProvider,
|
|
17
|
+
): Promise<Envelope> {
|
|
18
|
+
// Fresh DEK per value. Reusing DEKs across rows would break forward
|
|
19
|
+
// secrecy (one compromised ciphertext-IV pair leaks information about
|
|
20
|
+
// others encrypted with the same DEK).
|
|
21
|
+
const dek = randomBytes(DEK_LENGTH);
|
|
22
|
+
try {
|
|
23
|
+
const iv = randomBytes(IV_LENGTH);
|
|
24
|
+
|
|
25
|
+
const cipher = createCipheriv(ALGORITHM, dek, iv);
|
|
26
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
27
|
+
const authTag = cipher.getAuthTag();
|
|
28
|
+
|
|
29
|
+
const { encryptedDek, kekVersion } = await provider.wrapDek(dek);
|
|
30
|
+
return { ciphertext, iv, authTag, encryptedDek, kekVersion };
|
|
31
|
+
} finally {
|
|
32
|
+
// Zero the DEK on best effort regardless of success — if provider.wrapDek
|
|
33
|
+
// threw, the plaintext DEK must still not linger in the heap waiting
|
|
34
|
+
// for GC. Node can't guarantee secure erase, but clearing the bytes
|
|
35
|
+
// removes the obvious window.
|
|
36
|
+
dek.fill(0);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Decrypts an Envelope. Throws if the authTag doesn't match — tampered
|
|
41
|
+
// ciphertexts cannot round-trip.
|
|
42
|
+
export async function decryptValue(
|
|
43
|
+
envelope: Envelope,
|
|
44
|
+
provider: MasterKeyProvider,
|
|
45
|
+
): Promise<string> {
|
|
46
|
+
const dek = await provider.unwrapDek(envelope.encryptedDek, envelope.kekVersion);
|
|
47
|
+
try {
|
|
48
|
+
const decipher = createDecipheriv(ALGORITHM, dek, envelope.iv);
|
|
49
|
+
decipher.setAuthTag(envelope.authTag);
|
|
50
|
+
const plaintext = Buffer.concat([decipher.update(envelope.ciphertext), decipher.final()]);
|
|
51
|
+
return plaintext.toString("utf8");
|
|
52
|
+
} finally {
|
|
53
|
+
dek.fill(0);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { createDekCache, type DekCache, type DekCacheOptions } from "./dek-cache";
|
|
2
|
+
export {
|
|
3
|
+
createEnvMasterKeyProvider,
|
|
4
|
+
type EnvMasterKeyProviderOptions,
|
|
5
|
+
type Keyring,
|
|
6
|
+
} from "./env-master-key-provider";
|
|
7
|
+
export { decryptValue, encryptValue } from "./envelope";
|
|
8
|
+
export { assertNoSecretLeak } from "./leak-guard";
|
|
9
|
+
export { rewrapDek } from "./rotation";
|
|
10
|
+
export {
|
|
11
|
+
createSecret,
|
|
12
|
+
type Envelope,
|
|
13
|
+
isSecret,
|
|
14
|
+
type MasterKeyProvider,
|
|
15
|
+
type Secret,
|
|
16
|
+
type SecretAuditContext,
|
|
17
|
+
type SecretKeyRef,
|
|
18
|
+
type SecretsContext,
|
|
19
|
+
} from "./types";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Response-leak guard. Walks a handler-result tree and throws the moment it
|
|
2
|
+
// finds a Secret<> branded value — the dispatcher calls this after every
|
|
3
|
+
// handler returns, so accidentally including a plaintext secret in the
|
|
4
|
+
// response body becomes a runtime error at the handler boundary rather
|
|
5
|
+
// than a silent exfiltration to the client.
|
|
6
|
+
|
|
7
|
+
import { InternalError } from "../errors";
|
|
8
|
+
import { isSecret } from "./types";
|
|
9
|
+
|
|
10
|
+
// Maximum depth the walker descends. A legitimate result tree is rarely
|
|
11
|
+
// deeper than a few levels; the cap is a safety net against cyclic or
|
|
12
|
+
// pathologically-deep user input smuggled into a response.
|
|
13
|
+
const MAX_DEPTH = 12;
|
|
14
|
+
|
|
15
|
+
export function assertNoSecretLeak(value: unknown, path = "$", depth = 0): void {
|
|
16
|
+
// skip: nothing to walk at null/undefined leaves.
|
|
17
|
+
if (value === null || value === undefined) return;
|
|
18
|
+
|
|
19
|
+
if (isSecret(value)) {
|
|
20
|
+
throw new InternalError({
|
|
21
|
+
message:
|
|
22
|
+
`[secrets] Secret<> leaked into response at ${path}. ` +
|
|
23
|
+
"Feature code must call .reveal() and use the plaintext in an " +
|
|
24
|
+
"external call (SMTP, HTTP header, etc.) — never return the branded " +
|
|
25
|
+
"value, even unwrapped, unless you've stripped it from the response first.",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// skip: hit the recursion cap. Cyclic or pathologically-deep input is
|
|
30
|
+
// more likely than a legitimate 12-level-deep secret — trade coverage
|
|
31
|
+
// for termination guarantees.
|
|
32
|
+
if (depth >= MAX_DEPTH) return;
|
|
33
|
+
|
|
34
|
+
const t = typeof value;
|
|
35
|
+
// skip: primitives already handled by the isSecret check above — strings,
|
|
36
|
+
// numbers, booleans can't hold a brand.
|
|
37
|
+
if (t !== "object") return;
|
|
38
|
+
|
|
39
|
+
// Map and Set get walked through their entries — a feature could legitimately
|
|
40
|
+
// build either, and a custom toJSON could expand them onto the wire.
|
|
41
|
+
// JSON.stringify-by-default produces "{}" for both, which would mask the
|
|
42
|
+
// leak silently; we'd rather throw at the boundary than rely on that
|
|
43
|
+
// accident.
|
|
44
|
+
if (value instanceof Map) {
|
|
45
|
+
let i = 0;
|
|
46
|
+
for (const [k, v] of value) {
|
|
47
|
+
assertNoSecretLeak(k, `${path}.<map[${i}].key>`, depth + 1);
|
|
48
|
+
assertNoSecretLeak(v, `${path}.<map[${i}].val>`, depth + 1);
|
|
49
|
+
i++;
|
|
50
|
+
}
|
|
51
|
+
// skip: map fully walked, nothing else at this level.
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (value instanceof Set) {
|
|
55
|
+
let i = 0;
|
|
56
|
+
for (const v of value) {
|
|
57
|
+
assertNoSecretLeak(v, `${path}.<set[${i}]>`, depth + 1);
|
|
58
|
+
i++;
|
|
59
|
+
}
|
|
60
|
+
// skip: set fully walked, nothing else at this level.
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Skip remaining class instances we can't introspect safely (Date, Buffer,
|
|
65
|
+
// Temporal.Instant, custom domain classes, etc.). Plain objects have
|
|
66
|
+
// Object.prototype or null prototype — that's what handler responses
|
|
67
|
+
// serialize to JSON from.
|
|
68
|
+
const proto = Object.getPrototypeOf(value);
|
|
69
|
+
// skip: non-plain object (class instance). Brand check at entry already
|
|
70
|
+
// verified it's not a Secret<>; anything else is opaque to us.
|
|
71
|
+
if (proto !== null && proto !== Object.prototype && !Array.isArray(value)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
for (let i = 0; i < value.length; i++) {
|
|
77
|
+
assertNoSecretLeak(value[i], `${path}[${i}]`, depth + 1);
|
|
78
|
+
}
|
|
79
|
+
// skip: array fully walked, nothing else at this level.
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
84
|
+
// @cast-boundary recursive-walk
|
|
85
|
+
assertNoSecretLeak(v, `${path}.${k}`, depth + 1);
|
|
86
|
+
}
|
|
87
|
+
}
|