Skip to content

Commit f1c8c85

Browse files
whites11claude
andauthored
feat(catalog): add GCP Cloud KMS support for secret decryption (#4243)
## Summary - Add `PEERDB_KMS_PROVIDER` env var to dispatch between AWS KMS and GCP Cloud KMS for catalog secret decryption - **Rust** (`nexus/catalog`): GCP Cloud KMS REST API via `reqwest`, using a Workload Identity token fetched from the GKE metadata server - **Go** (`flow/internal/env.go`): GCP Cloud KMS via the official `cloud.google.com/go/kms/apiv1` client (uses Application Default Credentials, which resolves to Workload Identity on GKE) - Provider switch on both sides enumerates `aws` (and empty for back-compat), `gcp`, and returns an explicit error for unknown values; constants `KmsProviderAWS` / `KmsProviderGCP` introduced in Go to mirror the Rust pattern - Defaults to `aws` everywhere — no behavior change for existing AWS deployments ## Test plan - [ ] End-to-end test on GCP GKE cluster with Workload Identity, decrypting a catalog secret encrypted with a GCP KMS key - [ ] Verify `PEERDB_KMS_PROVIDER=` (empty), unset, and `PEERDB_KMS_PROVIDER=aws` all still hit the AWS path - [ ] Verify `PEERDB_KMS_PROVIDER=invalid` returns the new error rather than silently falling through to AWS 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a78aac9 commit f1c8c85

7 files changed

Lines changed: 137 additions & 9 deletions

File tree

flow/cmd/check_pinned_versions/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
// the Renovate package rules in renovate.json that skips automated updates for that dependency.
1919
var pinnedVersions = map[string]string{
2020
"cloud.google.com/go/bigquery": "v1.72.0",
21+
"cloud.google.com/go/kms": "v1.24.0",
2122
"cloud.google.com/go/pubsub/v2": "v2.3.0",
2223
"github.com/aws/aws-sdk-go-v2/feature/s3/manager": "v1.21.0",
2324
"google.golang.org/api": "v0.257.0",

flow/go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ require (
9696

9797
require github.com/quasilyte/go-ruleguard/dsl v0.3.23
9898

99+
require (
100+
cloud.google.com/go/kms v1.24.0 // PINNED: v1.26.0+ requires google.golang.org/api v0.265.0
101+
cloud.google.com/go/longrunning v0.7.0 // indirect
102+
)
103+
99104
require (
100105
cel.dev/expr v0.25.1 // indirect
101106
cloud.google.com/go/auth v0.18.1

flow/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ cloud.google.com/go/datacatalog v1.26.1 h1:bCRKA8uSQN8wGW3Tw0gwko4E9a64GRmbW1nCb
1515
cloud.google.com/go/datacatalog v1.26.1/go.mod h1:2Qcq8vsHNxMDgjgadRFmFG47Y+uuIVsyEGUrlrKEdrg=
1616
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
1717
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
18+
cloud.google.com/go/kms v1.24.0 h1:SWltUuoPhTdv9q/P0YEAWQfoYT32O5HdfPgTiWMvrH8=
19+
cloud.google.com/go/kms v1.24.0/go.mod h1:QDH3z2SJ50lfNOE8EokKC1G40i7I0f8xTMCoiptcb5g=
1820
cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
1921
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
2022
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=

flow/internal/env.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,20 @@ import (
1010
"strings"
1111
"sync"
1212

13+
gcpkms "cloud.google.com/go/kms/apiv1"
14+
gcpkmspb "cloud.google.com/go/kms/apiv1/kmspb"
1315
"github.com/aws/aws-sdk-go-v2/aws"
1416
"github.com/aws/aws-sdk-go-v2/config"
1517
"github.com/aws/aws-sdk-go-v2/service/kms"
1618
"golang.org/x/exp/constraints"
1719
)
1820

1921
const (
20-
KmsKeyIDEnvVar = "PEERDB_KMS_KEY_ID"
22+
KmsKeyIDEnvVar = "PEERDB_KMS_KEY_ID"
23+
KmsProviderEnvVar = "PEERDB_KMS_PROVIDER"
24+
25+
KmsProviderAWS = "aws"
26+
KmsProviderGCP = "gcp"
2127
)
2228

2329
// getEnvUint returns the value of the environment variable with the given name
@@ -71,6 +77,18 @@ func decryptWithKms(ctx context.Context, data []byte) ([]byte, error) {
7177
return data, nil
7278
}
7379

80+
provider := GetEnvString(KmsProviderEnvVar, KmsProviderAWS)
81+
switch provider {
82+
case KmsProviderAWS, "":
83+
return decryptWithAwsKms(ctx, data, keyID)
84+
case KmsProviderGCP:
85+
return decryptWithGcpKms(ctx, data, keyID)
86+
default:
87+
return nil, fmt.Errorf("unsupported KMS provider %q (expected %q or %q)", provider, KmsProviderAWS, KmsProviderGCP)
88+
}
89+
}
90+
91+
func decryptWithAwsKms(ctx context.Context, data []byte, keyID string) ([]byte, error) {
7492
cfg, err := config.LoadDefaultConfig(ctx)
7593
if err != nil {
7694
return nil, fmt.Errorf("failed to load AWS config: %w", err)
@@ -88,6 +106,24 @@ func decryptWithKms(ctx context.Context, data []byte) ([]byte, error) {
88106
return decrypted.Plaintext, nil
89107
}
90108

109+
func decryptWithGcpKms(ctx context.Context, data []byte, keyID string) ([]byte, error) {
110+
client, err := gcpkms.NewKeyManagementClient(ctx)
111+
if err != nil {
112+
return nil, fmt.Errorf("failed to create GCP KMS client: %w", err)
113+
}
114+
defer client.Close()
115+
116+
resp, err := client.Decrypt(ctx, &gcpkmspb.DecryptRequest{
117+
Name: keyID,
118+
Ciphertext: data,
119+
})
120+
if err != nil {
121+
return nil, fmt.Errorf("failed to decrypt with GCP KMS: %w", err)
122+
}
123+
124+
return resp.Plaintext, nil
125+
}
126+
91127
var kmsCache sync.Map
92128

93129
func GetKmsDecryptedEnvBase64EncodedBytes(ctx context.Context, name string, defaultValue []byte) ([]byte, error) {

nexus/catalog/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ edition = "2024"
66
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
77

88
[features]
9-
default = ["aws"]
9+
default = ["aws", "gcp"]
1010
aws = ["dep:aws-config", "dep:aws-sdk-kms"]
11+
gcp = ["dep:reqwest"]
1112

1213
[dependencies]
1314
anyhow = "1"
1415
aws-config = { version = "1", optional = true }
1516
aws-sdk-kms = { version = "1", optional = true }
17+
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
1618
async-trait = "0.1"
1719
base64 = "0.22"
1820
chacha20poly1305 = { version = "0.11.0-rc.1", default-features = false, features = ["alloc"] }

nexus/catalog/src/lib.rs

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,23 @@ mod embedded {
3232
pub struct Catalog {
3333
pg: Client,
3434
kms_key_id: Option<Arc<String>>,
35+
kms_provider: String,
36+
}
37+
38+
pub async fn kms_decrypt(
39+
encrypted_payload: &str,
40+
kms_key_id: &str,
41+
kms_provider: &str,
42+
) -> anyhow::Result<String> {
43+
match kms_provider {
44+
"gcp" => gcp_kms_decrypt(encrypted_payload, kms_key_id).await,
45+
"aws" | "" => aws_kms_decrypt(encrypted_payload, kms_key_id).await,
46+
other => Err(anyhow!("unsupported KMS provider: {}", other)),
47+
}
3548
}
3649

3750
#[cfg(feature = "aws")]
38-
pub async fn kms_decrypt(encrypted_payload: &str, kms_key_id: &str) -> anyhow::Result<String> {
51+
async fn aws_kms_decrypt(encrypted_payload: &str, kms_key_id: &str) -> anyhow::Result<String> {
3952
let region_provider = RegionProviderChain::default_provider().or_else("us-east-1");
4053
let config = aws_config::defaults(BehaviorVersion::v2026_01_12())
4154
.region(region_provider)
@@ -61,10 +74,71 @@ pub async fn kms_decrypt(encrypted_payload: &str, kms_key_id: &str) -> anyhow::R
6174
}
6275

6376
#[cfg(not(feature = "aws"))]
64-
pub async fn kms_decrypt(_encrypted_payload: &str, _kms_key_id: &str) -> anyhow::Result<String> {
77+
async fn aws_kms_decrypt(_encrypted_payload: &str, _kms_key_id: &str) -> anyhow::Result<String> {
6578
Err(anyhow::anyhow!("AWS KMS support not compiled in"))
6679
}
6780

81+
#[cfg(feature = "gcp")]
82+
async fn gcp_kms_decrypt(encrypted_payload: &str, kms_key_id: &str) -> anyhow::Result<String> {
83+
// kms_key_id is the full GCP resource name:
84+
// projects/PROJECT/locations/LOCATION/keyRings/RING/cryptoKeys/KEY
85+
86+
// Get access token from GKE Workload Identity metadata server
87+
let token = reqwest::Client::new()
88+
.get("http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token")
89+
.header("Metadata-Flavor", "Google")
90+
.send()
91+
.await
92+
.context("failed to get GCP access token from metadata server")?
93+
.json::<serde_json::Value>()
94+
.await
95+
.context("failed to parse GCP token response")?;
96+
97+
let access_token = token["access_token"]
98+
.as_str()
99+
.ok_or_else(|| anyhow!("no access_token in metadata response"))?;
100+
101+
// Call Cloud KMS decrypt API
102+
let url = format!(
103+
"https://cloudkms.googleapis.com/v1/{}:decrypt",
104+
kms_key_id
105+
);
106+
107+
let resp = reqwest::Client::new()
108+
.post(&url)
109+
.bearer_auth(access_token)
110+
.json(&serde_json::json!({ "ciphertext": encrypted_payload }))
111+
.send()
112+
.await
113+
.context("failed to call GCP KMS decrypt API")?;
114+
115+
if !resp.status().is_success() {
116+
let status = resp.status();
117+
let body = resp.text().await.unwrap_or_default();
118+
return Err(anyhow!("GCP KMS decrypt failed ({}): {}", status, body));
119+
}
120+
121+
let body: serde_json::Value = resp
122+
.json()
123+
.await
124+
.context("failed to parse GCP KMS decrypt response")?;
125+
126+
let plaintext_b64 = body["plaintext"]
127+
.as_str()
128+
.ok_or_else(|| anyhow!("no plaintext in GCP KMS response"))?;
129+
130+
let plaintext = BASE64_STANDARD
131+
.decode(plaintext_b64)
132+
.context("failed to decode GCP KMS plaintext")?;
133+
134+
String::from_utf8(plaintext).context("GCP KMS plaintext is not valid UTF-8")
135+
}
136+
137+
#[cfg(not(feature = "gcp"))]
138+
async fn gcp_kms_decrypt(_encrypted_payload: &str, _kms_key_id: &str) -> anyhow::Result<String> {
139+
Err(anyhow::anyhow!("GCP KMS support not compiled in"))
140+
}
141+
68142
async fn run_migrations(client: &mut Client) -> anyhow::Result<()> {
69143
let migration_report = embedded::migrations::runner()
70144
.run_async(client)
@@ -131,11 +205,13 @@ impl Catalog {
131205
pub async fn new(
132206
pt_config: pt::peerdb_peers::PostgresConfig,
133207
kms_key_id: &Option<Arc<String>>,
208+
kms_provider: &str,
134209
) -> anyhow::Result<Self> {
135210
let (pg, _) = connect_postgres(&pt_config).await?;
136211
Ok(Self {
137212
pg,
138213
kms_key_id: kms_key_id.clone(),
214+
kms_provider: kms_provider.to_string(),
139215
})
140216
}
141217

@@ -147,7 +223,7 @@ impl Catalog {
147223
let mut enc_keys = env::var("PEERDB_ENC_KEYS")?;
148224

149225
if let Some(kms_key_id) = &self.kms_key_id {
150-
enc_keys = kms_decrypt(&enc_keys, kms_key_id.as_ref()).await?;
226+
enc_keys = kms_decrypt(&enc_keys, kms_key_id.as_ref(), &self.kms_provider).await?;
151227
}
152228

153229
// TODO use serde derive

nexus/server/src/main.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -988,12 +988,16 @@ struct Args {
988988
/// KMS Key ID for decrypting the catalog password
989989
#[clap(long, env = "PEERDB_KMS_KEY_ID")]
990990
kms_key_id: Option<Arc<String>>,
991+
992+
/// KMS provider: "aws" (default) or "gcp"
993+
#[clap(long, default_value = "aws", env = "PEERDB_KMS_PROVIDER")]
994+
kms_provider: String,
991995
}
992996

993997
// Get catalog config from args
994998
async fn get_catalog_config(args: &Args) -> anyhow::Result<CatalogConfig<'_>> {
995999
let password = if let Some(kms_key_id) = &args.kms_key_id {
996-
kms_decrypt(&args.catalog_password, kms_key_id).await?
1000+
kms_decrypt(&args.catalog_password, kms_key_id, &args.kms_provider).await?
9971001
} else {
9981002
args.catalog_password.clone()
9991003
};
@@ -1059,11 +1063,12 @@ fn setup_tracing(log_dir: Option<&str>) -> TracerGuards {
10591063
async fn run_migrations(
10601064
config: &CatalogConfig<'_>,
10611065
kms_key_id: &Option<Arc<String>>,
1066+
kms_provider: &str,
10621067
) -> anyhow::Result<()> {
10631068
// retry connecting to the catalog 3 times with 30 seconds delay
10641069
// if it fails, return an error
10651070
for _ in 0..3 {
1066-
match Catalog::new(config.to_postgres_config(), kms_key_id).await {
1071+
match Catalog::new(config.to_postgres_config(), kms_key_id, kms_provider).await {
10671072
Ok(mut catalog) => {
10681073
catalog.run_migrations().await?;
10691074
return Ok(());
@@ -1158,7 +1163,7 @@ pub async fn main() -> anyhow::Result<()> {
11581163
}
11591164

11601165
if !args.migrations_disabled {
1161-
run_migrations(&catalog_config, &args.kms_key_id).await?;
1166+
run_migrations(&catalog_config, &args.kms_key_id, &args.kms_provider).await?;
11621167
}
11631168
if args.migrations_only {
11641169
return Ok(());
@@ -1194,10 +1199,11 @@ pub async fn main() -> anyhow::Result<()> {
11941199
let authenticator = authenticator.clone();
11951200
let pg_config = catalog_config.to_postgres_config();
11961201
let kms_key_id = args.kms_key_id.clone();
1202+
let kms_provider = args.kms_provider.clone();
11971203
let tls_acceptor = tls_acceptor.clone();
11981204

11991205
tokio::task::spawn(async move {
1200-
match Catalog::new(pg_config, &kms_key_id).await {
1206+
match Catalog::new(pg_config, &kms_key_id, &kms_provider).await {
12011207
Ok(catalog) => {
12021208
let nexus = Arc::new(NexusBackend::new(
12031209
Arc::new(catalog),

0 commit comments

Comments
 (0)