diff --git a/docs/client-certificates.md b/docs/client-certificates.md index 20f03ed..8b82b19 100644 --- a/docs/client-certificates.md +++ b/docs/client-certificates.md @@ -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** @@ -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 @@ -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 diff --git a/main.tf b/main.tf index e764361..1684662 100644 --- a/main.tf +++ b/main.tf @@ -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" { diff --git a/modules/terraform-aws-ca-iam/main.tf b/modules/terraform-aws-ca-iam/main.tf index 49745c1..8f21168 100644 --- a/modules/terraform-aws-ca-iam/main.tf +++ b/modules/terraform-aws-ca-iam/main.tf @@ -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 }) } \ No newline at end of file diff --git a/modules/terraform-aws-ca-iam/templates/tls_cert_policy.json.tpl b/modules/terraform-aws-ca-iam/templates/tls_cert_policy.json.tpl index 2788d81..c475e13 100644 --- a/modules/terraform-aws-ca-iam/templates/tls_cert_policy.json.tpl +++ b/modules/terraform-aws-ca-iam/templates/tls_cert_policy.json.tpl @@ -108,6 +108,16 @@ "Resource": [ "${internal_s3_bucket_arn}/*" ] + }, + { + "Sid": "SNSPublish", + "Effect": "Allow", + "Action": [ + "sns:Publish" + ], + "Resource": [ + "${sns_topic_arn}" + ] } ] } \ No newline at end of file diff --git a/modules/terraform-aws-ca-iam/variables.tf b/modules/terraform-aws-ca-iam/variables.tf index 1a3cbfd..9998fa4 100644 --- a/modules/terraform-aws-ca-iam/variables.tf +++ b/modules/terraform-aws-ca-iam/variables.tf @@ -54,4 +54,9 @@ variable "internal_s3_bucket_arn" { variable "aws_principals" { description = "List of ARNs for AWS principals allowed to assume role" default = [] -} \ No newline at end of file +} + +variable "sns_topic_arn" { + description = "SNS Topic ARN" + default = "" +} diff --git a/modules/terraform-aws-ca-lambda/README.MD b/modules/terraform-aws-ca-lambda/README.MD index 7cb91ba..c4d50fc 100644 --- a/modules/terraform-aws-ca-lambda/README.MD +++ b/modules/terraform-aws-ca-lambda/README.MD @@ -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:::serverless-ca-notifications-dev" ``` * copy and paste AWS Powershell CLI variables to terminal * enter `python` @@ -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 = "secops@example.com"; pathLengthConstraint = 1} | ConvertTo-Json) $Env:ROOT_CRL_DAYS="1" $Env:ROOT_CRL_SECONDS="600" +$Env:SNS_TOPIC_ARN="arn:aws:sns:::serverless-ca-notifications-dev" ``` * copy and paste AWS Powershell CLI variables to terminal diff --git a/modules/terraform-aws-ca-lambda/lambda_code/tls_cert/tls_cert.py b/modules/terraform-aws-ca-lambda/lambda_code/tls_cert/tls_cert.py index 79738d6..4b9aaff 100644 --- a/modules/terraform-aws-ca-lambda/lambda_code/tls_cert/tls_cert.py +++ b/modules/terraform-aws-ca-lambda/lambda_code/tls_cert/tls_cert.py @@ -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, @@ -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 @@ -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") @@ -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() diff --git a/modules/terraform-aws-ca-lambda/utils/aws/sns.py b/modules/terraform-aws-ca-lambda/utils/aws/sns.py new file mode 100644 index 0000000..5475e31 --- /dev/null +++ b/modules/terraform-aws-ca-lambda/utils/aws/sns.py @@ -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 diff --git a/modules/terraform-aws-ca-lambda/utils/certs/s3.py b/modules/terraform-aws-ca-lambda/utils/certs/s3.py index 4ec07b0..c82e02a 100644 --- a/modules/terraform-aws-ca-lambda/utils/certs/s3.py +++ b/modules/terraform-aws-ca-lambda/utils/certs/s3.py @@ -1,4 +1,5 @@ import boto3 +import json def s3_download_file(bucket_name, key): @@ -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 diff --git a/modules/terraform-aws-ca-sns/locals.tf b/modules/terraform-aws-ca-sns/locals.tf index 29e2a34..b3d2ab7 100644 --- a/modules/terraform-aws-ca-sns/locals.tf +++ b/modules/terraform-aws-ca-sns/locals.tf @@ -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, { diff --git a/tests/test_issued_certs.py b/tests/test_issued_certs.py index 2a1b3f1..6b6b6eb 100644 --- a/tests/test_issued_certs.py +++ b/tests/test_issued_certs.py @@ -1,5 +1,6 @@ from assertpy import assert_that import base64 +import json import structlog from datetime import timedelta from certvalidator.errors import InvalidCertificateError @@ -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, @@ -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 = { @@ -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():