Skip to content

Commit

Permalink
SNS notifications for GitOps issued certificates (#214)
Browse files Browse the repository at this point in the history
  • Loading branch information
paulschwarzenberger authored Aug 19, 2024
1 parent 288dd75 commit 5051d3c
Show file tree
Hide file tree
Showing 11 changed files with 169 additions and 6 deletions.
36 changes: 36 additions & 0 deletions docs/client-certificates.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Only valid domains will be included in the Subject Alternative Name X.509 certif
* Internal team creates and approves pull request (PR)
* Merge PR to initiate CA pipeline
* Certificate issued and published to DynamoDB table
* Certificate details published to SNS topic

**Enable GitOps**

Expand All @@ -81,6 +82,11 @@ If you followed the [Getting Started](getting-started.md) guide, you'll already
* change the value of Terraform variable `cert_info_files` to `["tls", "revoked", "revoked-root-ca"]`
* apply Terraform

**Subscribe to SNS Topic**

* Using the AWS console, SNS, subscribe to the CA Notifications SNS Topic using your email address
* Confirm the susbscription

**Adding CSR File to CA repository**

* In the example below replace `dev` with your environment name
Expand Down Expand Up @@ -112,8 +118,38 @@ python utils/server-csr.py
* certificates will be issued and can be downloaded from the DynamoDB table
* subject details entered in JSON e.g. `Organization`, `Locality` override those included in CSR

**Get certificate from SNS notification**

* Details of GitOps issued certificate are published to SNS
* From your SNS email, copy the value of the JSON key `Base64Certificate`

**Decoding certificate from SNS (Linux / MacOS)**

* Open terminal / command line
* Overwrite placeholder with text from `Base64Certificate` value in SNS JSON
```bash
echo B64CERT-TEXT-FROM-JSON | base64 --decode > test-example-com.pem
```

**Decoding certificate from SNS (Windows)**

* Open Windows PowerShell ISE
* Copy the script below into the editor
* Overwrite placeholder with text from `Base64Certificate` value in SNS JSON
* Press Run
```PowerShell
# PowerShell
$input = "B64CERT-TEXT-FROM-JSON"
$filepath = "c:\tmp\test-example-com.crt"
# Base64 decode from SNS
$cert = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($input)) | Out-File -FilePath $filepath
```

**Retrieving certificates from DynamoDB**

If you haven't subscribed to SNS, or you want to retrieve a non-GitOps issued certificates, the value can be obtained from DynamoDB

* using the console, navigate to the CA DynamoDB table
* select Explore table items
* run a query, searching for the Common Name
Expand Down
1 change: 1 addition & 0 deletions main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ module "tls_keygen_iam" {
policy = "tls_cert"
external_s3_bucket_arn = module.external_s3.s3_bucket_arn
internal_s3_bucket_arn = module.internal_s3.s3_bucket_arn
sns_topic_arn = module.sns-ca-notifications.sns_topic_arn
}

module "create_rsa_root_ca_lambda" {
Expand Down
1 change: 1 addition & 0 deletions modules/terraform-aws-ca-iam/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ resource "aws_iam_role_policy" "lambda" {
ddb_table_arn = var.ddb_table_arn,
external_s3_bucket_arn = var.external_s3_bucket_arn,
internal_s3_bucket_arn = var.internal_s3_bucket_arn
sns_topic_arn = var.sns_topic_arn
})
}
10 changes: 10 additions & 0 deletions modules/terraform-aws-ca-iam/templates/tls_cert_policy.json.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,16 @@
"Resource": [
"${internal_s3_bucket_arn}/*"
]
},
{
"Sid": "SNSPublish",
"Effect": "Allow",
"Action": [
"sns:Publish"
],
"Resource": [
"${sns_topic_arn}"
]
}
]
}
7 changes: 6 additions & 1 deletion modules/terraform-aws-ca-iam/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,9 @@ variable "internal_s3_bucket_arn" {
variable "aws_principals" {
description = "List of ARNs for AWS principals allowed to assume role"
default = []
}
}

variable "sns_topic_arn" {
description = "SNS Topic ARN"
default = ""
}
2 changes: 2 additions & 0 deletions modules/terraform-aws-ca-lambda/README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ ROOT_CA_INFO=$(jq -n '{}')
export ROOT_CA_INFO
export ROOT_CRL_DAYS="1"
export ROOT_CRL_SECONDS="600"
export SNS_TOPIC_ARN="arn:aws:sns:<REGION>:<ACCOUNT-ID>:serverless-ca-notifications-dev"
```
* copy and paste AWS Powershell CLI variables to terminal
* enter `python`
Expand Down Expand Up @@ -163,6 +164,7 @@ $Env:PROJECT="serverless"
$Env:ROOT_CA_INFO=(@{country = "GB"; state = "London"; lifetime = 7300; locality = "London"; organization = "serverless"; organizationalUnit = "Security Operations"; commonName = "Serverless Development Root CA"; emailAddress = "[email protected]"; pathLengthConstraint = 1} | ConvertTo-Json)
$Env:ROOT_CRL_DAYS="1"
$Env:ROOT_CRL_SECONDS="600"
$Env:SNS_TOPIC_ARN="arn:aws:sns:<REGION>:<ACCOUNT-ID>:serverless-ca-notifications-dev"
```
* copy and paste AWS Powershell CLI variables to terminal

Expand Down
20 changes: 17 additions & 3 deletions modules/terraform-aws-ca-lambda/lambda_code/tls_cert/tls_cert.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import base64
import os

from utils.aws.sns import publish_to_sns
from utils.certs.kms import kms_get_kms_key_id, kms_describe_key
from utils.certs.crypto import (
crypto_cert_request_info,
Expand All @@ -19,7 +20,7 @@
db_list_certificates,
db_issue_certificate,
)
from utils.certs.s3 import s3_download
from utils.certs.s3 import cert_issued_via_gitops, s3_download
from cryptography.x509 import load_pem_x509_certificate, load_pem_x509_csr
from cryptography.hazmat.primitives import serialization
from dataclasses import dataclass, field
Expand Down Expand Up @@ -206,12 +207,22 @@ def create_ca_chain_response(project: str, env_name: str, root_ca_name: str, iss
)


def sns_notify_cert_issued(cert_json, sns_topic_arn):
keys_to_publish = ["CertificateInfo", "Base64Certificate", "Subject"]
response = publish_to_sns(cert_json, "Certificate Issued", sns_topic_arn, keys_to_publish)

assert response["ResponseMetadata"]["HTTPStatusCode"] == 200
common_name = cert_json["CertificateInfo"]["CommonName"]
print(f"Certificate details for {common_name} published to SNS")


def lambda_handler(event, context): # pylint:disable=unused-argument,too-many-locals
project = os.environ["PROJECT"]
env_name = os.environ["ENVIRONMENT_NAME"]
external_s3_bucket_name = os.environ["EXTERNAL_S3_BUCKET"]
internal_s3_bucket_name = os.environ["INTERNAL_S3_BUCKET"]
max_cert_lifetime = int(os.environ["MAX_CERT_LIFETIME"])
sns_topic_arn = os.environ["SNS_TOPIC_ARN"]
domain = os.environ.get("DOMAIN")

public_crl = os.environ.get("PUBLIC_CRL")
Expand Down Expand Up @@ -271,9 +282,12 @@ def lambda_handler(event, context): # pylint:disable=unused-argument,too-many-l
certificate_info=cert_info,
base64_certificate=base64_certificate.decode("utf-8"),
subject=load_pem_x509_certificate(base64.b64decode(base64_certificate)).subject.rfc4514_string(),
base64_root_ca_certificate=ca_chain_response.base64_root_ca_certificate,
base64_issuing_ca_certificate=ca_chain_response.base64_issuing_ca_certificate,
base64_root_ca_certificate=ca_chain_response.base64_root_ca_certificate.decode("utf-8"),
base64_issuing_ca_certificate=ca_chain_response.base64_issuing_ca_certificate.decode("utf-8"),
base64_ca_chain=ca_chain_response.base64_ca_chain,
)

if cert_issued_via_gitops(internal_s3_bucket_name, response.subject):
sns_notify_cert_issued(response.to_dict(), sns_topic_arn)

return response.to_dict()
21 changes: 21 additions & 0 deletions modules/terraform-aws-ca-lambda/utils/aws/sns.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import boto3
import json


def publish_to_sns(json_data, subject, sns_topic_arn, keys_to_publish="All"):
# Filter out unwanted keys
if keys_to_publish == "All":
keys_to_publish = json_data.keys()

filtered_json_data = {key: json_data[key] for key in keys_to_publish if key in json_data}

client = boto3.client("sns")

response = client.publish(
TargetArn=sns_topic_arn,
Subject=subject,
Message=json.dumps({"default": json.dumps(filtered_json_data)}),
MessageStructure="json",
)

return response
60 changes: 60 additions & 0 deletions modules/terraform-aws-ca-lambda/utils/certs/s3.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import boto3
import json


def s3_download_file(bucket_name, key):
Expand Down Expand Up @@ -39,3 +40,62 @@ def s3_upload(
return s3_upload_file(file, external_s3_bucket_name, key, content_type)

return s3_upload_file(file, internal_s3_bucket_name, key, content_type)


def convert_x509_subject_str_to_dict(input_str):
# split string by commas
pairs = input_str.split(",")

# split each pair by '=' and construct dictionary
json_dictionary = {}
for pair in pairs:
key, value = pair.split("=")
json_dictionary[key] = value

return json_dictionary


def cert_issued_via_gitops(internal_s3_bucket_name, subject):
# get list of GitOps certificates from internal S3 bucket
tls_file = s3_download_file(internal_s3_bucket_name, "tls.json")

return is_cert_gitops(tls_file, subject)


def is_cert_gitops(tls_file, subject):
subject_json = convert_x509_subject_str_to_dict(subject)

cn = subject_json["CN"]
o = subject_json.get("O")
ou = subject_json.get("OU")

if tls_file is None:
gitops_certs = []

else:
# convert to json dictionary
gitops_certs = json.loads(tls_file["Body"].read())

for cert in gitops_certs:
common_name = cert["common_name"]
organization = cert.get("organization")
organizational_unit = cert.get("organizational_unit")

# check if certificate is included in tls.json
cn_matches = False
o_matches = False
ou_matches = False

if cn == common_name:
cn_matches = True

if o is None or organization is None or o == organization:
o_matches = True

if ou is None or organizational_unit is None or ou == organizational_unit:
ou_matches = True

if cn_matches and o_matches and ou_matches:
return True

return False
2 changes: 1 addition & 1 deletion modules/terraform-aws-ca-sns/locals.tf
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
locals {
sns_topic_display_name = coalesce(var.custom_sns_topic_name, title(replace("${var.project}-${var.function}-${var.env}", "-", " ")))
sns_topic_display_name = coalesce(var.custom_sns_topic_name, replace(title(replace("${var.project}-${var.function}-${var.env}", "-", " ")), " Ca ", " CA "))
sns_topic_name = coalesce(var.custom_sns_topic_name, "${var.project}-${var.function}-${var.env}")

tags = merge(var.tags, {
Expand Down
15 changes: 14 additions & 1 deletion tests/test_issued_certs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from assertpy import assert_that
import base64
import json
import structlog
from datetime import timedelta
from certvalidator.errors import InvalidCertificateError
Expand All @@ -19,7 +20,7 @@

from utils.modules.aws.kms import get_kms_details
from utils.modules.aws.lambdas import get_lambda_name, invoke_lambda
from utils.modules.aws.s3 import get_s3_bucket, put_s3_object
from utils.modules.aws.s3 import delete_s3_object, get_s3_bucket, list_s3_object_keys, put_s3_object
from .helper import (
helper_create_csr_info,
helper_get_certificate,
Expand Down Expand Up @@ -291,6 +292,14 @@ def test_csr_uploaded_to_s3():
# Upload CSR to S3 bucket
put_s3_object(bucket_name, kms_arn, f"csrs/{common_name}.csr", csr)

# If no tls.json present, test for SNS notification
test_sns = False
if "tls.json" not in list_s3_object_keys(bucket_name):
test_sns = True
certs_json = [{"common_name": common_name, "lifetime": 1, "csr_file": f"{common_name}.csr"}]
tls_file = bytes(json.dumps(certs_json), "utf-8")
put_s3_object(bucket_name, kms_arn, "tls.json", tls_file)

# Construct JSON data to pass to Lambda function
csr_file = f"{common_name}.csr"
json_data = {
Expand All @@ -308,6 +317,10 @@ def test_csr_uploaded_to_s3():
log.info("issued certificate", subject=issued_cert.subject.rfc4514_string())

assert_that(issued_cert.subject.rfc4514_string()).is_equal_to(expected_subject)
if test_sns:
# check SNS messsage received via email subscription
# TODO: implement programatically within tests
delete_s3_object(bucket_name, "tls.json")


def test_no_private_key_reuse():
Expand Down

0 comments on commit 5051d3c

Please sign in to comment.