Skip to content

Commit

Permalink
[#21] Enable fetching KeyPair from AWS SecretsManager (#42)
Browse files Browse the repository at this point in the history
Moved KeypairReader Bean creation outside of jwt-opa

Library configuration should not get involved in deciding
how and where the keys are loaded.

Also removed the key properties from the token properties.
so as to simplify adding more sources for secrets
(e.g., Vault, Azure, etc.).

Added fake AWS creds to Test GH Action
  • Loading branch information
massenz authored Oct 29, 2022
1 parent 169b2d1 commit 12db937
Show file tree
Hide file tree
Showing 27 changed files with 756 additions and 171 deletions.
15 changes: 12 additions & 3 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,16 @@ jobs:
- uses: actions/setup-java@v1
with:
java-version: 17
- name: Setup fake Credentials
run: cp gradle.properties.fake gradle.properties
- name: Setup fake Credentials for Tests
run: |
cp gradle.properties.fake gradle.properties
mkdir -p ~/.aws
cat <<EOF > ~/.aws/credentials
[default]
aws_access_key_id = fake
aws_secret_access_key = fake
EOF
- name: Build & Test
run: chmod +x gradlew && ./gradlew build test
run: |
echo "keys: $(ls -l testdata)"
chmod +x gradlew && ./gradlew test
4 changes: 1 addition & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,8 @@ generated/

# Key Material is never a good candidate for source control,
# unless they are used for test purposes (only).
*.pub
*.pem
!**/testdata/*.pem
!**/testdata/test-key.*

# Code Coverage
.coverage
Expand All @@ -39,7 +38,6 @@ tests/cover
# Gradle related files
.gradle
gradlew.bat
build/
!*/gradle/wrapper/gradle-wrapper.jar

### STS ###
Expand Down
41 changes: 25 additions & 16 deletions aws-upload-keys.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,31 @@
#
# Generates a Elliptict Cryptography keypair using openssl

set -e
set -eu

function usage {
echo "Usage: $(basename $0) KEY [DIR]
echo "Usage: $(basename $0) KEY SECRET
KEY the name of the key pair to upload, required;
DIR optionally, a directory where the keypair is stored
KEY the path to the key pair to upload, WITHOUT extension
SECRET the name of the secret to create in AWS Secrets Manager
This script uploads a key pair named 'KEY.pem' and 'KEY.pub' to AWS Secrets Manager,
using the \$AWS_PROFILE env var to obtain the credentials and the region to upload to.
Use \$AWS_ENDPOINT to specify a custom endpoint for the Secrets Manager service, if not using
the default AWS endpoint (eg, when testing against a localstack container, you can use
http://localhost:4566).
The pair can be generated using the keygen.sh script.
Requires the aws binary CLI (https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html)
"
}

KEY=${1:-}
DIR=${2:-}
SECRET=${2:-}
ENDPOINT_URL=""

if [[ -z ${KEY} || ${1:-} == "--help" || ${1:-} == "-h" ]]; then
if [[ -z ${KEY} || -z ${SECRET} || ${1:-} == "-h" ]]; then
usage
exit 1
fi
Expand All @@ -52,27 +56,32 @@ then
exit 1
fi

if [[ -n ${AWS_ENDPOINT:-} ]]; then
ENDPOINT_URL="--endpoint-url ${AWS_ENDPOINT}"
fi

PRIV=${KEY}.pem
PUB=${KEY}.pub
if [[ -n ${DIR} && -d ${DIR} ]]; then
PRIV=${DIR}/${PRIV}
PUB=${DIR}/${PUB}
if [[ ! -f ${PRIV} || ! -f ${PUB} ]]; then
usage
echo "ERROR: Cannot find ${PRIV} and/or ${PUB} keys"
exit 1
fi

out=$(mktemp /tmp/secret-XXXXXXXX.tmp)
cat <<EOF >$out
{
"priv": "$(while read -r line; do if [[ ! ${line} =~ ^----- ]]; \
then echo -n ${line}; fi; done < $PRIV)",
"pub": "$(while read -r line; do [[ ${line} =~ ^----- ]] || echo -n ${line}; done < $PUB)",
then echo -n ${line}; fi; done < ${PRIV})",
"pub": "$(while read -r line; do [[ ${line} =~ ^----- ]] || echo -n ${line}; done < ${PUB})",
"algorithm": "EC"
}
EOF

aws secretsmanager create-secret --name $KEY \
--description "Keypair $KEY generated by the $(basename $0) script" \
--secret-string file://$out

arn=$(aws ${ENDPOINT_URL} secretsmanager create-secret --name ${SECRET} \
--description "Keypair ${KEY} generated by the $(basename $0) script" \
--secret-string file://${out} | jq -r '.ARN')

rm $out
echo "[SUCCESS] Key Pair uploaded to AWS: ${KEY}"
echo "[SUCCESS] Key Pair ${KEY} uploaded to AWS: ${arn}"
10 changes: 9 additions & 1 deletion jwt-opa/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ plugins {
}

ext {
awsSdkVersion = '2.17.102'
jsonpathVersion = "2.5.0"
lombokVersion = "1.18.22"
minCoverageRatio = 0.80
Expand All @@ -37,7 +38,7 @@ ext {
}

group 'com.alertavert'
version '0.8.0'
version '0.9.0'

// OpenJDK 17 LTS is the only Java version supported
sourceCompatibility = JavaVersion.VERSION_17
Expand All @@ -47,6 +48,7 @@ if (JavaVersion.current() != JavaVersion.VERSION_17) {
}

repositories {
mavenLocal()
mavenCentral()
}

Expand Down Expand Up @@ -91,12 +93,18 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-webflux'

// AWS SDK for Secrets Manager, see: https://docs.aws.amazon.com/code-samples/latest/catalog/code-catalog-javav2-example_code-secretsmanager.html
implementation "software.amazon.awssdk:secretsmanager:${awsSdkVersion}"

testImplementation "com.jayway.jsonpath:json-path-assert:$jsonpathVersion"
testImplementation "org.mockito:mockito-core:$mockitoVersion"

// Test Containers: https://www.testcontainers.org/
testImplementation "org.testcontainers:testcontainers:$tcVersion"
testImplementation "org.testcontainers:junit-jupiter:$tcVersion"
testImplementation "org.testcontainers:localstack:$tcVersion"
testImplementation group: 'com.amazonaws', name: 'aws-java-sdk-core', version: '1.12.326'

}

jacocoTestCoverageVerification {
Expand Down
22 changes: 21 additions & 1 deletion jwt-opa/src/main/java/com/alertavert/opa/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,24 @@ public class Constants {
*/
public static final String ELLIPTIC_CURVE = "EC";

/** Marker for a Public Key object */
public static final String PUBLIC_KEY = "PUBLIC KEY";

/** Marker for a Private Key object */
public static final String PRIVATE_KEY = "PRIVATE KEY";

/**
* Passphrase-based encryption (see
* {@link com.alertavert.opa.configuration.KeyMaterialConfiguration}.
*/
public static final String PASSPHRASE = "SECRET";

/**
* The name of the Env Var which contains the name of the file storing the AWS API Token in a
* running EKS container.
*/
public static final String AWS_TOKEN_FILE = "AWS_WEB_IDENTITY_TOKEN_FILE";

/** OPA API version */
public static final String OPA_VERSION = "v1";

Expand All @@ -66,7 +78,7 @@ public class Constants {
public static final String DEFAULT_HEALTH_ROUTE = "/health";

/**
* The default login endpoing, by default only allowed using HTTP Basic auth, but will not
* The default login endpoint, by default only allowed using HTTP Basic auth, but will not
* require a valid API Token and won't try to authorize access.
*/
public static final String DEFAULT_LOGIN_ROUTE = "/login";
Expand All @@ -87,6 +99,14 @@ public class Constants {

public static final String API_TOKEN = "api_token";

public static final String KEYPAIR_LOADED = "Keypair loaded from AWS Secrets Manager: "
+ "secret-name = {}";
public static final String KEYPAIR_ERROR = "Cannot load secret from AWS: secret-name = {}, "
+ "error = {}";

public static final String CREDENTIALS_PROVIDER_LOG = "Creating a {} Credentials Provider: {}";
public static final String CREDENTIALS_PROVIDER_ERROR = "Cannot create Credentials Provider: {}";

/**
* A completely inactive user, that needs to act as a placeholder when the `username` is not
* found in the Users DB, and would trigger an exception in the Java Security HTTP Basic
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2021 AlertAvert.com. All rights reserved.
* Author: Marco Massenzio (marco@alertavert.com)
*/
package com.alertavert.opa;

/**
* Marker annotation for classes to be excluded by Jacoco test coverage report and rules.
*
* The naming is a bit contrived as Jacoco <strong>requires</strong> that the
* {@literal Generated} string be present in the annotation; classes annotated
* with {@link ExcludeFromCoverageGenerated} are <strong>not</strong> generated at all
* (Lombok does annotate methods/classes with {@link lombok.Generated}).
*
* Typically classes annotated with this marker <em>SHOULD</em> only contain either
* fairly trivial logic (e.g., configuration properties parsing and conversion) or access runtime
* components (e.g., AWS) where testing would require extensive (and, ultimately, self-defeating)
* mocking or real online services.
*
* <strong>USE WITH MODERATION</strong> and, most importantly, <strong>do not</strong> use it to
* bypass the Code Coverage check.
*
* @author M. Massenzio, 2022-04-14
*/
public @interface ExcludeFromCoverageGenerated {
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,6 @@ public class KeyMaterialConfiguration {

public KeyMaterialConfiguration(TokensProperties properties) {
this.tokensProperties = properties;
if (properties.getSignature().getKeypair() == null) {
throw new IllegalStateException(UNDEFINED_KEYPAIR);
}
}

@Bean
Expand All @@ -58,59 +55,24 @@ public String issuer() {
}

@Bean
Algorithm hmac(KeyPair keyPair) {
TokensProperties.SignatureProperties properties = tokensProperties.getSignature();

return switch (properties.getAlgorithm()) {
case PASSPHRASE -> Algorithm.HMAC256(properties.getSecret());
case ELLIPTIC_CURVE -> Algorithm.ECDSA256((ECPublicKey) keyPair.getPublic(),
(ECPrivateKey) keyPair.getPrivate());
default -> throw new IllegalArgumentException(String.format("Algorithm [%s] not supported",
properties.getAlgorithm()));
};
Algorithm hmac(KeypairReader reader) {
switch (reader.algorithm()) {
case PASSPHRASE:
return Algorithm.HMAC256(tokensProperties.getSecret());
case ELLIPTIC_CURVE:
KeyPair keyPair = reader.loadKeys();
return Algorithm.ECDSA256((ECPublicKey) keyPair.getPublic(),
(ECPrivateKey) keyPair.getPrivate());
default:
throw new IllegalArgumentException(String.format("Algorithm [%s] not supported",
reader.algorithm()));
}
}

@Bean
JWTVerifier verifier(KeypairReader reader) throws IOException {
return JWT.require(hmac(keyPair(reader)))
JWTVerifier verifier(Algorithm algorithm) throws IOException {
return JWT.require(algorithm)
.withIssuer(issuer())
.build();
}

@Bean
public KeyPair keyPair(KeypairReader reader) throws IOException {
return reader.loadKeys();
}

/**
* Default key pair reader from the file system; to load a key pair from a different storage
* (e.g., Vault) implement your custom {@link KeypairReader} and inject it as a {@literal
* reader} bean.
*
* <p>This reader will interpret the {@literal keypair.priv,pub} properties as paths.
*
* <p>To use your custom {@link KeypairReader} implementation, define your bean as primary:
*
<pre>
&#64;Bean &#64;Primary
public KeypairReader reader() {
return new KeypairReader() {
&#64;Override
public KeyPair loadKeys() throws KeyLoadException {
// do something here
return someKeypair;
}
};
</pre>
*
* @return a reader which will try and load the key pair from the filesystem.
*/
@Bean
public KeypairReader filereader() {
TokensProperties.SignatureProperties props = tokensProperties.getSignature();
return new KeypairFileReader(
props.getAlgorithm(),
Paths.get(props.getKeypair().getPriv()),
Paths.get(props.getKeypair().getPub()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,24 +30,21 @@
@ConfigurationProperties(prefix = "tokens")
public class TokensProperties {

@Data
public static class Pair {
String priv;
String pub;
}

@Data
public static class SignatureProperties {
private String algorithm;
private Pair keypair;
private String secret;
}

/**
* Corresponds to the {@literal "iss"} claim; the authority that has issued the token
*/
private String issuer;

/**
* Passphrase-based signature uses this secret.
*
* <strong>NOT recommended</strong> as this is insecure, prefer the use of private/public key
* pairs.
*
* @see com.alertavert.opa.security.crypto.KeypairReader
*/
private String secret;

/**
* {@literal true} by default, used in conjunction with {@link #expiresAfterSec} to determine
* whether the token should expire and, if so, how long after it has been created (the
Expand All @@ -68,6 +65,4 @@ public static class SignatureProperties {
* By default this value is 0, i.e., the token is immediately available for use.
*/
long notBeforeDelaySec = 0L;

SignatureProperties signature;
}
Loading

0 comments on commit 12db937

Please sign in to comment.