diff --git a/README.md b/README.md index 682d698..af9f49b 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,35 @@ org.killbill.billing.plugin.stripe.chargeStatementDescriptor=ZZZ' \ http://127.0.0.1:8080/1.0/kb/tenants/uploadPluginConfig/killbill-stripe ``` +## Securing API Keys + +By default, API keys are stored in plaintext in Kill Bill's tenant configuration. You can keep secrets out of the database by using environment variable references. + +### Environment variable references + +Store your Stripe keys in environment variables and reference them in the plugin config: + +``` +org.killbill.billing.plugin.stripe.apiKey=${env:STRIPE_API_KEY} +org.killbill.billing.plugin.stripe.publicKey=${env:STRIPE_PUBLIC_KEY} +``` + +This way, secrets never enter the database and can be managed by your orchestration layer (K8s Secrets, HashiCorp Vault, AWS SSM, etc.). + +For multi-tenant setups, each tenant can reference a different environment variable: + +``` +# Tenant A config +org.killbill.billing.plugin.stripe.apiKey=${env:STRIPE_KEY_TENANT_A} + +# Tenant B config +org.killbill.billing.plugin.stripe.apiKey=${env:STRIPE_KEY_TENANT_B} +``` + +### Migration note + +Existing plaintext configurations continue to work with zero changes. Environment variable references are fully opt-in. + ## Payment Method flow To charge a payment instrument (card, bank account, etc.), you first need to collect the payment instrument details in Stripe and create an associated payment method in Kill Bill. diff --git a/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigProperties.java b/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigProperties.java index 936104c..80dda1b 100644 --- a/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigProperties.java +++ b/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigProperties.java @@ -77,8 +77,8 @@ public class StripeConfigProperties { public StripeConfigProperties(final Properties properties, final String region) { this.region = region; - this.apiKey = properties.getProperty(PROPERTY_PREFIX + "apiKey"); - this.publicKey = properties.getProperty(PROPERTY_PREFIX + "publicKey"); + this.apiKey = StripeConfigPropertyResolver.resolve(properties.getProperty(PROPERTY_PREFIX + "apiKey")); + this.publicKey = StripeConfigPropertyResolver.resolve(properties.getProperty(PROPERTY_PREFIX + "publicKey")); this.apiBase = properties.getProperty(PROPERTY_PREFIX + "apiBase"); this.proxyHost = properties.getProperty(PROPERTY_PREFIX + "proxyHost"); this.proxyPort = Integer.parseInt(properties.getProperty(PROPERTY_PREFIX + "proxyPort", "-1")); diff --git a/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigPropertyResolver.java b/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigPropertyResolver.java new file mode 100644 index 0000000..cd2a002 --- /dev/null +++ b/src/main/java/org/killbill/billing/plugin/stripe/StripeConfigPropertyResolver.java @@ -0,0 +1,65 @@ +/* + * Copyright 2020-2020 Equinix, Inc + * Copyright 2014-2020 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package org.killbill.billing.plugin.stripe; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.annotation.Nullable; + +/** + * Resolves config property values that may be plaintext or environment variable + * references ({@code ${env:VAR_NAME}}). + * + * + */ +public class StripeConfigPropertyResolver { + + private static final Pattern ENV_PATTERN = Pattern.compile("^\\$\\{env:([^}]+)}$"); + + /** + * Resolve a configuration value. Supports plaintext and {@code ${env:VAR}}. + * + * @param value the raw property value (may be {@code null}) + * @return the resolved plaintext value, or {@code null} if input is {@code null} + */ + public static @Nullable String resolve(@Nullable final String value) { + if (value == null) { + return null; + } + + final Matcher envMatcher = ENV_PATTERN.matcher(value); + if (envMatcher.matches()) { + final String varName = envMatcher.group(1); + final String envValue = resolveEnvVar(varName); + if (envValue == null) { + throw new IllegalStateException("Environment variable '" + varName + "' is not set"); + } + return envValue; + } + + return value; + } + + // Package-private for test overriding + static @Nullable String resolveEnvVar(final String name) { + return System.getenv(name); + } +} diff --git a/src/test/java/org/killbill/billing/plugin/stripe/TestStripeConfigPropertyResolver.java b/src/test/java/org/killbill/billing/plugin/stripe/TestStripeConfigPropertyResolver.java new file mode 100644 index 0000000..d69971e --- /dev/null +++ b/src/test/java/org/killbill/billing/plugin/stripe/TestStripeConfigPropertyResolver.java @@ -0,0 +1,65 @@ +/* + * Copyright 2020-2020 Equinix, Inc + * Copyright 2014-2020 The Billing Project, LLC + * + * The Billing Project licenses this file to you under the Apache License, version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package org.killbill.billing.plugin.stripe; + +import java.util.Properties; + +import org.testng.Assert; +import org.testng.annotations.Test; + +public class TestStripeConfigPropertyResolver { + + @Test(groups = "fast") + public void testPlaintextPassthrough() { + Assert.assertEquals(StripeConfigPropertyResolver.resolve("sk_test_XXX"), "sk_test_XXX"); + Assert.assertEquals(StripeConfigPropertyResolver.resolve("plain value"), "plain value"); + Assert.assertEquals(StripeConfigPropertyResolver.resolve(""), ""); + } + + @Test(groups = "fast") + public void testNullPassthrough() { + Assert.assertNull(StripeConfigPropertyResolver.resolve(null)); + } + + @Test(groups = "fast") + public void testEnvVarResolution() { + // HOME is always set on Unix/macOS; use PATH as fallback + final String varName = System.getenv("HOME") != null ? "HOME" : "PATH"; + final String expected = System.getenv(varName); + Assert.assertNotNull(expected, varName + " should be set in the test environment"); + Assert.assertEquals(StripeConfigPropertyResolver.resolve("${env:" + varName + "}"), expected); + } + + @Test(groups = "fast", expectedExceptions = IllegalStateException.class, + expectedExceptionsMessageRegExp = ".*Environment variable.*is not set.*") + public void testEnvVarNotSetFails() { + // Use a var name that is extremely unlikely to exist + StripeConfigPropertyResolver.resolve("${env:KILLBILL_STRIPE_TEST_NONEXISTENT_VAR_12345}"); + } + + @Test(groups = "fast") + public void testBackwardCompatibilityWithStripeConfigProperties() { + final Properties properties = new Properties(); + properties.setProperty("org.killbill.billing.plugin.stripe.apiKey", "sk_test_plaintext"); + properties.setProperty("org.killbill.billing.plugin.stripe.publicKey", "pk_test_plaintext"); + + final StripeConfigProperties config = new StripeConfigProperties(properties, ""); + + Assert.assertEquals(config.getApiKey(), "sk_test_plaintext"); + Assert.assertEquals(config.getPublicKey(), "pk_test_plaintext"); + } +}