From 4fa5a513bbf6933b793bb90f8f9acce5a12e5952 Mon Sep 17 00:00:00 2001 From: bootjp Date: Sat, 18 Jul 2026 21:22:06 +0900 Subject: [PATCH 1/6] encryption: add production KEK providers --- ...6_04_29_partial_data_at_rest_encryption.md | 5 +- ...2026_07_18_implemented_9b_kek_providers.md | 64 +++++++++ go.mod | 32 +++++ go.sum | 85 +++++++++++ internal/encryption/errors.go | 4 +- internal/encryption/kek/aws_kms.go | 106 ++++++++++++++ internal/encryption/kek/aws_kms_test.go | 87 ++++++++++++ internal/encryption/kek/env.go | 91 ++++++++++++ internal/encryption/kek/env_test.go | 52 +++++++ internal/encryption/kek/gcp_kms.go | 134 ++++++++++++++++++ internal/encryption/kek/gcp_kms_test.go | 80 +++++++++++ internal/encryption/kek/kek.go | 4 +- internal/encryption/kek/provider.go | 33 +++++ internal/encryption/kek/source.go | 91 ++++++++++++ internal/encryption/kek/source_test.go | 57 ++++++++ internal/encryption/kek/vault.go | 127 +++++++++++++++++ internal/encryption/kek/vault_test.go | 65 +++++++++ internal/encryption/startup.go | 6 +- main.go | 40 +++--- main_encryption_admin.go | 10 +- main_encryption_kek_source_test.go | 100 +++++++++++++ 21 files changed, 1240 insertions(+), 33 deletions(-) create mode 100644 docs/design/2026_07_18_implemented_9b_kek_providers.md create mode 100644 internal/encryption/kek/aws_kms.go create mode 100644 internal/encryption/kek/aws_kms_test.go create mode 100644 internal/encryption/kek/env.go create mode 100644 internal/encryption/kek/env_test.go create mode 100644 internal/encryption/kek/gcp_kms.go create mode 100644 internal/encryption/kek/gcp_kms_test.go create mode 100644 internal/encryption/kek/provider.go create mode 100644 internal/encryption/kek/source.go create mode 100644 internal/encryption/kek/source_test.go create mode 100644 internal/encryption/kek/vault.go create mode 100644 internal/encryption/kek/vault_test.go create mode 100644 main_encryption_kek_source_test.go diff --git a/docs/design/2026_04_29_partial_data_at_rest_encryption.md b/docs/design/2026_04_29_partial_data_at_rest_encryption.md index fc953defa..ea229f11c 100644 --- a/docs/design/2026_04_29_partial_data_at_rest_encryption.md +++ b/docs/design/2026_04_29_partial_data_at_rest_encryption.md @@ -1,6 +1,6 @@ # Data-at-rest encryption for elastickv -Status: Partial — Stages 0–8 and 9A shipped (5E deferred); remaining Stage 9 work open +Status: Partial — Stages 0–8 and 9A–9B shipped (5E deferred); remaining Stage 9 work open Author: bootjp Date: 2026-04-29 @@ -33,7 +33,8 @@ Date: 2026-04-29 | 7 | Writer registry + deterministic nonce (§4.1). Covers process-start registration, storage-layer registration gate, runtime re-registration for cutover and rotation, conf-change-time pre-registration, and §5.5 registry projection in `GetSidecarState` / `ResyncSidecar` (`WriterRegistryForCaller`). | shipped | `2026_05_26_implemented_7a_process_start_registration.md` + `2026_05_26_implemented_7a2_storage_layer_registration_enforcement.md` + `2026_05_28_implemented_7b_runtime_reregistration.md` + `2026_05_28_implemented_7b_prime_runtime_reregistration_rotation.md` + `2026_05_29_implemented_7c_confchange_time_registration.md` + `2026_07_11_implemented_7d_sidecar_registry_projection.md` | | 8 | Snapshot header v2 (§4.4); WAL coverage closure (§4.3 / §4.6) | shipped | [`2026_05_29_implemented_8a_snapshot_header_v2.md`](2026_05_29_implemented_8a_snapshot_header_v2.md) + [`2026_06_01_implemented_8b_wal_coverage_closure.md`](2026_06_01_implemented_8b_wal_coverage_closure.md) | | 9A | Compress-then-encrypt, authenticated compression flag, encrypted-store Pebble compression policy, storage benchmark (§6.4, §8.3) | shipped | `2026_07_18_implemented_9a_encryption_compression.md` | -| 9B+ | KMS-backed wrappers, rotation/retire/rewrite, remaining benchmarks and encrypted Jepsen (§5.2, §5.4, §6.5, §8) | open | — | +| 9B | AWS KMS, GCP KMS, Vault Transit, and test/CI env KEK providers; mutually-exclusive source loader and loaded-provider mutator gate (§5.1, §6.1, §6.5) | shipped | `2026_07_18_implemented_9b_kek_providers.md` | +| 9C+ | Rotation budget/rewrap/retire/rewrite, metrics, remaining benchmarks and encrypted Jepsen (§5.2, §5.4, §6.5, §8, §9.2) | open | — | Stages 0–4 ship the entire byte-tag pipeline (storage envelope, raft envelope, FSM dispatch, halt-on-error) but leave it **production diff --git a/docs/design/2026_07_18_implemented_9b_kek_providers.md b/docs/design/2026_07_18_implemented_9b_kek_providers.md new file mode 100644 index 000000000..d8bae8392 --- /dev/null +++ b/docs/design/2026_07_18_implemented_9b_kek_providers.md @@ -0,0 +1,64 @@ +# Stage 9B: production KEK providers + +Status: Implemented +Author: bootjp +Date: 2026-07-18 + +## Scope + +Stage 9B completes the §5.1 KEK source matrix: + +- `--kekUri=aws-kms://` uses AWS KMS `Encrypt` / `Decrypt`, derives + the client region from the ARN, and binds every request to the fixed + `elastickv-purpose=dek-wrap-v1` encryption context. +- `--kekUri=gcp-kms://` uses Google Cloud KMS with fixed + AAD. Request and response CRC32C values are supplied and verified before a + wrapped DEK or plaintext DEK is accepted. +- `--kekUri=vault-transit:///` uses Vault Transit through standard + `VAULT_*` client configuration. Binary DEKs are base64 encoded for the API; + only versioned `vault:v...` ciphertexts are accepted. +- `ELASTICKV_KEK_BASE64` supplies a 32-byte test/CI-only static KEK. The + variable is unset immediately after the decode attempt, including malformed + input. It is also unset when source ambiguity is rejected. +- `--kekFile` keeps its existing owner-only regular-file contract. + +File, URI, and environment sources are mutually exclusive. Ambiguous +configuration fails closed instead of selecting a source by precedence. + +## Runtime wiring + +The startup loader returns the actual loaded `kek.Wrapper`. Startup guards now +derive `KEKConfigured` from that non-nil wrapper rather than from `--kekFile` +text. The same loaded-state boolean is threaded to the EncryptionAdmin mutator +gate, so URI and environment providers can bootstrap/rotate while a failed or +absent provider cannot expose mutating RPCs. + +Remote wrappers use a 30-second operation deadline and reject nil, empty, +integrity-invalid, or non-32-byte provider responses before sidecar state can be +updated. Provider clients are safe for concurrent use and are hidden behind +narrow interfaces for deterministic tests. + +## Compatibility and verification + +No envelope, sidecar, Raft opcode, or snapshot format changes. Existing wrapped +DEKs remain provider-specific opaque bytes, and the `kek.Wrapper` contract is +unchanged. + +Verification includes: + +- fake-client unit tests for AWS encryption context, GCP AAD/CRC32C, and Vault + binary request/response encoding; +- malformed provider response, wrong DEK length, invalid URI, source conflict, + and environment-unset tests; +- a production-wiring integration test that loads the environment KEK through + the source selector, bootstraps both DEKs, enables the storage envelope, + writes encrypted data, snapshots it, restores it into an encrypted Pebble + store, and reads the original plaintext; +- startup/mutator caller audit confirming the gate consumes loaded-wrapper + state rather than the legacy file flag. + +## Remaining work + +Stage 5E discovery batching and Stage 9C+ rotation-budget, rewrap, rewrite, +retirement, metrics, remaining benchmark, and encrypted Jepsen requirements are +not part of this milestone. diff --git a/go.mod b/go.mod index 02cfe9faf..431a2970a 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,13 @@ go 1.26 toolchain go1.26.5 require ( + cloud.google.com/go/kms v1.31.0 github.com/Jille/grpc-multi-resolver v1.3.0 github.com/aws/aws-sdk-go-v2 v1.42.1 github.com/aws/aws-sdk-go-v2/config v1.32.29 github.com/aws/aws-sdk-go-v2/credentials v1.19.28 github.com/aws/aws-sdk-go-v2/service/dynamodb v1.60.0 + github.com/aws/aws-sdk-go-v2/service/kms v1.54.1 github.com/aws/smithy-go v1.27.3 github.com/cockroachdb/errors v1.14.0 github.com/cockroachdb/pebble/v2 v2.1.6 @@ -19,6 +21,7 @@ require ( github.com/goccy/go-json v0.10.6 github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e github.com/hanwen/go-fuse/v2 v2.10.1 + github.com/hashicorp/vault/api v1.23.0 github.com/klauspost/compress v1.19.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 @@ -41,6 +44,12 @@ require ( ) require ( + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.7.0 // indirect + cloud.google.com/go/longrunning v0.9.0 // indirect github.com/DataDog/zstd v1.5.7 // indirect github.com/RaduBerinde/axisds v0.1.0 // indirect github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54 // indirect @@ -56,6 +65,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cockroachdb/crlib v0.0.0-20241112164430-1264a2edc35b // indirect github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect @@ -65,23 +75,40 @@ require ( github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect + github.com/googleapis/gax-go/v2 v2.21.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/tidwall/btree v1.1.0 // indirect github.com/tidwall/match v1.1.1 // indirect @@ -92,6 +119,7 @@ require ( go.etcd.io/etcd/pkg/v3 v3.7.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect @@ -101,7 +129,11 @@ require ( golang.org/x/crypto v0.52.0 // indirect golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/api v0.274.0 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 1203c7d30..d3710ade0 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,17 @@ +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U= +cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY= +cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= +cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY= +cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/Jille/grpc-multi-resolver v1.3.0 h1:cbVm1TtWP7YxdiCCZ8gU4/78pYO2OXpzZSFAAUMdFLs= @@ -30,6 +44,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.12.7 h1:uqsK github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.12.7/go.mod h1:Js/P8Zbwe1mRejnD+OpFLyQiJ8ioQlo3GMAg7Dfxk7w= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= +github.com/aws/aws-sdk-go-v2/service/kms v1.54.1 h1:aeJAJyvWS3gQ679pJbz8ZdOh3MViD1zvEdoZMVEawbg= +github.com/aws/aws-sdk-go-v2/service/kms v1.54.1/go.mod h1:0RXNc6Yf3AvSMldGD6Lcch96Ojlw2TtGnHsqfD/L4u8= github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 h1:sLzmJGCMv+C8KqiJgEqDLB6vxaJGmobRh4rr//ZpA3w= github.com/aws/aws-sdk-go-v2/service/signin v1.4.0/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg= github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 h1:qjMmry/cBDee1E/2gyvel0uRYCi3mwRZ2hf6N+GAodo= @@ -46,8 +62,12 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/cockroachdb/crlib v0.0.0-20241112164430-1264a2edc35b h1:SHlYZ/bMx7frnmeqCu+xm0TCxXLzX3jQIVuFbnFGtFU= github.com/cockroachdb/crlib v0.0.0-20241112164430-1264a2edc35b/go.mod h1:Gq51ZeKaFCXk6QwuGM0w1dnaOqc/F5zKT2zA9D6Xeac= github.com/cockroachdb/datadriven v1.0.3-0.20250407164829-2945557346d5 h1:UycK/E0TkisVrQbSoxvU827FwgBBcZ95nRRmpj/12QI= @@ -71,23 +91,37 @@ github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03V github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/getsentry/sentry-go v0.47.0 h1:AnSMSyrYA5qZCIN/2xpgAAwv63sVULV+vBq37ajouc8= github.com/getsentry/sentry-go v0.47.0/go.mod h1:h+b4VHpKnK7aUXB5wc+KDnPgp9ZtfliRD4eV85FbiSA= github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9 h1:r5GgOLGbza2wVHRzK7aAj6lWZjfbAwiu/RDCVOKjRyM= github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9/go.mod h1:106OIgooyS7OzLDOpUGgm9fA3bQENb/cFSyyBmMoJDs= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -100,12 +134,41 @@ github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e h1:4bw4WeyTYPp0sma github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg= +github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= +github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hanwen/go-fuse/v2 v2.10.1 h1:QAqZuc9+aBtTou+OPruU/hkYQYCkgPtQd2QaepHkTTs= github.com/hanwen/go-fuse/v2 v2.10.1/go.mod h1:aU7NkGYZUmuJrZapoI3mEcNve7PZTySUOLBuch/vR6U= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A= +github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -120,8 +183,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882 h1:0lgqHvJWHLGW5TuObJrfyEi6+ASTKDBWikGvPqy9Yiw= github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -131,6 +202,8 @@ github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTw github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -146,6 +219,8 @@ github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3b github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= @@ -186,6 +261,8 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= @@ -223,6 +300,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -237,6 +316,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -247,6 +328,10 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= +google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= diff --git a/internal/encryption/errors.go b/internal/encryption/errors.go index fcec5a201..ee7feaa73 100644 --- a/internal/encryption/errors.go +++ b/internal/encryption/errors.go @@ -159,7 +159,7 @@ var ( // and the operator's clear intent (enable encryption) cannot be // satisfied. Fail fast at startup rather than discover the // mismatch later via a halted apply loop. - ErrKEKRequiredWithFlag = errors.New("encryption: --encryption-enabled is set but no KEK source (--kekFile) was provided; refusing to start (set --kekFile or unset --encryption-enabled)") + ErrKEKRequiredWithFlag = errors.New("encryption: --encryption-enabled is set but no KEK source was provided; refusing to start (set --kekFile, --kekUri, or ELASTICKV_KEK_BASE64, or unset --encryption-enabled)") // ErrKEKMismatch is the §9.1 startup-refusal guard raised when // the data dir contains a sidecar whose wrapped DEKs do NOT @@ -171,7 +171,7 @@ var ( // DEK. Recovery requires the operator to either point // --kekFile at the correct KEK file or restore the data dir // from a backup that matches the supplied KEK. - ErrKEKMismatch = errors.New("encryption: configured KEK cannot unwrap one or more wrapped DEKs in the sidecar; refusing to start (verify --kekFile matches the KEK that bootstrapped this data dir)") + ErrKEKMismatch = errors.New("encryption: configured KEK cannot unwrap one or more wrapped DEKs in the sidecar; refusing to start (verify the configured KEK source matches the KEK that bootstrapped this data dir)") // ErrLocalEpochExhausted is the §9.1 startup-refusal guard // raised when any active DEK in the sidecar has reached the diff --git a/internal/encryption/kek/aws_kms.go b/internal/encryption/kek/aws_kms.go new file mode 100644 index 000000000..f4400be82 --- /dev/null +++ b/internal/encryption/kek/aws_kms.go @@ -0,0 +1,106 @@ +package kek + +import ( + "context" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/arn" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + awskms "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/cockroachdb/errors" +) + +const awsEncryptionContextKey = "elastickv-purpose" +const awsEncryptionContextValue = "dek-wrap-v1" + +type awsKMSClient interface { + Encrypt(context.Context, *awskms.EncryptInput, ...func(*awskms.Options)) (*awskms.EncryptOutput, error) + Decrypt(context.Context, *awskms.DecryptInput, ...func(*awskms.Options)) (*awskms.DecryptOutput, error) +} + +// AWSKMSWrapper wraps DEKs using an AWS KMS symmetric ENCRYPT_DECRYPT key. +type AWSKMSWrapper struct { + client awsKMSClient + keyARN string + timeout time.Duration +} + +// NewAWSKMSWrapper loads the standard AWS credential chain and derives the KMS +// endpoint region from keyARN. +func NewAWSKMSWrapper(ctx context.Context, keyARN string) (*AWSKMSWrapper, error) { + parsed, err := arn.Parse(keyARN) + if err != nil || parsed.Service != "kms" || parsed.Region == "" || parsed.AccountID == "" || + (!strings.HasPrefix(parsed.Resource, "key/") && !strings.HasPrefix(parsed.Resource, "alias/")) { + return nil, errors.Wrapf(ErrInvalidKEKURI, "invalid AWS KMS key ARN %q", keyARN) + } + cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(parsed.Region)) + if err != nil { + return nil, errors.Wrap(err, "kek: load AWS configuration") + } + return newAWSKMSWrapper(awskms.NewFromConfig(cfg), keyARN), nil +} + +func newAWSKMSWrapper(client awsKMSClient, keyARN string) *AWSKMSWrapper { + return &AWSKMSWrapper{client: client, keyARN: keyARN, timeout: providerRequestTimeout} +} + +// Wrap calls AWS KMS Encrypt with a fixed encryption context that is required +// again by Unwrap. +func (w *AWSKMSWrapper) Wrap(dek []byte) ([]byte, error) { + if w == nil || w.client == nil { + return nil, errors.Wrap(ErrInvalidProviderResponse, "kek: AWS KMS client is nil") + } + if err := validateDEK(dek); err != nil { + return nil, err + } + ctx, cancel := requestContext(w.timeout) + defer cancel() + out, err := w.client.Encrypt(ctx, &awskms.EncryptInput{ + KeyId: aws.String(w.keyARN), + Plaintext: dek, + EncryptionContext: map[string]string{ + awsEncryptionContextKey: awsEncryptionContextValue, + }, + }) + if err != nil { + return nil, errors.Wrap(err, "kek: AWS KMS Encrypt") + } + if out == nil || len(out.CiphertextBlob) == 0 { + return nil, errors.Wrap(ErrInvalidProviderResponse, "kek: AWS KMS returned empty ciphertext") + } + return append([]byte(nil), out.CiphertextBlob...), nil +} + +// Unwrap calls AWS KMS Decrypt and rejects any non-32-byte plaintext response. +func (w *AWSKMSWrapper) Unwrap(wrapped []byte) ([]byte, error) { + if w == nil || w.client == nil { + return nil, errors.Wrap(ErrInvalidProviderResponse, "kek: AWS KMS client is nil") + } + if len(wrapped) == 0 { + return nil, errors.Wrap(ErrInvalidProviderResponse, "kek: AWS KMS ciphertext is empty") + } + ctx, cancel := requestContext(w.timeout) + defer cancel() + out, err := w.client.Decrypt(ctx, &awskms.DecryptInput{ + CiphertextBlob: wrapped, + KeyId: aws.String(w.keyARN), + EncryptionContext: map[string]string{ + awsEncryptionContextKey: awsEncryptionContextValue, + }, + }) + if err != nil { + return nil, errors.Wrap(err, "kek: AWS KMS Decrypt") + } + if out == nil { + return nil, errors.Wrap(ErrInvalidProviderResponse, "kek: AWS KMS returned nil plaintext") + } + if err := validateDEK(out.Plaintext); err != nil { + return nil, errors.Wrap(err, "kek: AWS KMS plaintext") + } + return append([]byte(nil), out.Plaintext...), nil +} + +// Name returns the provider and configured key ARN. +func (w *AWSKMSWrapper) Name() string { return "aws-kms:" + w.keyARN } diff --git a/internal/encryption/kek/aws_kms_test.go b/internal/encryption/kek/aws_kms_test.go new file mode 100644 index 000000000..97fb30767 --- /dev/null +++ b/internal/encryption/kek/aws_kms_test.go @@ -0,0 +1,87 @@ +package kek + +import ( + "bytes" + "context" + "testing" + + awskms "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/cockroachdb/errors" + "github.com/stretchr/testify/require" +) + +type fakeAWSKMSClient struct { + encryptInput *awskms.EncryptInput + decryptInput *awskms.DecryptInput + encryptOutput *awskms.EncryptOutput + decryptOutput *awskms.DecryptOutput + err error +} + +func (f *fakeAWSKMSClient) Encrypt(_ context.Context, input *awskms.EncryptInput, _ ...func(*awskms.Options)) (*awskms.EncryptOutput, error) { + f.encryptInput = input + return f.encryptOutput, f.err +} + +func (f *fakeAWSKMSClient) Decrypt(_ context.Context, input *awskms.DecryptInput, _ ...func(*awskms.Options)) (*awskms.DecryptOutput, error) { + f.decryptInput = input + return f.decryptOutput, f.err +} + +func TestAWSKMSWrapperRequestBinding(t *testing.T) { + const keyARN = "arn:aws:kms:us-east-1:123456789012:key/1234abcd" + dek := bytes.Repeat([]byte{0x42}, fileKEKSize) + client := &fakeAWSKMSClient{ + encryptOutput: &awskms.EncryptOutput{CiphertextBlob: []byte("wrapped")}, + decryptOutput: &awskms.DecryptOutput{Plaintext: append([]byte(nil), dek...)}, + } + wrapper := newAWSKMSWrapper(client, keyARN) + + wrapped, err := wrapper.Wrap(dek) + require.NoError(t, err) + require.Equal(t, []byte("wrapped"), wrapped) + require.Equal(t, keyARN, *client.encryptInput.KeyId) + require.Equal(t, dek, client.encryptInput.Plaintext) + require.Equal(t, awsEncryptionContextValue, client.encryptInput.EncryptionContext[awsEncryptionContextKey]) + + plain, err := wrapper.Unwrap(wrapped) + require.NoError(t, err) + require.Equal(t, dek, plain) + require.Equal(t, keyARN, *client.decryptInput.KeyId) + require.Equal(t, awsEncryptionContextValue, client.decryptInput.EncryptionContext[awsEncryptionContextKey]) + require.Equal(t, "aws-kms:"+keyARN, wrapper.Name()) +} + +func TestAWSKMSWrapperRejectsProviderFailures(t *testing.T) { + const keyARN = "arn:aws:kms:us-east-1:123456789012:key/1234abcd" + dek := bytes.Repeat([]byte{0x42}, fileKEKSize) + for _, tc := range []struct { + name string + client *fakeAWSKMSClient + unwrap bool + want error + }{ + {name: "encrypt error", client: &fakeAWSKMSClient{err: errors.New("denied")}, want: errors.New("sentinel")}, + {name: "empty ciphertext", client: &fakeAWSKMSClient{encryptOutput: &awskms.EncryptOutput{}}, want: ErrInvalidProviderResponse}, + {name: "short plaintext", client: &fakeAWSKMSClient{decryptOutput: &awskms.DecryptOutput{Plaintext: []byte("short")}}, unwrap: true, want: ErrInvalidDEKLength}, + } { + t.Run(tc.name, func(t *testing.T) { + wrapper := newAWSKMSWrapper(tc.client, keyARN) + var err error + if tc.unwrap { + _, err = wrapper.Unwrap([]byte("wrapped")) + } else { + _, err = wrapper.Wrap(dek) + } + require.Error(t, err) + if errors.Is(tc.want, ErrInvalidProviderResponse) || errors.Is(tc.want, ErrInvalidDEKLength) { + require.ErrorIs(t, err, tc.want) + } + }) + } +} + +func TestNewAWSKMSWrapperRejectsInvalidARNBeforeConfigLoad(t *testing.T) { + _, err := NewAWSKMSWrapper(context.Background(), "not-an-arn") + require.ErrorIs(t, err, ErrInvalidKEKURI) +} diff --git a/internal/encryption/kek/env.go b/internal/encryption/kek/env.go new file mode 100644 index 000000000..59fa5c48a --- /dev/null +++ b/internal/encryption/kek/env.go @@ -0,0 +1,91 @@ +package kek + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "os" + + "github.com/cockroachdb/errors" +) + +// EnvVar is the test/CI-only environment variable accepted as a static KEK +// source. Production deployments should use a remote KMS provider. +const EnvVar = "ELASTICKV_KEK_BASE64" + +var ErrNilEnvWrapper = errors.New("kek: EnvWrapper is nil or uninitialised; construct with NewEnvWrapper") + +// EnvWrapper wraps DEKs with a process-local AES-256-GCM key loaded from +// EnvVar. NewEnvWrapper removes EnvVar immediately after decoding it so the +// raw KEK is not retained in the process environment. +type EnvWrapper struct { + aead cipher.AEAD +} + +// NewEnvWrapper reads a standard-base64 encoded 32-byte KEK. EnvVar is unset +// after the decode attempt on both success and failure paths. +func NewEnvWrapper() (*EnvWrapper, error) { + encoded, ok := os.LookupEnv(EnvVar) + if !ok { + return nil, errors.Errorf("kek: %s is not set", EnvVar) + } + raw, decodeErr := base64.StdEncoding.DecodeString(encoded) + unsetErr := os.Unsetenv(EnvVar) + if decodeErr != nil { + return nil, errors.Wrapf(decodeErr, "kek: decode %s", EnvVar) + } + defer clear(raw) + if unsetErr != nil { + return nil, errors.Wrapf(unsetErr, "kek: unset %s", EnvVar) + } + if err := validateDEK(raw); err != nil { + return nil, errors.Wrapf(err, "kek: %s", EnvVar) + } + block, err := aes.NewCipher(raw) + if err != nil { + return nil, errors.Wrap(err, "kek: env aes.NewCipher") + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, errors.Wrap(err, "kek: env cipher.NewGCM") + } + return &EnvWrapper{aead: aead}, nil +} + +// Wrap returns nonce || AES-GCM(KEK, DEK), matching FileWrapper's local +// provider format. +func (w *EnvWrapper) Wrap(dek []byte) ([]byte, error) { + if w == nil || w.aead == nil { + return nil, errors.WithStack(ErrNilEnvWrapper) + } + if err := validateDEK(dek); err != nil { + return nil, err + } + nonce := make([]byte, fileNonceSize) + if _, err := rand.Read(nonce); err != nil { + return nil, errors.Wrap(err, "kek: env random nonce") + } + out := make([]byte, 0, fileNonceSize+fileKEKSize+fileTagSize) + out = append(out, nonce...) + return w.aead.Seal(out, nonce, dek, nil), nil +} + +// Unwrap authenticates and decrypts an EnvWrapper payload. +func (w *EnvWrapper) Unwrap(wrapped []byte) ([]byte, error) { + if w == nil || w.aead == nil { + return nil, errors.WithStack(ErrNilEnvWrapper) + } + if len(wrapped) != fileNonceSize+fileKEKSize+fileTagSize { + return nil, errors.Errorf("kek: env wrapped DEK is %d bytes, want %d", + len(wrapped), fileNonceSize+fileKEKSize+fileTagSize) + } + plain, err := w.aead.Open(nil, wrapped[:fileNonceSize], wrapped[fileNonceSize:], nil) + if err != nil { + return nil, errors.Wrap(err, "kek: env AES-GCM Open") + } + return plain, nil +} + +// Name identifies the environment-backed provider without exposing key bytes. +func (*EnvWrapper) Name() string { return "env" } diff --git a/internal/encryption/kek/env_test.go b/internal/encryption/kek/env_test.go new file mode 100644 index 000000000..a63cdb8ba --- /dev/null +++ b/internal/encryption/kek/env_test.go @@ -0,0 +1,52 @@ +package kek + +import ( + "bytes" + "encoding/base64" + "os" + "testing" + + "github.com/cockroachdb/errors" + "github.com/stretchr/testify/require" +) + +func TestEnvWrapperRoundTripAndUnset(t *testing.T) { + key := bytes.Repeat([]byte{0x41}, fileKEKSize) + t.Setenv(EnvVar, base64.StdEncoding.EncodeToString(key)) + wrapper, err := NewEnvWrapper() + require.NoError(t, err) + _, stillSet := os.LookupEnv(EnvVar) + require.False(t, stillSet) + require.Equal(t, "env", wrapper.Name()) + + dek := bytes.Repeat([]byte{0xA5}, fileKEKSize) + wrapped, err := wrapper.Wrap(dek) + require.NoError(t, err) + require.NotEqual(t, dek, wrapped) + plain, err := wrapper.Unwrap(wrapped) + require.NoError(t, err) + require.Equal(t, dek, plain) + + wrapped[len(wrapped)-1] ^= 0x01 + _, err = wrapper.Unwrap(wrapped) + require.Error(t, err) +} + +func TestEnvWrapperInvalidInputStillUnsets(t *testing.T) { + t.Setenv(EnvVar, "not-base64") + _, err := NewEnvWrapper() + require.Error(t, err) + _, stillSet := os.LookupEnv(EnvVar) + require.False(t, stillSet) +} + +func TestEnvWrapperRejectsInvalidDEKLength(t *testing.T) { + t.Setenv(EnvVar, base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, fileKEKSize))) + wrapper, err := NewEnvWrapper() + require.NoError(t, err) + _, err = wrapper.Wrap([]byte("short")) + require.ErrorIs(t, err, ErrInvalidDEKLength) + var nilWrapper *EnvWrapper + _, err = nilWrapper.Wrap(bytes.Repeat([]byte{1}, fileKEKSize)) + require.True(t, errors.Is(err, ErrNilEnvWrapper)) +} diff --git a/internal/encryption/kek/gcp_kms.go b/internal/encryption/kek/gcp_kms.go new file mode 100644 index 000000000..09c85af6d --- /dev/null +++ b/internal/encryption/kek/gcp_kms.go @@ -0,0 +1,134 @@ +package kek + +import ( + "context" + "hash/crc32" + "strings" + "time" + + gcpkms "cloud.google.com/go/kms/apiv1" + "cloud.google.com/go/kms/apiv1/kmspb" + "github.com/cockroachdb/errors" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +var gcpKMSAAD = []byte("elastickv-dek-wrap-v1") + +type gcpKMSClient interface { + Encrypt(context.Context, *kmspb.EncryptRequest) (*kmspb.EncryptResponse, error) + Decrypt(context.Context, *kmspb.DecryptRequest) (*kmspb.DecryptResponse, error) +} + +type gcpSDKClient struct { + client *gcpkms.KeyManagementClient +} + +func (c gcpSDKClient) Encrypt(ctx context.Context, req *kmspb.EncryptRequest) (*kmspb.EncryptResponse, error) { + out, err := c.client.Encrypt(ctx, req) + return out, errors.Wrap(err, "GCP KMS SDK Encrypt") +} + +func (c gcpSDKClient) Decrypt(ctx context.Context, req *kmspb.DecryptRequest) (*kmspb.DecryptResponse, error) { + out, err := c.client.Decrypt(ctx, req) + return out, errors.Wrap(err, "GCP KMS SDK Decrypt") +} + +// GCPKMSWrapper wraps DEKs using a Google Cloud KMS symmetric CryptoKey. +type GCPKMSWrapper struct { + client gcpKMSClient + keyName string + timeout time.Duration +} + +// NewGCPKMSWrapper uses Application Default Credentials to construct a Cloud +// KMS client for keyName. +func NewGCPKMSWrapper(ctx context.Context, keyName string) (*GCPKMSWrapper, error) { + if !validGCPKeyName(keyName) { + return nil, errors.Wrapf(ErrInvalidKEKURI, "invalid GCP KMS CryptoKey name %q", keyName) + } + client, err := gcpkms.NewKeyManagementClient(ctx) + if err != nil { + return nil, errors.Wrap(err, "kek: create GCP KMS client") + } + return newGCPKMSWrapper(gcpSDKClient{client: client}, keyName), nil +} + +func newGCPKMSWrapper(client gcpKMSClient, keyName string) *GCPKMSWrapper { + return &GCPKMSWrapper{client: client, keyName: keyName, timeout: providerRequestTimeout} +} + +func validGCPKeyName(name string) bool { + parts := strings.Split(name, "/") + return len(parts) == 8 && parts[0] == "projects" && parts[1] != "" && + parts[2] == "locations" && parts[3] != "" && parts[4] == "keyRings" && + parts[5] != "" && parts[6] == "cryptoKeys" && parts[7] != "" +} + +func crc32c(data []byte) *wrapperspb.Int64Value { + return wrapperspb.Int64(int64(crc32.Checksum(data, crc32.MakeTable(crc32.Castagnoli)))) +} + +func checksumMatches(data []byte, checksum *wrapperspb.Int64Value) bool { + return checksum != nil && crc32c(data).Value == checksum.Value +} + +// Wrap calls Cloud KMS Encrypt with fixed AAD and verifies request/response +// CRC32C integrity metadata. +func (w *GCPKMSWrapper) Wrap(dek []byte) ([]byte, error) { + if w == nil || w.client == nil { + return nil, errors.Wrap(ErrInvalidProviderResponse, "kek: GCP KMS client is nil") + } + if err := validateDEK(dek); err != nil { + return nil, err + } + ctx, cancel := requestContext(w.timeout) + defer cancel() + out, err := w.client.Encrypt(ctx, &kmspb.EncryptRequest{ + Name: w.keyName, + Plaintext: dek, + AdditionalAuthenticatedData: gcpKMSAAD, + PlaintextCrc32C: crc32c(dek), + AdditionalAuthenticatedDataCrc32C: crc32c(gcpKMSAAD), + }) + if err != nil { + return nil, errors.Wrap(err, "kek: GCP KMS Encrypt") + } + if out == nil || len(out.Ciphertext) == 0 || !out.VerifiedPlaintextCrc32C || + !out.VerifiedAdditionalAuthenticatedDataCrc32C || !checksumMatches(out.Ciphertext, out.CiphertextCrc32C) { + return nil, errors.WithStack(ErrInvalidProviderResponse) + } + return append([]byte(nil), out.Ciphertext...), nil +} + +// Unwrap calls Cloud KMS Decrypt and verifies the plaintext CRC32C before +// accepting the 32-byte DEK. +func (w *GCPKMSWrapper) Unwrap(wrapped []byte) ([]byte, error) { + if w == nil || w.client == nil { + return nil, errors.Wrap(ErrInvalidProviderResponse, "kek: GCP KMS client is nil") + } + if len(wrapped) == 0 { + return nil, errors.Wrap(ErrInvalidProviderResponse, "kek: GCP KMS ciphertext is empty") + } + ctx, cancel := requestContext(w.timeout) + defer cancel() + out, err := w.client.Decrypt(ctx, &kmspb.DecryptRequest{ + Name: w.keyName, + Ciphertext: wrapped, + AdditionalAuthenticatedData: gcpKMSAAD, + CiphertextCrc32C: crc32c(wrapped), + AdditionalAuthenticatedDataCrc32C: crc32c(gcpKMSAAD), + }) + if err != nil { + return nil, errors.Wrap(err, "kek: GCP KMS Decrypt") + } + if out == nil || !checksumMatches(out.Plaintext, out.PlaintextCrc32C) { + return nil, errors.WithStack(ErrInvalidProviderResponse) + } + if err := validateDEK(out.Plaintext); err != nil { + return nil, errors.Wrap(err, "kek: GCP KMS plaintext") + } + return append([]byte(nil), out.Plaintext...), nil +} + +// Name returns the provider and configured CryptoKey resource name. +func (w *GCPKMSWrapper) Name() string { return "gcp-kms:" + w.keyName } diff --git a/internal/encryption/kek/gcp_kms_test.go b/internal/encryption/kek/gcp_kms_test.go new file mode 100644 index 000000000..a0c394aaa --- /dev/null +++ b/internal/encryption/kek/gcp_kms_test.go @@ -0,0 +1,80 @@ +package kek + +import ( + "bytes" + "context" + "testing" + + "cloud.google.com/go/kms/apiv1/kmspb" + "github.com/stretchr/testify/require" +) + +type fakeGCPKMSClient struct { + encryptInput *kmspb.EncryptRequest + decryptInput *kmspb.DecryptRequest + encryptOutput *kmspb.EncryptResponse + decryptOutput *kmspb.DecryptResponse + err error +} + +func (f *fakeGCPKMSClient) Encrypt(_ context.Context, input *kmspb.EncryptRequest) (*kmspb.EncryptResponse, error) { + f.encryptInput = input + return f.encryptOutput, f.err +} + +func (f *fakeGCPKMSClient) Decrypt(_ context.Context, input *kmspb.DecryptRequest) (*kmspb.DecryptResponse, error) { + f.decryptInput = input + return f.decryptOutput, f.err +} + +func TestGCPKMSWrapperRequestAndResponseIntegrity(t *testing.T) { + const keyName = "projects/p/locations/global/keyRings/r/cryptoKeys/k" + dek := bytes.Repeat([]byte{0x52}, fileKEKSize) + ciphertext := []byte("gcp-wrapped") + client := &fakeGCPKMSClient{ + encryptOutput: &kmspb.EncryptResponse{ + Ciphertext: ciphertext, + CiphertextCrc32C: crc32c(ciphertext), + VerifiedPlaintextCrc32C: true, + VerifiedAdditionalAuthenticatedDataCrc32C: true, + }, + decryptOutput: &kmspb.DecryptResponse{Plaintext: dek, PlaintextCrc32C: crc32c(dek)}, + } + wrapper := newGCPKMSWrapper(client, keyName) + + wrapped, err := wrapper.Wrap(dek) + require.NoError(t, err) + require.Equal(t, ciphertext, wrapped) + require.Equal(t, keyName, client.encryptInput.Name) + require.Equal(t, gcpKMSAAD, client.encryptInput.AdditionalAuthenticatedData) + require.True(t, checksumMatches(dek, client.encryptInput.PlaintextCrc32C)) + + plain, err := wrapper.Unwrap(wrapped) + require.NoError(t, err) + require.Equal(t, dek, plain) + require.Equal(t, gcpKMSAAD, client.decryptInput.AdditionalAuthenticatedData) + require.True(t, checksumMatches(wrapped, client.decryptInput.CiphertextCrc32C)) + require.Equal(t, "gcp-kms:"+keyName, wrapper.Name()) +} + +func TestGCPKMSWrapperRejectsIntegrityFailures(t *testing.T) { + const keyName = "projects/p/locations/global/keyRings/r/cryptoKeys/k" + dek := bytes.Repeat([]byte{0x52}, fileKEKSize) + client := &fakeGCPKMSClient{encryptOutput: &kmspb.EncryptResponse{ + Ciphertext: []byte("ciphertext"), + CiphertextCrc32C: crc32c([]byte("different")), + VerifiedPlaintextCrc32C: true, + VerifiedAdditionalAuthenticatedDataCrc32C: true, + }} + _, err := newGCPKMSWrapper(client, keyName).Wrap(dek) + require.ErrorIs(t, err, ErrInvalidProviderResponse) + + client.decryptOutput = &kmspb.DecryptResponse{Plaintext: dek, PlaintextCrc32C: crc32c([]byte("different"))} + _, err = newGCPKMSWrapper(client, keyName).Unwrap([]byte("ciphertext")) + require.ErrorIs(t, err, ErrInvalidProviderResponse) +} + +func TestValidGCPKeyName(t *testing.T) { + require.True(t, validGCPKeyName("projects/p/locations/global/keyRings/r/cryptoKeys/k")) + require.False(t, validGCPKeyName("projects/p/keyRings/r/cryptoKeys/k")) +} diff --git a/internal/encryption/kek/kek.go b/internal/encryption/kek/kek.go index a4550af2e..93dc8e8f2 100644 --- a/internal/encryption/kek/kek.go +++ b/internal/encryption/kek/kek.go @@ -6,8 +6,8 @@ // in a KMS, a sealed file, or HashiCorp Vault — and only exercised at // process boot and at DEK rotation. // -// Stage 0 ships only the FileWrapper for tests / single-host clusters. -// AWS KMS, GCP KMS, and Vault providers are added in Stage 9. +// Implementations include AWS KMS, GCP KMS, Vault Transit, a static file, and +// the test/CI-only environment provider described by the design. package kek // Wrapper wraps and unwraps DEK bytes under an externally-held KEK. diff --git a/internal/encryption/kek/provider.go b/internal/encryption/kek/provider.go new file mode 100644 index 000000000..e2350fbad --- /dev/null +++ b/internal/encryption/kek/provider.go @@ -0,0 +1,33 @@ +package kek + +import ( + "context" + "time" + + "github.com/cockroachdb/errors" +) + +const providerRequestTimeout = 30 * time.Second + +var ( + // ErrInvalidDEKLength rejects non-AES-256 data keys at every provider + // boundary, including malformed provider responses. + ErrInvalidDEKLength = errors.New("kek: DEK must be exactly 32 bytes") + // ErrInvalidProviderResponse rejects an empty or integrity-invalid response + // before it can be persisted in the encryption sidecar. + ErrInvalidProviderResponse = errors.New("kek: invalid provider response") +) + +func validateDEK(dek []byte) error { + if len(dek) != fileKEKSize { + return errors.Wrapf(ErrInvalidDEKLength, "got %d bytes", len(dek)) + } + return nil +} + +func requestContext(timeout time.Duration) (context.Context, context.CancelFunc) { + if timeout <= 0 { + timeout = providerRequestTimeout + } + return context.WithTimeout(context.Background(), timeout) +} diff --git a/internal/encryption/kek/source.go b/internal/encryption/kek/source.go new file mode 100644 index 000000000..fecf5bc7a --- /dev/null +++ b/internal/encryption/kek/source.go @@ -0,0 +1,91 @@ +package kek + +import ( + "context" + "os" + "strings" + + "github.com/cockroachdb/errors" +) + +const ( + awsKMSScheme = "aws-kms://" + gcpKMSScheme = "gcp-kms://" + vaultTransitScheme = "vault-transit://" +) + +var ( + // ErrMultipleKEKSources rejects ambiguous key configuration rather than + // silently selecting one source by precedence. + ErrMultipleKEKSources = errors.New("kek: configure exactly one of --kekFile, --kekUri, or ELASTICKV_KEK_BASE64") + // ErrInvalidKEKURI rejects unknown providers and malformed provider targets. + ErrInvalidKEKURI = errors.New("kek: invalid KEK URI") +) + +// NewWrapperFromSources resolves exactly one file, URI, or environment-backed +// KEK. No configured source returns nil so encryption-disabled deployments keep +// their existing startup behavior. +func NewWrapperFromSources(ctx context.Context, filePath, uri string) (Wrapper, error) { + _, envSet := os.LookupEnv(EnvVar) + if countConfiguredSources(filePath != "", uri != "", envSet) > 1 { + return nil, rejectSourceConflict(envSet) + } + switch { + case filePath != "": + wrapper, err := NewFileWrapper(filePath) + if err != nil { + return nil, errors.Wrapf(err, "kek: load file %q", filePath) + } + return wrapper, nil + case uri != "": + return newURIWrapper(ctx, uri) + case envSet: + return NewEnvWrapper() + default: + return nil, nil + } +} + +func countConfiguredSources(sources ...bool) int { + configured := 0 + for _, present := range sources { + if present { + configured++ + } + } + return configured +} + +func rejectSourceConflict(envSet bool) error { + if envSet { + if err := os.Unsetenv(EnvVar); err != nil { + return errors.Wrapf(err, "kek: unset %s after source conflict", EnvVar) + } + } + return errors.WithStack(ErrMultipleKEKSources) +} + +func newURIWrapper(ctx context.Context, uri string) (Wrapper, error) { + switch { + case strings.HasPrefix(uri, awsKMSScheme): + target := strings.TrimPrefix(uri, awsKMSScheme) + if target == "" { + return nil, errors.WithStack(ErrInvalidKEKURI) + } + return NewAWSKMSWrapper(ctx, target) + case strings.HasPrefix(uri, gcpKMSScheme): + target := strings.TrimPrefix(uri, gcpKMSScheme) + if target == "" { + return nil, errors.WithStack(ErrInvalidKEKURI) + } + return NewGCPKMSWrapper(ctx, target) + case strings.HasPrefix(uri, vaultTransitScheme): + target := strings.TrimPrefix(uri, vaultTransitScheme) + if target == "" { + return nil, errors.WithStack(ErrInvalidKEKURI) + } + return NewVaultTransitWrapper(target) + default: + return nil, errors.Wrapf(ErrInvalidKEKURI, "unsupported URI %q", uri) + } +} diff --git a/internal/encryption/kek/source_test.go b/internal/encryption/kek/source_test.go new file mode 100644 index 000000000..b6aa17505 --- /dev/null +++ b/internal/encryption/kek/source_test.go @@ -0,0 +1,57 @@ +package kek + +import ( + "bytes" + "context" + "encoding/base64" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewWrapperFromSourcesNoSource(t *testing.T) { + t.Setenv(EnvVar, "") + require.NoError(t, os.Unsetenv(EnvVar)) + wrapper, err := NewWrapperFromSources(context.Background(), "", "") + require.NoError(t, err) + require.Nil(t, wrapper) +} + +func TestNewWrapperFromSourcesRejectsAmbiguity(t *testing.T) { + t.Setenv(EnvVar, base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, fileKEKSize))) + _, err := NewWrapperFromSources(context.Background(), "/tmp/kek", "") + require.ErrorIs(t, err, ErrMultipleKEKSources) + _, stillSet := os.LookupEnv(EnvVar) + require.False(t, stillSet) +} + +func TestNewWrapperFromSourcesFileAndEnv(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "kek.bin") + require.NoError(t, os.WriteFile(path, bytes.Repeat([]byte{2}, fileKEKSize), 0o600)) + + wrapper, err := NewWrapperFromSources(context.Background(), path, "") + require.NoError(t, err) + require.Contains(t, wrapper.Name(), "file:") + + t.Setenv(EnvVar, base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{3}, fileKEKSize))) + wrapper, err = NewWrapperFromSources(context.Background(), "", "") + require.NoError(t, err) + require.Equal(t, "env", wrapper.Name()) + _, stillSet := os.LookupEnv(EnvVar) + require.False(t, stillSet) +} + +func TestNewURIWrapperRejectsInvalidTargetsWithoutNetwork(t *testing.T) { + for _, uri := range []string{ + "unknown://key", + "aws-kms://not-an-arn", + "gcp-kms://projects/incomplete", + "vault-transit://transit", + } { + _, err := newURIWrapper(context.Background(), uri) + require.ErrorIsf(t, err, ErrInvalidKEKURI, "uri=%q", uri) + } +} diff --git a/internal/encryption/kek/vault.go b/internal/encryption/kek/vault.go new file mode 100644 index 000000000..fc3e7f391 --- /dev/null +++ b/internal/encryption/kek/vault.go @@ -0,0 +1,127 @@ +package kek + +import ( + "context" + "encoding/base64" + "os" + "strings" + "time" + + "github.com/cockroachdb/errors" + vaultapi "github.com/hashicorp/vault/api" +) + +type vaultLogicalClient interface { + WriteWithContext(context.Context, string, map[string]interface{}) (*vaultapi.Secret, error) +} + +// VaultTransitWrapper wraps DEKs with a Vault Transit symmetric key. +type VaultTransitWrapper struct { + logical vaultLogicalClient + mount string + keyName string + timeout time.Duration +} + +// NewVaultTransitWrapper uses the standard VAULT_ADDR, VAULT_TOKEN, TLS, and +// namespace environment configuration. target is /. +func NewVaultTransitWrapper(target string) (*VaultTransitWrapper, error) { + mount, keyName, err := parseVaultTarget(target) + if err != nil { + return nil, err + } + config := vaultapi.DefaultConfig() + if err := config.ReadEnvironment(); err != nil { + return nil, errors.Wrap(err, "kek: read Vault environment") + } + client, err := vaultapi.NewClient(config) + if err != nil { + return nil, errors.Wrap(err, "kek: create Vault client") + } + client.SetToken(os.Getenv("VAULT_TOKEN")) + return newVaultTransitWrapper(client.Logical(), mount, keyName), nil +} + +func newVaultTransitWrapper(logical vaultLogicalClient, mount, keyName string) *VaultTransitWrapper { + return &VaultTransitWrapper{logical: logical, mount: mount, keyName: keyName, timeout: providerRequestTimeout} +} + +func parseVaultTarget(target string) (string, string, error) { + parts := strings.Split(target, "/") + if len(parts) < 2 || parts[0] == "" { + return "", "", errors.Wrapf(ErrInvalidKEKURI, "invalid Vault Transit target %q", target) + } + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return "", "", errors.Wrapf(ErrInvalidKEKURI, "invalid Vault Transit target %q", target) + } + } + return parts[0], strings.Join(parts[1:], "/"), nil +} + +// Wrap base64-encodes the binary DEK for Vault's JSON API and stores Vault's +// versioned ciphertext string as the wrapped sidecar bytes. +func (w *VaultTransitWrapper) Wrap(dek []byte) ([]byte, error) { + if w == nil || w.logical == nil { + return nil, errors.Wrap(ErrInvalidProviderResponse, "kek: Vault client is nil") + } + if err := validateDEK(dek); err != nil { + return nil, err + } + ctx, cancel := requestContext(w.timeout) + defer cancel() + secret, err := w.logical.WriteWithContext(ctx, w.mount+"/encrypt/"+w.keyName, map[string]interface{}{ + "plaintext": base64.StdEncoding.EncodeToString(dek), + }) + if err != nil { + return nil, errors.Wrap(err, "kek: Vault Transit encrypt") + } + ciphertext, ok := vaultString(secret, "ciphertext") + if !ok || !strings.HasPrefix(ciphertext, "vault:v") { + return nil, errors.WithStack(ErrInvalidProviderResponse) + } + return []byte(ciphertext), nil +} + +// Unwrap asks Vault Transit to decrypt its versioned ciphertext and validates +// the returned base64 plaintext as a 32-byte DEK. +func (w *VaultTransitWrapper) Unwrap(wrapped []byte) ([]byte, error) { + if w == nil || w.logical == nil { + return nil, errors.Wrap(ErrInvalidProviderResponse, "kek: Vault client is nil") + } + if len(wrapped) == 0 || !strings.HasPrefix(string(wrapped), "vault:v") { + return nil, errors.Wrap(ErrInvalidProviderResponse, "kek: invalid Vault ciphertext") + } + ctx, cancel := requestContext(w.timeout) + defer cancel() + secret, err := w.logical.WriteWithContext(ctx, w.mount+"/decrypt/"+w.keyName, map[string]interface{}{ + "ciphertext": string(wrapped), + }) + if err != nil { + return nil, errors.Wrap(err, "kek: Vault Transit decrypt") + } + encoded, ok := vaultString(secret, "plaintext") + if !ok { + return nil, errors.WithStack(ErrInvalidProviderResponse) + } + plain, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, errors.Wrap(ErrInvalidProviderResponse, "kek: Vault plaintext is not base64") + } + if err := validateDEK(plain); err != nil { + clear(plain) + return nil, errors.Wrap(err, "kek: Vault plaintext") + } + return plain, nil +} + +func vaultString(secret *vaultapi.Secret, field string) (string, bool) { + if secret == nil || secret.Data == nil { + return "", false + } + value, ok := secret.Data[field].(string) + return value, ok && value != "" +} + +// Name returns the provider and Transit mount/key path. +func (w *VaultTransitWrapper) Name() string { return "vault-transit:" + w.mount + "/" + w.keyName } diff --git a/internal/encryption/kek/vault_test.go b/internal/encryption/kek/vault_test.go new file mode 100644 index 000000000..60f864822 --- /dev/null +++ b/internal/encryption/kek/vault_test.go @@ -0,0 +1,65 @@ +package kek + +import ( + "bytes" + "context" + "encoding/base64" + "testing" + + "github.com/hashicorp/vault/api" + "github.com/stretchr/testify/require" +) + +type fakeVaultLogical struct { + path string + data map[string]interface{} + dek []byte +} + +func (f *fakeVaultLogical) WriteWithContext(_ context.Context, path string, data map[string]interface{}) (*api.Secret, error) { + f.path = path + f.data = data + if _, ok := data["plaintext"]; ok { + return &api.Secret{Data: map[string]interface{}{"ciphertext": "vault:v1:ciphertext"}}, nil + } + return &api.Secret{Data: map[string]interface{}{"plaintext": base64.StdEncoding.EncodeToString(f.dek)}}, nil +} + +func TestVaultTransitWrapperRequestBinding(t *testing.T) { + dek := bytes.Repeat([]byte{0x62}, fileKEKSize) + logical := &fakeVaultLogical{dek: dek} + wrapper := newVaultTransitWrapper(logical, "transit", "service/key") + + wrapped, err := wrapper.Wrap(dek) + require.NoError(t, err) + require.Equal(t, []byte("vault:v1:ciphertext"), wrapped) + require.Equal(t, "transit/encrypt/service/key", logical.path) + require.Equal(t, base64.StdEncoding.EncodeToString(dek), logical.data["plaintext"]) + + plain, err := wrapper.Unwrap(wrapped) + require.NoError(t, err) + require.Equal(t, dek, plain) + require.Equal(t, "transit/decrypt/service/key", logical.path) + require.Equal(t, "vault:v1:ciphertext", logical.data["ciphertext"]) + require.Equal(t, "vault-transit:transit/service/key", wrapper.Name()) +} + +func TestParseVaultTarget(t *testing.T) { + mount, keyName, err := parseVaultTarget("transit/service/key") + require.NoError(t, err) + require.Equal(t, "transit", mount) + require.Equal(t, "service/key", keyName) + for _, target := range []string{"", "transit", "transit//key", "../key", "transit/../key"} { + _, _, err := parseVaultTarget(target) + require.ErrorIsf(t, err, ErrInvalidKEKURI, "target=%q", target) + } +} + +func TestVaultTransitWrapperRejectsMalformedResponses(t *testing.T) { + logical := &fakeVaultLogical{dek: []byte("short")} + wrapper := newVaultTransitWrapper(logical, "transit", "key") + _, err := wrapper.Unwrap([]byte("vault:v1:ciphertext")) + require.ErrorIs(t, err, ErrInvalidDEKLength) + _, err = wrapper.Unwrap([]byte("not-vault")) + require.ErrorIs(t, err, ErrInvalidProviderResponse) +} diff --git a/internal/encryption/startup.go b/internal/encryption/startup.go index 411aa42ef..6f8278bf4 100644 --- a/internal/encryption/startup.go +++ b/internal/encryption/startup.go @@ -28,7 +28,7 @@ type StartupConfig struct { // (downgrade prevention). EncryptionEnabled bool - // KEKConfigured is true iff --kekFile is non-empty. The KEK + // KEKConfigured is true iff one configured KEK source loaded. The KEK // itself is supplied via KEK below; KEKConfigured exists // independently so the helper can distinguish "operator did // not supply a KEK source" from "supplied but failed to load" @@ -203,7 +203,7 @@ func guardSidecarWithoutFlag(cfg StartupConfig, sidecarPresent bool) error { } // guardKEKRequired fires when the operator turned on -// --encryption-enabled without supplying --kekFile. A flag-on / +// --encryption-enabled without supplying a KEK source. A flag-on / // KEK-off node would refuse every mutator at the Stage 6B-2 RPC // gate AND HaltApply if a mutator ever did commit, neither of // which matches the operator's stated intent. Fail fast at startup. @@ -220,7 +220,7 @@ func guardKEKRequired(cfg StartupConfig) error { // guardKEKMatchesSidecar attempts to KEK-unwrap every wrapped DEK // in the sidecar. A single failure fires ErrKEKMismatch with the // offending key_id annotated — the classic operator error here is -// "wrong --kekFile points at a key from a different cluster" and +// "the configured KEK belongs to a different cluster" and // the key_id identifies which DEK could not be unwrapped, which is // almost always enough to root-cause. // diff --git a/main.go b/main.go index 9274bd07b..dbbf19027 100644 --- a/main.go +++ b/main.go @@ -192,22 +192,22 @@ var ( // // Mutating RPCs (BootstrapEncryption / RotateDEK / // RegisterEncryptionWriter) are gated by Stage 6B-2 on the - // AND of --encryption-enabled and --kekFile being non-empty. + // AND of --encryption-enabled and a successfully loaded KEK source. // Setting --encryptionSidecarPath ALONE no longer enables // mutators; the operator must explicitly opt in to encryption // AND supply a KEK source. With either gate condition false, // registerEncryptionAdminServer omits the Proposer + LeaderView // options and every mutator short-circuits at the gRPC boundary // with FailedPrecondition before any Raft proposal is created. - encryptionSidecarPath = flag.String("encryptionSidecarPath", "", "§5.1 keys.json path; enables read-only EncryptionAdmin capability probing. Mutating RPCs (Bootstrap / RotateDEK / RegisterEncryptionWriter) are additionally gated on this flag being non-empty AND --encryption-enabled AND --kekFile being non-empty (all three required so the applier's WithKEK + WithKeystore + WithSidecarPath options are all wired before mutators can commit).") + encryptionSidecarPath = flag.String("encryptionSidecarPath", "", "§5.1 keys.json path; enables read-only EncryptionAdmin capability probing. Mutating RPCs additionally require --encryption-enabled and one loaded KEK source.") // Stage 6B-2: cluster-wide encryption opt-in flag. The mutating // EncryptionAdmin RPCs (BootstrapEncryption, RotateDEK, // RegisterEncryptionWriter) become reachable only when this - // flag is set AND --kekFile points at a valid KEK source. + // flag is set AND exactly one configured KEK source loads successfully. // Default off; pre-Stage-6 clusters and operators who have // not yet committed to encryption are unaffected. - encryptionEnabled = flag.Bool("encryption-enabled", false, "§6.5 opt-in to encryption-mutating EncryptionAdmin RPCs. Requires --kekFile to be set; without that, mutators still refuse with FailedPrecondition. Default off.") + encryptionEnabled = flag.Bool("encryption-enabled", false, "§6.5 opt-in to encryption-mutating EncryptionAdmin RPCs. Requires exactly one KEK source from --kekFile, --kekUri, or ELASTICKV_KEK_BASE64. Default off.") // Stage 6F: operator-requested DEK rotation at boot. The flag is // intentionally a request, not a guarantee: only the leader of the @@ -216,15 +216,14 @@ var ( // leadership during this process uptime. encryptionRotateOnStartup = flag.Bool("encryption-rotate-on-startup", false, "§6.5 request a one-shot DEK rotation after this node becomes leader of the default Raft group. Safe for rolling restarts: followers keep the request in memory and only fire if they acquire leadership during this process uptime.") - // Stage 6B-2: KEK source. The KEK never appears in elastickv's + // KEK sources. The KEK never appears in elastickv's // data dir; it is held externally and exercised only at process - // boot and at DEK bootstrap/rotation per §5.1. Stage 6B-2 ships - // only the file-backed wrapper (kek.FileWrapper); KMS providers - // (--kekUri) land in Stage 9. Empty disables KEK loading; the + // boot and at DEK bootstrap/rotation per §5.1. Empty disables KEK loading; the // applier's ApplyBootstrap and ApplyRotation paths then return // ErrKEKNotConfigured at apply time, which is masked at the // RPC boundary by the mutator gate documented above. kekFile = flag.String("kekFile", "", "§5.1 KEK file path (32 raw bytes, owner-only mode). When set, the file-backed kek.Wrapper is constructed at startup and threaded into the §6.3 EncryptionApplier so ApplyBootstrap and ApplyRotation can KEK-unwrap.") + kekURI = flag.String("kekUri", "", "§5.1 remote KEK URI: aws-kms://, gcp-kms://, or vault-transit:///. Mutually exclusive with --kekFile and ELASTICKV_KEK_BASE64.") // Key visualizer sampler flags. The sampler runs entirely in-memory // on each node, feeds AdminServer.GetKeyVizMatrix, and is disabled @@ -438,7 +437,7 @@ func run() error { // visible to every shard's storage cipher. // // Both are nil-safe in the applier path: WithKEK / WithKeystore - // are only attached to the applier when --kekFile is non-empty + // are only attached to the applier when a KEK source is loaded // (else the applier stays in the Stage 6A posture where // ApplyBootstrap / ApplyRotation return ErrKEKNotConfigured). kekWrapper, err := loadKEKAfterPreNonceStartupGuards(cfg) @@ -600,6 +599,7 @@ func run() error { redisApplyObserver: redisApplyObserver, cleanup: &cleanup, encWiring: encWiring, + kekConfigured: kekWrapper != nil, keyvizSampler: sampler, encryptionConfChangeInterceptor: encryptionConfChangeInterceptor, }); err != nil { @@ -1585,7 +1585,7 @@ func loadKEKAfterPreNonceStartupGuards(cfg runtimeConfig) (kek.Wrapper, error) { return loadKEKAndRunStartupGuards() } -// loadKEKAndRunStartupGuards loads the file-backed KEK wrapper and +// loadKEKAndRunStartupGuards loads the configured KEK wrapper and // runs the §9.1 startup-refusal guards (Stage 6C-1) BEFORE // buildShardGroups constructs any Raft engine or storage state. The // two operations are paired in a single helper because the guards @@ -1614,7 +1614,7 @@ func loadKEKAndRunStartupGuards() (kek.Wrapper, error) { } if err := encryption.CheckStartupGuards(encryption.StartupConfig{ EncryptionEnabled: *encryptionEnabled, - KEKConfigured: *kekFile != "", + KEKConfigured: kekWrapper != nil, KEK: kekWrapper, SidecarPath: *encryptionSidecarPath, }); err != nil { @@ -1623,20 +1623,17 @@ func loadKEKAndRunStartupGuards() (kek.Wrapper, error) { return kekWrapper, nil } -// loadKEKWrapperFromFlag constructs the file-backed KEK wrapper -// from the --kekFile flag, returning nil if the flag is empty. +// loadKEKWrapperFromFlag constructs the configured KEK wrapper from the +// mutually-exclusive file, URI, or environment source. // Returns the kek.Wrapper interface rather than the concrete // *kek.FileWrapper so the call site (buildShardGroups → applier) // stays decoupled from the file-mode provider — Stage 9 KMS // providers (AWS KMS, GCP KMS, Vault) will satisfy the same // interface and slot in without rewriting the dispatch site. func loadKEKWrapperFromFlag() (kek.Wrapper, error) { - if *kekFile == "" { - return nil, nil - } - w, err := kek.NewFileWrapper(*kekFile) + w, err := kek.NewWrapperFromSources(context.Background(), *kekFile, *kekURI) if err != nil { - return nil, errors.Wrapf(err, "failed to load KEK from %s", *kekFile) + return nil, errors.Wrap(err, "failed to load KEK source") } return w, nil } @@ -1790,6 +1787,7 @@ type serversInput struct { metricsRegistry *monitoring.Registry cfg runtimeConfig encWiring encryptionWriteWiring + kekConfigured bool redisApplyObserver *adapter.RedisApplyObserver cleanup *internalutil.CleanupStack // keyvizSampler is the in-memory key visualizer sampler, or nil @@ -1873,6 +1871,7 @@ func startServersAfterStartupRotation(waitRotateOnStartup startupRotationWaiter, pubsubRelay: adapter.NewRedisPubSubRelay(), readTracker: in.readTracker, encWiring: in.encWiring, + kekConfigured: in.kekConfigured, redisApplyObserver: in.redisApplyObserver, dynamoAddress: *dynamoAddr, defaultGroup: in.cfg.defaultGroup, @@ -2629,6 +2628,7 @@ func startRaftServers( forwardDeps adminForwardServerDeps, confChangeInterceptor internalraftadmin.MembershipChangeInterceptor, encWiring encryptionWriteWiring, + kekConfigured bool, defaultGroup uint64, s3BlobObserver adapter.S3BlobOffloadObserver, s3BlobPushBlocked func() bool, @@ -2638,7 +2638,7 @@ func startRaftServers( // options appended below. Sized as a constant so the magic-number // linter does not complain. const extraOptsCap = 2 - enableMutators := encryptionMutatorsEnabled() + enableMutators := encryptionMutatorsEnabled(kekConfigured) encryptionCapabilityFanout := buildEncryptionCapabilityFanout(ctx, eg, runtimes, enableMutators) startStorageEnvelopeV2CapabilityMonitor(ctx, eg, encryptionCapabilityFanout, encWiring) adminWriterRegistry := writerRegistryForEncryptionAdmin(runtimes, defaultGroup) @@ -3042,6 +3042,7 @@ type runtimeServerRunner struct { readTracker *kv.ActiveTimestampTracker redisApplyObserver *adapter.RedisApplyObserver encWiring encryptionWriteWiring + kekConfigured bool dynamoAddress string defaultGroup uint64 leaderDynamo map[string]string @@ -3162,6 +3163,7 @@ func (r *runtimeServerRunner) startRaftTransport() error { forwardDeps, r.encryptionConfChangeInterceptor, r.encWiring, + r.kekConfigured, r.defaultGroup, r.metricsRegistry.S3BlobOffloadObserver(), s3BlobPushBlocked, diff --git a/main_encryption_admin.go b/main_encryption_admin.go index 9243082c4..f2c98a9b6 100644 --- a/main_encryption_admin.go +++ b/main_encryption_admin.go @@ -14,7 +14,7 @@ import ( // readback. Returns true iff THIS NODE has all three of: // // - --encryption-enabled (explicit operator opt-in) -// - --kekFile non-empty (KEK source loaded so ApplyBootstrap +// - a KEK source loaded (file, URI, or test/CI environment source, so ApplyBootstrap // / ApplyRotation can KEK-unwrap) // - --encryptionSidecarPath non-empty (so the applier can // crash-durably persist Active.{Storage,Raft} + keys[]) @@ -62,8 +62,8 @@ import ( // Kept in this file (not main.go) so the flag-driven gate logic // is colocated with the registerEncryptionAdminServer helper // that consumes it. -func encryptionMutatorsEnabled() bool { - return *encryptionEnabled && *kekFile != "" && *encryptionSidecarPath != "" +func encryptionMutatorsEnabled(kekConfigured bool) bool { + return *encryptionEnabled && kekConfigured && *encryptionSidecarPath != "" } func encryptionAdminMutatorAuthority(enabled bool, groupID, defaultGroup uint64) bool { @@ -123,7 +123,7 @@ type encryptionAdminEngine interface { // // Stage 6B-2: the mutator wiring (Proposer + LeaderView) is now // gated on the supplied enableMutators boolean, which the caller -// in main.go computes as (--encryption-enabled AND --kekFile +// in main.go computes as (--encryption-enabled AND loaded KEK // non-empty AND engine non-nil). When enableMutators is false, // Proposer + LeaderView stay unwired and EncryptionAdminServer's // BootstrapEncryption / RotateDEK / RegisterEncryptionWriter @@ -142,7 +142,7 @@ type encryptionAdminEngine interface { // means the cluster has explicitly chosen NOT to participate // in the §7.1 rollout, so mutator RPCs MUST refuse even on // a fully-keyed binary. -// - --kekFile being non-empty means a KEK source is loaded; +// - kekConfigured means a file, URI, or environment KEK source is loaded; // without it, a mutator that committed would land in the // applier with no KEK and return ErrKEKNotConfigured from // the §6.3 HaltApply path — that is fail-closed but it diff --git a/main_encryption_kek_source_test.go b/main_encryption_kek_source_test.go new file mode 100644 index 000000000..f0ef447ba --- /dev/null +++ b/main_encryption_kek_source_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "testing" + + "github.com/bootjp/elastickv/internal/encryption" + "github.com/bootjp/elastickv/internal/encryption/fsmwire" + "github.com/bootjp/elastickv/internal/encryption/kek" + "github.com/bootjp/elastickv/store" + "github.com/stretchr/testify/require" +) + +func TestEncryptionMutatorsEnabledUsesLoadedKEKState(t *testing.T) { + previousEnabled := *encryptionEnabled + previousSidecar := *encryptionSidecarPath + t.Cleanup(func() { + *encryptionEnabled = previousEnabled + *encryptionSidecarPath = previousSidecar + }) + + *encryptionEnabled = true + *encryptionSidecarPath = "/data/encryption/keys.json" + require.True(t, encryptionMutatorsEnabled(true)) + require.False(t, encryptionMutatorsEnabled(false)) + *encryptionEnabled = false + require.False(t, encryptionMutatorsEnabled(true)) + *encryptionEnabled = true + *encryptionSidecarPath = "" + require.False(t, encryptionMutatorsEnabled(true)) +} + +func TestEnvKEKBootstrapCutoverSnapshotRestore(t *testing.T) { + staticKEK := bytes.Repeat([]byte{0x71}, encryption.KeySize) + t.Setenv(kek.EnvVar, base64.StdEncoding.EncodeToString(staticKEK)) + wrapper, err := kek.NewWrapperFromSources(context.Background(), "", "") + require.NoError(t, err) + + storageDEK := bytes.Repeat([]byte{0x72}, encryption.KeySize) + raftDEK := bytes.Repeat([]byte{0x73}, encryption.KeySize) + wrappedStorage, err := wrapper.Wrap(storageDEK) + require.NoError(t, err) + wrappedRaft, err := wrapper.Wrap(raftDEK) + require.NoError(t, err) + + dir := t.TempDir() + sidecarPath := dir + "/keys.json" + keystore := encryption.NewKeystore() + wiring, err := buildEncryptionWriteWiring(true, "n1", sidecarPath, wrapper, keystore, []groupSpec{{id: 1}}) + require.NoError(t, err) + source, err := store.NewPebbleStore(dir+"/source", wiring.pebbleOptions()...) + require.NoError(t, err) + t.Cleanup(func() { _ = source.Close() }) + registry, err := store.WriterRegistryFor(source) + require.NoError(t, err) + applier, err := encryption.NewApplier( + registry, + encryption.WithKEK(wrapper), + encryption.WithKeystore(keystore), + encryption.WithSidecarPath(sidecarPath), + encryption.WithStateCache(wiring.cache), + ) + require.NoError(t, err) + + require.NoError(t, applier.ApplyBootstrap(1, fsmwire.BootstrapPayload{ + StorageDEKID: e2eStorageDEKID, + WrappedStorage: wrappedStorage, + RaftDEKID: e2eRaftDEKID, + WrappedRaft: wrappedRaft, + BatchRegistry: []fsmwire.RegistrationPayload{ + {DEKID: e2eStorageDEKID, FullNodeID: e2eNodeID, LocalEpoch: 0}, + }, + })) + require.NoError(t, applier.ApplyRotation(2, fsmwire.RotationPayload{ + SubTag: fsmwire.RotateSubEnableStorageEnvelope, + DEKID: e2eStorageDEKID, + Purpose: fsmwire.PurposeStorage, + Wrapped: []byte{}, + ProposerRegistration: fsmwire.RegistrationPayload{DEKID: e2eStorageDEKID, FullNodeID: e2eNodeID, LocalEpoch: 1}, + })) + wiring.cache.MarkRegistered(e2eStorageDEKID) + require.NoError(t, source.PutAt(context.Background(), []byte("env-kek"), []byte("secret"), 100, 0)) + + snapshot, err := source.Snapshot() + require.NoError(t, err) + var snapshotBytes bytes.Buffer + _, err = snapshot.WriteTo(&snapshotBytes) + require.NoError(t, err) + require.NoError(t, snapshot.Close()) + + target, err := store.NewPebbleStore(dir+"/target", wiring.pebbleOptions()...) + require.NoError(t, err) + t.Cleanup(func() { _ = target.Close() }) + require.NoError(t, target.Restore(bytes.NewReader(snapshotBytes.Bytes()))) + got, err := target.GetAt(context.Background(), []byte("env-kek"), 100) + require.NoError(t, err) + require.Equal(t, []byte("secret"), got) +} From df1c19fe53ef1b875c7d47f6812a6c9fe50b6818 Mon Sep 17 00:00:00 2001 From: bootjp Date: Sat, 18 Jul 2026 21:27:03 +0900 Subject: [PATCH 2/6] Reuse GCP KMS checksum table --- internal/encryption/kek/gcp_kms.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/encryption/kek/gcp_kms.go b/internal/encryption/kek/gcp_kms.go index 09c85af6d..418df7cdc 100644 --- a/internal/encryption/kek/gcp_kms.go +++ b/internal/encryption/kek/gcp_kms.go @@ -12,7 +12,10 @@ import ( "google.golang.org/protobuf/types/known/wrapperspb" ) -var gcpKMSAAD = []byte("elastickv-dek-wrap-v1") +var ( + gcpKMSAAD = []byte("elastickv-dek-wrap-v1") + crc32cCastagnoli = crc32.MakeTable(crc32.Castagnoli) +) type gcpKMSClient interface { Encrypt(context.Context, *kmspb.EncryptRequest) (*kmspb.EncryptResponse, error) @@ -65,7 +68,7 @@ func validGCPKeyName(name string) bool { } func crc32c(data []byte) *wrapperspb.Int64Value { - return wrapperspb.Int64(int64(crc32.Checksum(data, crc32.MakeTable(crc32.Castagnoli)))) + return wrapperspb.Int64(int64(crc32.Checksum(data, crc32cCastagnoli))) } func checksumMatches(data []byte, checksum *wrapperspb.Int64Value) bool { From 017ba51eca88dec3daec41304b0102e6796d0ccd Mon Sep 17 00:00:00 2001 From: bootjp Date: Sun, 19 Jul 2026 18:06:19 +0900 Subject: [PATCH 3/6] encryption: harden KEK provider startup --- ...6_04_29_partial_data_at_rest_encryption.md | 8 +- ...2026_07_18_implemented_9b_kek_providers.md | 17 +++- internal/encryption/kek/aws_kms.go | 2 +- internal/encryption/kek/aws_kms_test.go | 13 ++- internal/encryption/kek/provider.go | 33 ++++++++ internal/encryption/kek/provider_test.go | 82 +++++++++++++++++++ internal/encryption/kek/source_test.go | 6 +- internal/encryption/kek/vault.go | 19 ++++- internal/encryption/kek/vault_test.go | 28 +++++-- main.go | 13 +++ main_encryption_kek_source_test.go | 29 +++++++ 11 files changed, 231 insertions(+), 19 deletions(-) create mode 100644 internal/encryption/kek/provider_test.go diff --git a/docs/design/2026_04_29_partial_data_at_rest_encryption.md b/docs/design/2026_04_29_partial_data_at_rest_encryption.md index ea229f11c..21fadc4f4 100644 --- a/docs/design/2026_04_29_partial_data_at_rest_encryption.md +++ b/docs/design/2026_04_29_partial_data_at_rest_encryption.md @@ -1020,7 +1020,8 @@ Two-tier hierarchy: - **KEK (Key Encryption Key).** Held outside the cluster, never on the cluster's disks. Sources, in order of preference: - 1. AWS KMS: `--kekUri=aws-kms://arn:aws:kms:...`. The KMS key never + 1. AWS KMS: `--kekUri=aws-kms://arn:aws:kms:...:key/...` with an + immutable key ARN (mutable alias ARNs are rejected). The KMS key never leaves AWS; we call `Encrypt` / `Decrypt` to wrap/unwrap DEKs. 2. GCP KMS: `--kekUri=gcp-kms://projects/.../keys/...`. Same shape. 3. HashiCorp Vault Transit: `--kekUri=vault-transit://...`. @@ -1032,7 +1033,10 @@ Two-tier hierarchy: inspection); supported only for tests and CI. No default. If `--encryption-enabled` is set without a KEK source, - the process refuses to start. + the process refuses to start. Before encryption mutators are exposed, every + configured provider must also complete a random 32-byte DEK wrap/unwrap + preflight; this catches credentials, reachability, permission, key-binding, + and malformed-response failures before a Raft entry can commit. - **DEK (Data Encryption Key).** 32-byte AES key. Two DEKs are issued in v1: `dek_storage` (used by §4.1) and `dek_raft` diff --git a/docs/design/2026_07_18_implemented_9b_kek_providers.md b/docs/design/2026_07_18_implemented_9b_kek_providers.md index d8bae8392..5cd2b0451 100644 --- a/docs/design/2026_07_18_implemented_9b_kek_providers.md +++ b/docs/design/2026_07_18_implemented_9b_kek_providers.md @@ -8,7 +8,8 @@ Date: 2026-07-18 Stage 9B completes the §5.1 KEK source matrix: -- `--kekUri=aws-kms://` uses AWS KMS `Encrypt` / `Decrypt`, derives +- `--kekUri=aws-kms://` uses an immutable AWS KMS `key/` ARN with + `Encrypt` / `Decrypt`, derives the client region from the ARN, and binds every request to the fixed `elastickv-purpose=dek-wrap-v1` encryption context. - `--kekUri=gcp-kms://` uses Google Cloud KMS with fixed @@ -16,7 +17,7 @@ Stage 9B completes the §5.1 KEK source matrix: wrapped DEK or plaintext DEK is accepted. - `--kekUri=vault-transit:///` uses Vault Transit through standard `VAULT_*` client configuration. Binary DEKs are base64 encoded for the API; - only versioned `vault:v...` ciphertexts are accepted. + only `vault:v:` ciphertexts are accepted. - `ELASTICKV_KEK_BASE64` supplies a 32-byte test/CI-only static KEK. The variable is unset immediately after the decode attempt, including malformed input. It is also unset when source ambiguity is rejected. @@ -33,6 +34,13 @@ text. The same loaded-state boolean is threaded to the EncryptionAdmin mutator gate, so URI and environment providers can bootstrap/rotate while a failed or absent provider cannot expose mutating RPCs. +Before that gate can open, startup performs a real random 32-byte DEK +wrap/unwrap round trip and compares the result in constant time. This proves +provider reachability, credentials, encrypt/decrypt permission, key binding, +and response shape on a fresh data directory where no sidecar DEK exists to +exercise the provider. A failed preflight refuses process start before Raft or +the mutating RPC surface is available. + Remote wrappers use a 30-second operation deadline and reject nil, empty, integrity-invalid, or non-32-byte provider responses before sidecar state can be updated. Provider clients are safe for concurrent use and are hidden behind @@ -48,8 +56,9 @@ Verification includes: - fake-client unit tests for AWS encryption context, GCP AAD/CRC32C, and Vault binary request/response encoding; -- malformed provider response, wrong DEK length, invalid URI, source conflict, - and environment-unset tests; +- malformed provider response, strict Vault ciphertext, immutable AWS key ARN, + wrong DEK length, invalid URI, source conflict, environment-unset, and + startup wrap/unwrap preflight tests; - a production-wiring integration test that loads the environment KEK through the source selector, bootstraps both DEKs, enables the storage envelope, writes encrypted data, snapshots it, restores it into an encrypted Pebble diff --git a/internal/encryption/kek/aws_kms.go b/internal/encryption/kek/aws_kms.go index f4400be82..3f7beed32 100644 --- a/internal/encryption/kek/aws_kms.go +++ b/internal/encryption/kek/aws_kms.go @@ -32,7 +32,7 @@ type AWSKMSWrapper struct { func NewAWSKMSWrapper(ctx context.Context, keyARN string) (*AWSKMSWrapper, error) { parsed, err := arn.Parse(keyARN) if err != nil || parsed.Service != "kms" || parsed.Region == "" || parsed.AccountID == "" || - (!strings.HasPrefix(parsed.Resource, "key/") && !strings.HasPrefix(parsed.Resource, "alias/")) { + !strings.HasPrefix(parsed.Resource, "key/") || strings.TrimPrefix(parsed.Resource, "key/") == "" { return nil, errors.Wrapf(ErrInvalidKEKURI, "invalid AWS KMS key ARN %q", keyARN) } cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(parsed.Region)) diff --git a/internal/encryption/kek/aws_kms_test.go b/internal/encryption/kek/aws_kms_test.go index 97fb30767..f8bb012f7 100644 --- a/internal/encryption/kek/aws_kms_test.go +++ b/internal/encryption/kek/aws_kms_test.go @@ -82,6 +82,15 @@ func TestAWSKMSWrapperRejectsProviderFailures(t *testing.T) { } func TestNewAWSKMSWrapperRejectsInvalidARNBeforeConfigLoad(t *testing.T) { - _, err := NewAWSKMSWrapper(context.Background(), "not-an-arn") - require.ErrorIs(t, err, ErrInvalidKEKURI) + for _, keyARN := range []string{ + "not-an-arn", + "arn:aws:kms:us-east-1:123456789012:key/", + "arn:aws:kms:us-east-1:123456789012:alias/current", + "arn:aws:kms:us-east-1:123456789012:alias/", + } { + t.Run(keyARN, func(t *testing.T) { + _, err := NewAWSKMSWrapper(context.Background(), keyARN) + require.ErrorIs(t, err, ErrInvalidKEKURI) + }) + } } diff --git a/internal/encryption/kek/provider.go b/internal/encryption/kek/provider.go index e2350fbad..5991bc7ca 100644 --- a/internal/encryption/kek/provider.go +++ b/internal/encryption/kek/provider.go @@ -2,6 +2,8 @@ package kek import ( "context" + "crypto/rand" + "crypto/subtle" "time" "github.com/cockroachdb/errors" @@ -16,8 +18,39 @@ var ( // ErrInvalidProviderResponse rejects an empty or integrity-invalid response // before it can be persisted in the encryption sidecar. ErrInvalidProviderResponse = errors.New("kek: invalid provider response") + // ErrKEKPreflightFailed prevents mutators from opening when the configured + // provider cannot complete a real wrap/unwrap round trip. + ErrKEKPreflightFailed = errors.New("kek: provider preflight failed") ) +// VerifyWrapper proves credentials, provider reachability, encrypt/decrypt +// permissions, and key binding before any encryption mutator can commit a +// wrapped DEK. Provider constructors alone only validate local configuration. +func VerifyWrapper(wrapper Wrapper) error { + if wrapper == nil { + return errors.Wrap(ErrKEKPreflightFailed, "wrapper is nil") + } + dek := make([]byte, fileKEKSize) + defer clear(dek) + if _, err := rand.Read(dek); err != nil { + return errors.Wrapf(ErrKEKPreflightFailed, "generate probe DEK: %v", err) + } + wrapped, err := wrapper.Wrap(dek) + if err != nil { + return errors.Wrapf(ErrKEKPreflightFailed, "wrap with %s: %v", wrapper.Name(), err) + } + defer clear(wrapped) + plain, err := wrapper.Unwrap(wrapped) + if err != nil { + return errors.Wrapf(ErrKEKPreflightFailed, "unwrap with %s: %v", wrapper.Name(), err) + } + defer clear(plain) + if len(plain) != len(dek) || subtle.ConstantTimeCompare(plain, dek) != 1 { + return errors.Wrapf(ErrKEKPreflightFailed, "%s returned a different DEK", wrapper.Name()) + } + return nil +} + func validateDEK(dek []byte) error { if len(dek) != fileKEKSize { return errors.Wrapf(ErrInvalidDEKLength, "got %d bytes", len(dek)) diff --git a/internal/encryption/kek/provider_test.go b/internal/encryption/kek/provider_test.go new file mode 100644 index 000000000..af913df00 --- /dev/null +++ b/internal/encryption/kek/provider_test.go @@ -0,0 +1,82 @@ +package kek + +import ( + "bytes" + "testing" + + "github.com/cockroachdb/errors" + "github.com/stretchr/testify/require" +) + +type preflightWrapper struct { + wrapErr error + unwrapErr error + mismatch bool +} + +func (w *preflightWrapper) Wrap(dek []byte) ([]byte, error) { + if w.wrapErr != nil { + return nil, w.wrapErr + } + return append([]byte(nil), dek...), nil +} + +func (w *preflightWrapper) Unwrap(wrapped []byte) ([]byte, error) { + if w.unwrapErr != nil { + return nil, w.unwrapErr + } + plain := append([]byte(nil), wrapped...) + if w.mismatch { + plain[0] ^= 0xFF + } + return plain, nil +} + +func (*preflightWrapper) Name() string { return "test" } + +func TestVerifyWrapper(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + wrapper Wrapper + wantErr bool + }{ + {name: "round trip", wrapper: &preflightWrapper{}}, + {name: "nil wrapper", wrapper: nil, wantErr: true}, + {name: "wrap failure", wrapper: &preflightWrapper{wrapErr: errors.New("denied")}, wantErr: true}, + {name: "unwrap failure", wrapper: &preflightWrapper{unwrapErr: errors.New("denied")}, wantErr: true}, + {name: "mismatched plaintext", wrapper: &preflightWrapper{mismatch: true}, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + err := VerifyWrapper(tc.wrapper) + if tc.wantErr { + require.ErrorIs(t, err, ErrKEKPreflightFailed) + return + } + require.NoError(t, err) + }) + } +} + +func TestVerifyWrapperUsesRandomDEKLength(t *testing.T) { + t.Parallel() + wrapper := &capturingPreflightWrapper{} + require.NoError(t, VerifyWrapper(wrapper)) + require.Len(t, wrapper.seen, fileKEKSize) + require.False(t, bytes.Equal(wrapper.seen, make([]byte, fileKEKSize))) +} + +type capturingPreflightWrapper struct { + seen []byte +} + +func (w *capturingPreflightWrapper) Wrap(dek []byte) ([]byte, error) { + w.seen = append([]byte(nil), dek...) + return append([]byte(nil), dek...), nil +} + +func (*capturingPreflightWrapper) Unwrap(wrapped []byte) ([]byte, error) { + return append([]byte(nil), wrapped...), nil +} + +func (*capturingPreflightWrapper) Name() string { return "capture" } diff --git a/internal/encryption/kek/source_test.go b/internal/encryption/kek/source_test.go index b6aa17505..4bb834f7d 100644 --- a/internal/encryption/kek/source_test.go +++ b/internal/encryption/kek/source_test.go @@ -51,7 +51,9 @@ func TestNewURIWrapperRejectsInvalidTargetsWithoutNetwork(t *testing.T) { "gcp-kms://projects/incomplete", "vault-transit://transit", } { - _, err := newURIWrapper(context.Background(), uri) - require.ErrorIsf(t, err, ErrInvalidKEKURI, "uri=%q", uri) + t.Run(uri, func(t *testing.T) { + _, err := newURIWrapper(context.Background(), uri) + require.ErrorIsf(t, err, ErrInvalidKEKURI, "uri=%q", uri) + }) } } diff --git a/internal/encryption/kek/vault.go b/internal/encryption/kek/vault.go index fc3e7f391..d9790a90e 100644 --- a/internal/encryption/kek/vault.go +++ b/internal/encryption/kek/vault.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "os" + "strconv" "strings" "time" @@ -77,7 +78,7 @@ func (w *VaultTransitWrapper) Wrap(dek []byte) ([]byte, error) { return nil, errors.Wrap(err, "kek: Vault Transit encrypt") } ciphertext, ok := vaultString(secret, "ciphertext") - if !ok || !strings.HasPrefix(ciphertext, "vault:v") { + if !ok || !validVaultCiphertext(ciphertext) { return nil, errors.WithStack(ErrInvalidProviderResponse) } return []byte(ciphertext), nil @@ -89,7 +90,7 @@ func (w *VaultTransitWrapper) Unwrap(wrapped []byte) ([]byte, error) { if w == nil || w.logical == nil { return nil, errors.Wrap(ErrInvalidProviderResponse, "kek: Vault client is nil") } - if len(wrapped) == 0 || !strings.HasPrefix(string(wrapped), "vault:v") { + if !validVaultCiphertext(string(wrapped)) { return nil, errors.Wrap(ErrInvalidProviderResponse, "kek: invalid Vault ciphertext") } ctx, cancel := requestContext(w.timeout) @@ -115,6 +116,20 @@ func (w *VaultTransitWrapper) Unwrap(wrapped []byte) ([]byte, error) { return plain, nil } +func validVaultCiphertext(ciphertext string) bool { + const prefix = "vault:v" + if !strings.HasPrefix(ciphertext, prefix) { + return false + } + rest := strings.TrimPrefix(ciphertext, prefix) + separator := strings.IndexByte(rest, ':') + if separator <= 0 || separator == len(rest)-1 { + return false + } + version, err := strconv.ParseUint(rest[:separator], 10, 64) + return err == nil && version > 0 +} + func vaultString(secret *vaultapi.Secret, field string) (string, bool) { if secret == nil || secret.Data == nil { return "", false diff --git a/internal/encryption/kek/vault_test.go b/internal/encryption/kek/vault_test.go index 60f864822..bb09ccff4 100644 --- a/internal/encryption/kek/vault_test.go +++ b/internal/encryption/kek/vault_test.go @@ -11,16 +11,21 @@ import ( ) type fakeVaultLogical struct { - path string - data map[string]interface{} - dek []byte + path string + data map[string]interface{} + dek []byte + ciphertext string } func (f *fakeVaultLogical) WriteWithContext(_ context.Context, path string, data map[string]interface{}) (*api.Secret, error) { f.path = path f.data = data if _, ok := data["plaintext"]; ok { - return &api.Secret{Data: map[string]interface{}{"ciphertext": "vault:v1:ciphertext"}}, nil + ciphertext := f.ciphertext + if ciphertext == "" { + ciphertext = "vault:v1:ciphertext" + } + return &api.Secret{Data: map[string]interface{}{"ciphertext": ciphertext}}, nil } return &api.Secret{Data: map[string]interface{}{"plaintext": base64.StdEncoding.EncodeToString(f.dek)}}, nil } @@ -50,8 +55,10 @@ func TestParseVaultTarget(t *testing.T) { require.Equal(t, "transit", mount) require.Equal(t, "service/key", keyName) for _, target := range []string{"", "transit", "transit//key", "../key", "transit/../key"} { - _, _, err := parseVaultTarget(target) - require.ErrorIsf(t, err, ErrInvalidKEKURI, "target=%q", target) + t.Run(target, func(t *testing.T) { + _, _, err := parseVaultTarget(target) + require.ErrorIsf(t, err, ErrInvalidKEKURI, "target=%q", target) + }) } } @@ -62,4 +69,13 @@ func TestVaultTransitWrapperRejectsMalformedResponses(t *testing.T) { require.ErrorIs(t, err, ErrInvalidDEKLength) _, err = wrapper.Unwrap([]byte("not-vault")) require.ErrorIs(t, err, ErrInvalidProviderResponse) + for _, ciphertext := range []string{"vault:v", "vault:v1:", "vault:v0:data", "vault:vx:data"} { + t.Run(ciphertext, func(t *testing.T) { + invalid := newVaultTransitWrapper(&fakeVaultLogical{dek: bytes.Repeat([]byte{1}, fileKEKSize), ciphertext: ciphertext}, "transit", "key") + _, wrapErr := invalid.Wrap(bytes.Repeat([]byte{2}, fileKEKSize)) + require.ErrorIs(t, wrapErr, ErrInvalidProviderResponse) + _, unwrapErr := invalid.Unwrap([]byte(ciphertext)) + require.ErrorIs(t, unwrapErr, ErrInvalidProviderResponse) + }) + } } diff --git a/main.go b/main.go index dbbf19027..96f7bdaaf 100644 --- a/main.go +++ b/main.go @@ -1620,9 +1620,22 @@ func loadKEKAndRunStartupGuards() (kek.Wrapper, error) { }); err != nil { return nil, errors.Wrap(err, "encryption startup guards refused process start") } + if err := verifyKEKBeforeMutators(kekWrapper); err != nil { + return nil, err + } return kekWrapper, nil } +func verifyKEKBeforeMutators(wrapper kek.Wrapper) error { + if !*encryptionEnabled || *encryptionSidecarPath == "" || wrapper == nil { + return nil + } + if err := kek.VerifyWrapper(wrapper); err != nil { + return errors.Wrap(err, "encryption KEK preflight refused process start") + } + return nil +} + // loadKEKWrapperFromFlag constructs the configured KEK wrapper from the // mutually-exclusive file, URI, or environment source. // Returns the kek.Wrapper interface rather than the concrete diff --git a/main_encryption_kek_source_test.go b/main_encryption_kek_source_test.go index f0ef447ba..dead8a56b 100644 --- a/main_encryption_kek_source_test.go +++ b/main_encryption_kek_source_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/base64" + "errors" "testing" "github.com/bootjp/elastickv/internal/encryption" @@ -32,11 +33,39 @@ func TestEncryptionMutatorsEnabledUsesLoadedKEKState(t *testing.T) { require.False(t, encryptionMutatorsEnabled(true)) } +type failingStartupKEK struct{} + +func (failingStartupKEK) Wrap([]byte) ([]byte, error) { return nil, errors.New("provider unavailable") } +func (failingStartupKEK) Unwrap([]byte) ([]byte, error) { + return nil, errors.New("provider unavailable") +} +func (failingStartupKEK) Name() string { return "failing" } + +func TestVerifyKEKBeforeMutators(t *testing.T) { + previousEnabled := *encryptionEnabled + previousSidecar := *encryptionSidecarPath + t.Cleanup(func() { + *encryptionEnabled = previousEnabled + *encryptionSidecarPath = previousSidecar + }) + + *encryptionEnabled = true + *encryptionSidecarPath = "/data/encryption/keys.json" + require.ErrorIs(t, verifyKEKBeforeMutators(failingStartupKEK{}), kek.ErrKEKPreflightFailed) + + *encryptionEnabled = false + require.NoError(t, verifyKEKBeforeMutators(failingStartupKEK{})) + *encryptionEnabled = true + *encryptionSidecarPath = "" + require.NoError(t, verifyKEKBeforeMutators(failingStartupKEK{})) +} + func TestEnvKEKBootstrapCutoverSnapshotRestore(t *testing.T) { staticKEK := bytes.Repeat([]byte{0x71}, encryption.KeySize) t.Setenv(kek.EnvVar, base64.StdEncoding.EncodeToString(staticKEK)) wrapper, err := kek.NewWrapperFromSources(context.Background(), "", "") require.NoError(t, err) + require.NoError(t, kek.VerifyWrapper(wrapper)) storageDEK := bytes.Repeat([]byte{0x72}, encryption.KeySize) raftDEK := bytes.Repeat([]byte{0x73}, encryption.KeySize) From 46b533e2818e6afb8046391bb6db70812a67e9fb Mon Sep 17 00:00:00 2001 From: bootjp Date: Sun, 19 Jul 2026 19:37:05 +0900 Subject: [PATCH 4/6] adapter: bound Lua cache test cost --- adapter/redis_lua_context.go | 43 +++++++++++-------- adapter/redis_lua_negative_type_cache_test.go | 12 +++--- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/adapter/redis_lua_context.go b/adapter/redis_lua_context.go index 3b10a46ac..260c6f861 100644 --- a/adapter/redis_lua_context.go +++ b/adapter/redis_lua_context.go @@ -48,7 +48,8 @@ type luaScriptContext struct { // buggy script from probing unbounded unique non-existent keys and // blowing up memory. Once full, further misses fall back to the // server-side probe (still correct, just not cached). - negativeType map[string]bool + negativeType map[string]bool + negativeTypeLimit int // keyTypeProbeCount counts how many times the server-side keyTypeAt // helper was invoked during this Eval. Only read by tests via @@ -261,21 +262,22 @@ func newLuaScriptContext(ctx context.Context, server *RedisServer) (*luaScriptCo } startTS := server.readTS() return &luaScriptContext{ - server: server, - startTS: startTS, - readPin: server.pinReadTS(startTS), - ctx: ctx, - touched: map[string]struct{}{}, - deleted: map[string]bool{}, - everDeleted: map[string]bool{}, - negativeType: map[string]bool{}, - strings: map[string]*luaStringState{}, - lists: map[string]*luaListState{}, - hashes: map[string]*luaHashState{}, - sets: map[string]*luaSetState{}, - zsets: map[string]*luaZSetState{}, - streams: map[string]*luaStreamState{}, - ttls: map[string]*luaTTLState{}, + server: server, + startTS: startTS, + readPin: server.pinReadTS(startTS), + ctx: ctx, + touched: map[string]struct{}{}, + deleted: map[string]bool{}, + everDeleted: map[string]bool{}, + negativeType: map[string]bool{}, + negativeTypeLimit: maxNegativeTypeCacheEntries, + strings: map[string]*luaStringState{}, + lists: map[string]*luaListState{}, + hashes: map[string]*luaHashState{}, + sets: map[string]*luaSetState{}, + zsets: map[string]*luaZSetState{}, + streams: map[string]*luaStreamState{}, + ttls: map[string]*luaTTLState{}, }, nil } @@ -479,7 +481,7 @@ func (c *luaScriptContext) keyType(key []byte) (redisValueType, error) { if err != nil { return redisTypeNone, err } - if typ == redisTypeNone && len(c.negativeType) < maxNegativeTypeCacheEntries { + if typ == redisTypeNone && len(c.negativeType) < c.effectiveNegativeTypeLimit() { // Pin the absence result for the rest of this Eval so repeated // BullMQ-style polling of a missing key (e.g. a "delayed" zset) // does not re-run the ~8-seek rawKeyTypeAt probe on every @@ -493,6 +495,13 @@ func (c *luaScriptContext) keyType(key []byte) (redisValueType, error) { return typ, nil } +func (c *luaScriptContext) effectiveNegativeTypeLimit() int { + if c.negativeTypeLimit > 0 { + return c.negativeTypeLimit + } + return maxNegativeTypeCacheEntries +} + func (c *luaScriptContext) ensureKeyNotExpired(key []byte) error { ttl, err := c.loadTTL(key) if err != nil { diff --git a/adapter/redis_lua_negative_type_cache_test.go b/adapter/redis_lua_negative_type_cache_test.go index 6253f7e30..6a3a29db3 100644 --- a/adapter/redis_lua_negative_type_cache_test.go +++ b/adapter/redis_lua_negative_type_cache_test.go @@ -156,17 +156,19 @@ func TestLuaNegativeTypeCache_BoundedSize(t *testing.T) { sc, err := newLuaScriptContext(ctx, nodes[0].redisServer) require.NoError(t, err) defer sc.Close() + const cacheLimit = 4 + sc.negativeTypeLimit = cacheLimit // Probe cap+overflow unique missing keys. Each probe is a miss // (redisTypeNone); only the first `cap` should be memoized. - const overflow = 50 - total := maxNegativeTypeCacheEntries + overflow + const overflow = 2 + total := cacheLimit + overflow for i := 0; i < total; i++ { typ, kerr := sc.keyType([]byte(fmt.Sprintf("lua:neg:cap:%d", i))) require.NoError(t, kerr) require.Equal(t, redisTypeNone, typ) } - require.Equal(t, maxNegativeTypeCacheEntries, len(sc.negativeType), + require.Equal(t, cacheLimit, len(sc.negativeType), "negativeType map must be capped at maxNegativeTypeCacheEntries") // Each unique key above required exactly one probe on first access. @@ -182,11 +184,11 @@ func TestLuaNegativeTypeCache_BoundedSize(t *testing.T) { require.Equal(t, total, sc.keyTypeProbeCount, "a key inserted before the cap must remain cached") - overflowKey := []byte(fmt.Sprintf("lua:neg:cap:%d", maxNegativeTypeCacheEntries+1)) + overflowKey := []byte(fmt.Sprintf("lua:neg:cap:%d", cacheLimit+1)) _, kerr = sc.keyType(overflowKey) require.NoError(t, kerr) require.Equal(t, total+1, sc.keyTypeProbeCount, "a key probed after the cap was reached must fall back to the server probe") - require.Equal(t, maxNegativeTypeCacheEntries, len(sc.negativeType), + require.Equal(t, cacheLimit, len(sc.negativeType), "fallback probe must NOT grow the bounded cache") } From 12d34f721443436a6f9e7b0d9723ca8e66a05a55 Mon Sep 17 00:00:00 2001 From: bootjp Date: Sun, 19 Jul 2026 22:23:25 +0900 Subject: [PATCH 5/6] encryption: harden Vault Transit key binding --- ...6_04_29_partial_data_at_rest_encryption.md | 3 +- ...2026_07_18_implemented_9b_kek_providers.md | 9 +++-- internal/encryption/kek/vault.go | 18 +++++++-- internal/encryption/kek/vault_test.go | 39 +++++++++++++++---- 4 files changed, 54 insertions(+), 15 deletions(-) diff --git a/docs/design/2026_04_29_partial_data_at_rest_encryption.md b/docs/design/2026_04_29_partial_data_at_rest_encryption.md index 21fadc4f4..91af40095 100644 --- a/docs/design/2026_04_29_partial_data_at_rest_encryption.md +++ b/docs/design/2026_04_29_partial_data_at_rest_encryption.md @@ -1023,7 +1023,8 @@ Two-tier hierarchy: 1. AWS KMS: `--kekUri=aws-kms://arn:aws:kms:...:key/...` with an immutable key ARN (mutable alias ARNs are rejected). The KMS key never leaves AWS; we call `Encrypt` / `Decrypt` to wrap/unwrap DEKs. - 2. GCP KMS: `--kekUri=gcp-kms://projects/.../keys/...`. Same shape. + 2. GCP KMS: `--kekUri=gcp-kms://projects//locations//keyRings//cryptoKeys/`. + Same shape. 3. HashiCorp Vault Transit: `--kekUri=vault-transit://...`. 4. Static file: `--kekFile=/etc/elastickv/kek.bin` — 32 bytes raw. Recommended only when the file lives on a tmpfs or sealed diff --git a/docs/design/2026_07_18_implemented_9b_kek_providers.md b/docs/design/2026_07_18_implemented_9b_kek_providers.md index 5cd2b0451..d178ea543 100644 --- a/docs/design/2026_07_18_implemented_9b_kek_providers.md +++ b/docs/design/2026_07_18_implemented_9b_kek_providers.md @@ -16,8 +16,11 @@ Stage 9B completes the §5.1 KEK source matrix: AAD. Request and response CRC32C values are supplied and verified before a wrapped DEK or plaintext DEK is accepted. - `--kekUri=vault-transit:///` uses Vault Transit through standard - `VAULT_*` client configuration. Binary DEKs are base64 encoded for the API; - only `vault:v:` ciphertexts are accepted. + `VAULT_*` client configuration, including nested mount paths with the final + path segment interpreted as the key name. The existing key is read before + encrypt to prevent implicit key creation, and encrypt/decrypt use fixed AAD. + Binary DEKs are base64 encoded for the API; only + `vault:v:` ciphertexts are accepted. - `ELASTICKV_KEK_BASE64` supplies a 32-byte test/CI-only static KEK. The variable is unset immediately after the decode attempt, including malformed input. It is also unset when source ambiguity is rejected. @@ -55,7 +58,7 @@ unchanged. Verification includes: - fake-client unit tests for AWS encryption context, GCP AAD/CRC32C, and Vault - binary request/response encoding; + key existence, nested mount, AAD, and binary request/response encoding; - malformed provider response, strict Vault ciphertext, immutable AWS key ARN, wrong DEK length, invalid URI, source conflict, environment-unset, and startup wrap/unwrap preflight tests; diff --git a/internal/encryption/kek/vault.go b/internal/encryption/kek/vault.go index d9790a90e..9a36cef7d 100644 --- a/internal/encryption/kek/vault.go +++ b/internal/encryption/kek/vault.go @@ -13,9 +13,12 @@ import ( ) type vaultLogicalClient interface { + ReadWithContext(context.Context, string) (*vaultapi.Secret, error) WriteWithContext(context.Context, string, map[string]interface{}) (*vaultapi.Secret, error) } +var vaultTransitAAD = base64.StdEncoding.EncodeToString([]byte("elastickv-dek-wrap-v1")) + // VaultTransitWrapper wraps DEKs with a Vault Transit symmetric key. type VaultTransitWrapper struct { logical vaultLogicalClient @@ -57,7 +60,7 @@ func parseVaultTarget(target string) (string, string, error) { return "", "", errors.Wrapf(ErrInvalidKEKURI, "invalid Vault Transit target %q", target) } } - return parts[0], strings.Join(parts[1:], "/"), nil + return strings.Join(parts[:len(parts)-1], "/"), parts[len(parts)-1], nil } // Wrap base64-encodes the binary DEK for Vault's JSON API and stores Vault's @@ -71,8 +74,16 @@ func (w *VaultTransitWrapper) Wrap(dek []byte) ([]byte, error) { } ctx, cancel := requestContext(w.timeout) defer cancel() + key, err := w.logical.ReadWithContext(ctx, w.mount+"/keys/"+w.keyName) + if err != nil { + return nil, errors.Wrap(err, "kek: read Vault Transit key") + } + if _, ok := vaultString(key, "type"); !ok { + return nil, errors.Wrap(ErrInvalidProviderResponse, "kek: Vault Transit key does not exist") + } secret, err := w.logical.WriteWithContext(ctx, w.mount+"/encrypt/"+w.keyName, map[string]interface{}{ - "plaintext": base64.StdEncoding.EncodeToString(dek), + "plaintext": base64.StdEncoding.EncodeToString(dek), + "associated_data": vaultTransitAAD, }) if err != nil { return nil, errors.Wrap(err, "kek: Vault Transit encrypt") @@ -96,7 +107,8 @@ func (w *VaultTransitWrapper) Unwrap(wrapped []byte) ([]byte, error) { ctx, cancel := requestContext(w.timeout) defer cancel() secret, err := w.logical.WriteWithContext(ctx, w.mount+"/decrypt/"+w.keyName, map[string]interface{}{ - "ciphertext": string(wrapped), + "ciphertext": string(wrapped), + "associated_data": vaultTransitAAD, }) if err != nil { return nil, errors.Wrap(err, "kek: Vault Transit decrypt") diff --git a/internal/encryption/kek/vault_test.go b/internal/encryption/kek/vault_test.go index bb09ccff4..900ea5115 100644 --- a/internal/encryption/kek/vault_test.go +++ b/internal/encryption/kek/vault_test.go @@ -11,14 +11,24 @@ import ( ) type fakeVaultLogical struct { - path string + readPath string + writePath string data map[string]interface{} dek []byte ciphertext string + key *api.Secret +} + +func (f *fakeVaultLogical) ReadWithContext(_ context.Context, path string) (*api.Secret, error) { + f.readPath = path + if f.key != nil { + return f.key, nil + } + return &api.Secret{Data: map[string]interface{}{"type": "aes256-gcm96"}}, nil } func (f *fakeVaultLogical) WriteWithContext(_ context.Context, path string, data map[string]interface{}) (*api.Secret, error) { - f.path = path + f.writePath = path f.data = data if _, ok := data["plaintext"]; ok { ciphertext := f.ciphertext @@ -33,27 +43,30 @@ func (f *fakeVaultLogical) WriteWithContext(_ context.Context, path string, data func TestVaultTransitWrapperRequestBinding(t *testing.T) { dek := bytes.Repeat([]byte{0x62}, fileKEKSize) logical := &fakeVaultLogical{dek: dek} - wrapper := newVaultTransitWrapper(logical, "transit", "service/key") + wrapper := newVaultTransitWrapper(logical, "security/transit", "orders") wrapped, err := wrapper.Wrap(dek) require.NoError(t, err) require.Equal(t, []byte("vault:v1:ciphertext"), wrapped) - require.Equal(t, "transit/encrypt/service/key", logical.path) + require.Equal(t, "security/transit/keys/orders", logical.readPath) + require.Equal(t, "security/transit/encrypt/orders", logical.writePath) require.Equal(t, base64.StdEncoding.EncodeToString(dek), logical.data["plaintext"]) + require.Equal(t, vaultTransitAAD, logical.data["associated_data"]) plain, err := wrapper.Unwrap(wrapped) require.NoError(t, err) require.Equal(t, dek, plain) - require.Equal(t, "transit/decrypt/service/key", logical.path) + require.Equal(t, "security/transit/decrypt/orders", logical.writePath) require.Equal(t, "vault:v1:ciphertext", logical.data["ciphertext"]) - require.Equal(t, "vault-transit:transit/service/key", wrapper.Name()) + require.Equal(t, vaultTransitAAD, logical.data["associated_data"]) + require.Equal(t, "vault-transit:security/transit/orders", wrapper.Name()) } func TestParseVaultTarget(t *testing.T) { mount, keyName, err := parseVaultTarget("transit/service/key") require.NoError(t, err) - require.Equal(t, "transit", mount) - require.Equal(t, "service/key", keyName) + require.Equal(t, "transit/service", mount) + require.Equal(t, "key", keyName) for _, target := range []string{"", "transit", "transit//key", "../key", "transit/../key"} { t.Run(target, func(t *testing.T) { _, _, err := parseVaultTarget(target) @@ -62,6 +75,16 @@ func TestParseVaultTarget(t *testing.T) { } } +func TestVaultTransitWrapperRequiresExistingKey(t *testing.T) { + logical := &fakeVaultLogical{key: &api.Secret{Data: map[string]interface{}{}}} + wrapper := newVaultTransitWrapper(logical, "transit", "missing") + + _, err := wrapper.Wrap(bytes.Repeat([]byte{0x42}, fileKEKSize)) + require.ErrorIs(t, err, ErrInvalidProviderResponse) + require.Equal(t, "transit/keys/missing", logical.readPath) + require.Empty(t, logical.writePath) +} + func TestVaultTransitWrapperRejectsMalformedResponses(t *testing.T) { logical := &fakeVaultLogical{dek: []byte("short")} wrapper := newVaultTransitWrapper(logical, "transit", "key") From 2308534284bdd86eac29d103cfacc06b7f104145 Mon Sep 17 00:00:00 2001 From: bootjp Date: Thu, 23 Jul 2026 17:17:34 +0900 Subject: [PATCH 6/6] proto: reserve removed raft status fields --- proto/service.pb.go | 4 ++-- proto/service.proto | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/proto/service.pb.go b/proto/service.pb.go index 04d7cce82..4ea699619 100644 --- a/proto/service.pb.go +++ b/proto/service.pb.go @@ -2558,7 +2558,7 @@ const file_service_proto_rawDesc = "" + "\bstart_ts\x18\x01 \x01(\x04R\astartTs\",\n" + "\x10RollbackResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\"\x18\n" + - "\x16RaftAdminStatusRequest\"\xa2\x03\n" + + "\x16RaftAdminStatusRequest\"\xd8\x03\n" + "\x17RaftAdminStatusResponse\x12%\n" + "\x05state\x18\x01 \x01(\x0e2\x0f.RaftAdminStateR\x05state\x12\x1b\n" + "\tleader_id\x18\x02 \x01(\tR\bleaderId\x12%\n" + @@ -2572,7 +2572,7 @@ const file_service_proto_rawDesc = "" + "fsmPending\x12\x1b\n" + "\tnum_peers\x18\n" + " \x01(\x04R\bnumPeers\x12,\n" + - "\x12last_contact_nanos\x18\v \x01(\x03R\x10lastContactNanos\"\x1f\n" + + "\x12last_contact_nanos\x18\v \x01(\x03R\x10lastContactNanosJ\x04\b\f\x10\rJ\x04\b\r\x10\x0eR\x13configuration_indexR\x13pending_conf_change\"\x1f\n" + "\x1dRaftAdminConfigurationRequest\"W\n" + "\x0fRaftAdminMember\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n" + diff --git a/proto/service.proto b/proto/service.proto index a2212b101..9e4d13d6d 100644 --- a/proto/service.proto +++ b/proto/service.proto @@ -200,6 +200,8 @@ message RaftAdminStatusResponse { uint64 fsm_pending = 9; uint64 num_peers = 10; int64 last_contact_nanos = 11; + reserved 12, 13; + reserved "configuration_index", "pending_conf_change"; } message RaftAdminConfigurationRequest {}