-
Notifications
You must be signed in to change notification settings - Fork 1
Private CA #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sudo-rgorai
wants to merge
37
commits into
main
Choose a base branch
from
privateCA
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Private CA #2
Changes from 16 commits
Commits
Show all changes
37 commits
Select commit
Hold shift + click to select a range
798e263
Add files for private CA
sudo-rgorai 84a9e1d
Take AWS region as input
sudo-rgorai 1e8aa25
Remove action for generating root X.509 certificate
sudo-rgorai 123b832
Use current timestamp for auth header
sudo-rgorai ce72f23
Accept lambda event as params
sudo-rgorai 571e245
Use region for all aws services
sudo-rgorai abcbce9
Use exact pattern matching for deleting temporary files
sudo-rgorai af7756d
Fix filenames in deploy-resources.sh
sudo-rgorai 2e1b9b7
Change CA action from get to generate
sudo-rgorai 8039e58
Delete secret.json after use
sudo-rgorai ba4deb7
Delete package-lock.json
sudo-rgorai e27dff0
Fix ssh client cert validity
sudo-rgorai c30758f
Use pubkey file instead of string for generating certificate
sudo-rgorai 99afb54
Do not use response.json
sudo-rgorai eb67f93
Base64 encode response certificate
sudo-rgorai 99a8b9b
Invoke function using lambda URL instead of AWS CLI
sudo-rgorai b9c2c48
Add -h help tag and handle invalid action
sudo-rgorai d38a12c
Certificate validity should be decided by CA not by the subject
sudo-rgorai 1b7802e
Check for existing certificates and their expiration
sudo-rgorai 4c70c44
Add certificate expiration buffer of 5 minutes
sudo-rgorai 6efcdb3
Use awscurl instead of curl to generate certs
sudo-rgorai ff72a42
Do not use awscurl
sudo-rgorai c854587
reorganize directories
d67a780
add awsprofile, let server decide it's own secret region
e073fbc
Add audience header
sudo-rgorai b4f21c5
Use docker container
sudo-rgorai b078b61
Fix server deployment issues
sudo-rgorai 6424640
Add instruction for AWS secrets region environment variable
sudo-rgorai a128e39
Specify certificate type
sudo-rgorai d4fdede
private-ca: use variables for validity, cert details
28b4de3
merge main
2720292
Added cron job to Docker Container to regenerate certs everyday
23f4391
added override default cert location for curl
312cda7
workflow to push private ca container to docker hub
9df7571
updated push path for privateCA workflow
dfa428c
fix: set curl cert bundle path
315bc95
fix: changed cd path [skip ci]
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| private-ca/lambda/node_modules/ | ||
| private-ca/lambda/package-lock.json | ||
| .talismanrc |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| # Private Certificate Authority (CA) for SSH and SSL Certificates | ||
|
|
||
| This project provides a private Certificate Authority (CA) implementation for generating both SSH and SSL certificates. It allows you to issue certificates for SSH hosts and users, as well as client SSL certificates for secure communication. | ||
|
|
||
| ## Installation | ||
|
|
||
|
|
||
| Deploy the resources by running: | ||
|
|
||
| ```bash | ||
| ./deploy-resources.sh | ||
| ``` | ||
|
|
||
| This creates the following resources on AWS: | ||
| - Secret to store the keys for signing certificates | ||
| - A role for the lambda function | ||
| - A policy to be attached to the role giving read/update access to created secret | ||
| - An openSSH layer to facilitate SSH operations | ||
| - The lambda function to act as a privateCA | ||
|
|
||
|
|
||
| ## Usage | ||
|
|
||
| Certificates can be generated by running: | ||
|
|
||
| ```bash | ||
| ./generate-certificate.sh | ||
| ``` | ||
|
|
||
| You can pass the following params to modify the payload: | ||
|
|
||
| - `CA_ACTION` | ||
| - generateHostSSHCert | ||
| - generateClientSSHCert | ||
| - generateClientX509Cert | ||
| - `CERT_PUBKEY` - Public Key of the client to be signed for generating the certificate | ||
| - `CERT_VALIDITY` - Validity of the generated certificate in days | ||
| - `AWS_STS_REGION` - AWS region for generating sts token | ||
| - `AWS_SECRETS_REGION` - AWS region of the secret | ||
| - `CA_LAMBDA_FUNCTION_NAME` - Name of the CA lambda function |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import sys | ||
| from datetime import datetime | ||
| import boto3 | ||
| from botocore.auth import SigV4Auth | ||
| from botocore.awsrequest import AWSRequest | ||
| from botocore.credentials import Credentials | ||
|
|
||
| if __name__ == "__main__": | ||
| access_key_id = sys.argv[1] | ||
| secret_access_key = sys.argv[2] | ||
| session_token = sys.argv[3] | ||
| aws_region = sys.argv[4] | ||
|
|
||
| sts_host = "sts." + aws_region + ".amazonaws.com" | ||
| request_parameters = 'Action=GetCallerIdentity&Version=2011-06-15' | ||
| request_headers = { | ||
| 'Host': sts_host, | ||
| 'X-Amz-Date': datetime.now().strftime('%Y%m%dT%H%M%SZ') | ||
| } | ||
| request = AWSRequest(method="POST", url="/", data=request_parameters, | ||
| headers=request_headers) | ||
| boto_creds = Credentials(access_key_id, secret_access_key,token=session_token) | ||
| auth = SigV4Auth(boto_creds, "sts", aws_region) | ||
| auth.add_auth(request) | ||
|
|
||
| authorization = request.headers["Authorization"] | ||
| date = request.headers["X-Amz-Date"] | ||
|
|
||
| response = f'{{"Authorization": "{authorization}", "Date": "{date}"}}' | ||
| print(response) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| #!/bin/bash | ||
|
|
||
| SECRET_NAME=${1:-"privateCA"} | ||
| ROLE_NAME=${2:-"privateCALambdaRole"} | ||
| POLICY_NAME=${3:-"PrivateCAPolicy"} | ||
| LAYER_NAME=${4:-"openssh"} | ||
| FUNCTION_NAME=${5:-"privateCA"} | ||
| AWS_REGION=${6:-"ap-south-1"} | ||
|
|
||
| ################## Secret ################## | ||
|
|
||
| # Generate Keys | ||
| ssh-keygen -t rsa -b 4096 -f host_ca -C host_ca -N "" | ||
| ssh-keygen -t rsa -b 4096 -f user_ca -C user_ca -N "" | ||
|
|
||
| openssl genrsa -out key.pem 2048 | ||
| openssl rsa -in key.pem -outform PEM -pubout -out public.pem | ||
| openssl req -new -x509 -key key.pem -out root.crt -days 365 -subj "/C=US/ST=California/L=YourCity/O=Fundwave/OU=Fundwave/CN=FundwaveCA" | ||
|
|
||
| HOST_CA_PRIVATE_KEY=$(cat host_ca | base64 -w 0) | ||
| HOST_CA_PUBLIC_KEY=$(cat host_ca.pub | base64 -w 0) | ||
| USER_CA_PRIVATE_KEY=$(cat user_ca | base64 -w 0) | ||
| USER_CA_PUBLIC_KEY=$(cat user_ca.pub | base64 -w 0) | ||
| ROOT_SSL_PRIVATE_KEY=$(cat key.pem | base64 -w 0) | ||
| ROOT_SSL_PUBLIC_KEY=$(cat public.pem | base64 -w 0) | ||
| ROOT_SSL_CERT=$(cat root.crt | base64 -w 0) | ||
|
|
||
| echo "{\"host_ca\": \"${HOST_CA_PRIVATE_KEY}\", \"host_ca.pub\": \"${HOST_CA_PUBLIC_KEY}\", \"user_ca\": \"${USER_CA_PRIVATE_KEY}\",\"user_ca.pub\": \"${USER_CA_PUBLIC_KEY}\",\"root_ssl_private_key\": \"${ROOT_SSL_PRIVATE_KEY}\",\"root_ssl_public_key\": \"${ROOT_SSL_PUBLIC_KEY}\", \"rootX509cert\": \"${ROOT_SSL_CERT}\"}" | jq . > secret.json | ||
|
|
||
| # Create Secret | ||
| SECRET_ARN=$(aws secretsmanager create-secret \ | ||
| --name $SECRET_NAME \ | ||
| --secret-string file://secret.json \ | ||
| --region $AWS_REGION | jq ".ARN" | tr -d '"') | ||
|
|
||
| # Clean up | ||
| rm host_ca host_ca.pub user_ca user_ca.pub key.pem public.pem root.crt secret.json | ||
| ############################################ | ||
|
|
||
| ################### Role ################### | ||
|
|
||
| # Create role for lambda | ||
| echo "{\"Version\": \"2012-10-17\",\"Statement\": [{\"Sid\": \"AllowLambdaAssumeRole\",\"Effect\": \"Allow\",\"Principal\": {\"Service\": \"lambda.amazonaws.com\"},\"Action\": \"sts:AssumeRole\"}]}" | jq . > Trust-Policy.json | ||
|
|
||
| ROLE_ARN=$(aws iam create-role \ | ||
| --role-name $ROLE_NAME \ | ||
| --region $AWS_REGION \ | ||
| --assume-role-policy-document file://Trust-Policy.json | jq ".Role.Arn" | tr -d '"') | ||
|
|
||
| # Create Policy for Lambda Role to Read and Update Secrets | ||
| echo "{\"Version\": \"2012-10-17\",\"Statement\": [{\"Sid\": \"VisualEditor0\",\"Effect\": \"Allow\",\"Action\": [\"secretsmanager:GetSecretValue\",\"secretsmanager:UpdateSecret\"],\"Resource\": \"${SECRET_ARN}\"}, {\"Action\": [\"logs:CreateLogGroup\",\"logs:CreateLogStream\",\"logs:PutLogEvents\"],\"Effect\": \"Allow\",\"Resource\": \"arn:aws:logs:*:*:*\"}]}" | jq . > Policy.json | ||
|
|
||
| POLICY_ARN=$(aws iam create-policy --policy-name $POLICY_NAME --region $AWS_REGION --policy-document file://Policy.json | jq ".Policy.Arn" | tr -d '"') | ||
|
|
||
| # Attach policy to role | ||
| aws iam attach-role-policy --role-name $ROLE_NAME --policy-arn $POLICY_ARN --region $AWS_REGION | ||
|
|
||
| # Clean up | ||
| rm Trust-Policy.json Policy.json | ||
| ############################################ | ||
|
|
||
| ################### Layer ################## | ||
|
|
||
| # Create OpenSSH layer | ||
| sudo docker run --rm -v $(pwd)/openssh-layer:/lambda/opt lambci/yumda:2 yum install -y openssh | ||
| cd openssh-layer | ||
| sudo zip -yr ./openssh-layer.zip . > /dev/null | ||
| LAYER_ARN=$(aws lambda publish-layer-version \ | ||
| --layer-name $LAYER_NAME \ | ||
| --zip-file fileb://openssh-layer.zip \ | ||
| --region $AWS_REGION \ | ||
| --query 'LayerVersionArn' \ | ||
| --output text) | ||
| cd .. | ||
|
|
||
| # Clean up | ||
| sudo rm -r openssh-layer/ | ||
| ############################################ | ||
|
|
||
| ################## Lambda ################## | ||
|
|
||
| # Create lambda function | ||
| cd lambda | ||
| npm i | ||
| zip -r ./lambda.zip . | ||
| mv lambda.zip ../ | ||
| cd .. | ||
|
|
||
| aws lambda create-function \ | ||
| --function-name $FUNCTION_NAME \ | ||
| --runtime nodejs18.x \ | ||
| --region $AWS_REGION \ | ||
| --handler index.handler \ | ||
| --zip-file fileb://lambda.zip \ | ||
| --layers $LAYER_ARN \ | ||
| --role $ROLE_ARN | ||
|
|
||
| aws lambda add-permission \ | ||
| --function-name $FUNCTION_NAME \ | ||
| --action lambda:InvokeFunctionUrl \ | ||
| --principal "*" \ | ||
| --function-url-auth-type "NONE" \ | ||
| --statement-id url | ||
|
|
||
| FUNCTION_URL=$(aws lambda create-function-url-config --function-name "privateCA" --auth-type "NONE" | jq -r ".FunctionUrl") | ||
|
|
||
| echo "CA deployed at URL:" | ||
| echo "${FUNCTION_URL}" | ||
|
|
||
| # Clean up | ||
| sudo rm -r lambda/node_modules/ lambda/package-lock.json lambda.zip | ||
| ########################################### |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| #!/bin/bash | ||
|
|
||
| CA_ACTION=${1} | ||
| CERT_PUBKEY_FILE=${2} | ||
| CA_LAMBDA_URL=${3} | ||
| CERT_VALIDITY=${4:-"1"} | ||
| AWS_STS_REGION=${5:-"ap-south-1"} | ||
| AWS_SECRETS_REGION=${6:-"ap-south-1"} | ||
| CA_LAMBDA_FUNCTION_NAME=${7:-"privateCA"} | ||
|
|
||
| CERT_PUBKEY=$(cat ${CERT_PUBKEY_FILE} | base64 -w 0) | ||
|
|
||
| # Temporary Credentials | ||
| TEMP_CREDS=$(aws sts get-session-token) | ||
|
|
||
| ACCESS_KEY_ID=$(echo $TEMP_CREDS | jq -r ".Credentials.AccessKeyId") | ||
| SECRET_ACCESS_KEY=$(echo $TEMP_CREDS | jq -r ".Credentials.SecretAccessKey") | ||
| SESSION_TOKEN=$(echo $TEMP_CREDS | jq -r ".Credentials.SessionToken") | ||
|
|
||
| # Auth Headers | ||
| python -m venv env && source env/bin/activate | ||
| pip install boto3 | ||
|
|
||
| output=$(python aws-auth-header.py $ACCESS_KEY_ID $SECRET_ACCESS_KEY $SESSION_TOKEN $AWS_STS_REGION) | ||
| auth_header=$(echo $output | jq -r ".Authorization") | ||
| date=$(echo $output | jq -r ".Date") | ||
|
|
||
| EVENT_JSON=$(echo "{\"auth\":{\"amzDate\":\"${date}\",\"authorizationHeader\":\"${auth_header}\",\"sessionToken\":\"${SESSION_TOKEN}\"},\"certValidity\":\"${CERT_VALIDITY}\",\"certPubkey\":\"${CERT_PUBKEY}\",\"action\":\"${CA_ACTION}\",\"awsSTSRegion\":\"${AWS_STS_REGION}\",\"awsSecretsRegion\":\"${AWS_SECRETS_REGION}\"}") | ||
|
|
||
| curl "${CA_LAMBDA_URL}" -H 'content-type: application/json' -d "$EVENT_JSON" | tr -d '"' | base64 -d > certificate | ||
|
|
||
| # Clean up | ||
| deactivate | ||
| sudo rm -r env/ | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import fs from 'fs'; | ||
| import child_process from 'child_process'; | ||
| import util from 'util'; | ||
|
|
||
| const exec = util.promisify(child_process.exec); | ||
|
|
||
| export const signClientSSHCertificate = async (callerIdentity, secret, certValidity, certPubkey) => { | ||
|
|
||
| const arn = callerIdentity.GetCallerIdentityResponse.GetCallerIdentityResult.Arn; | ||
| const roleName = arn.match(/\/([^/]+)$/)?.[1]; | ||
|
|
||
| const caKeyPath = "/tmp/client_ca"; | ||
| const publicKeyName = "ssh_client_rsa_key"; | ||
| const publicKeyPath = "/tmp/" + publicKeyName + ".pub"; | ||
| const certificatePath = "/tmp/" + publicKeyName + "-cert.pub"; | ||
| const user_ca = Buffer.from(secret.user_ca, 'base64').toString('utf-8'); | ||
| certPubkey = Buffer.from(certPubkey, 'base64').toString('utf-8'); | ||
| fs.writeFileSync(caKeyPath, user_ca); | ||
| fs.writeFileSync(publicKeyPath, certPubkey); | ||
|
|
||
| let { stdout, stderr } = await exec(`chmod 600 ${caKeyPath}`); | ||
| console.log('stdout:', stdout); | ||
| console.log('stderr:', stderr); | ||
|
|
||
| ({ stdout, stderr } = await exec(`ssh-keygen -s ${caKeyPath} -I client_${roleName} -n ${roleName} -V +${certValidity}d ${publicKeyPath}`)); | ||
| console.log('stdout:', stdout); | ||
| console.log('stderr:', stderr); | ||
|
|
||
| const certificate = fs.readFileSync(certificatePath, 'utf8'); | ||
| return certificate; | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import forge from 'node-forge'; | ||
| import md from 'node-forge'; | ||
|
|
||
| export const generateClientX509Cert = async (callerIdentity, secret, event) => { | ||
|
|
||
| const pki = forge.pki; | ||
|
|
||
| const arn = callerIdentity.GetCallerIdentityResponse.GetCallerIdentityResult.Arn; | ||
| const roleName = arn.match(/\/([^/]+)$/)?.[1]; | ||
|
|
||
| // Load the root certificate private key from a file or string | ||
| const rootKeyPem = Buffer.from(secret.root_ssl_private_key, 'base64').toString('utf-8'); | ||
| const rootKey = pki.privateKeyFromPem(rootKeyPem); | ||
|
|
||
| // Load the root certificate public key from a file or string | ||
| let rootCertKey = 'rootX509cert'; | ||
| if(!(rootCertKey in secret)) | ||
| { | ||
| console.log("No root certificate found. Aborting creation of client X.509 certificate."); | ||
| return { | ||
| statusCode: 500, | ||
| body: "No root certificate found." | ||
| }; | ||
| } | ||
| let rootCertPem = Buffer.from(secret['rootX509cert'], 'base64').toString('utf-8'); | ||
| const rootCert = pki.certificateFromPem(rootCertPem); | ||
|
|
||
| // openssl genrsa -out key.pem 2048 | ||
| // openssl rsa -in key.pem -outform PEM -pubout -out public.pem | ||
| const clientPublicKey = pki.publicKeyFromPem(Buffer.from(event.certPubkey, 'base64').toString('utf-8')); | ||
|
|
||
| // Create a client certificate signing request (CSR) | ||
| const clientCertReq = pki.createCertificationRequest(); | ||
| clientCertReq.publicKey = clientPublicKey; | ||
| clientCertReq.setSubject([ | ||
| { name: 'countryName', value: 'US' }, | ||
| { name: 'localityName', value: 'California' }, | ||
| { name: 'organizationName', value: 'Fundwave' }, | ||
| { name: 'organizationalUnitName', value: 'Fundwave' }, | ||
| { name: 'commonName', value: roleName } | ||
| ]); | ||
|
|
||
| // Sign the client certificate request with the root certificate and private key | ||
| const clientCert = pki.createCertificate(); | ||
| clientCert.publicKey = clientCertReq.publicKey; | ||
| clientCert.serialNumber = '01'; // Set a unique serial number | ||
|
|
||
| const startDate = new Date(); // Valid from the current date and time | ||
| const endDate = new Date(); | ||
| endDate.setDate(startDate.getDate() + event.certValidity); | ||
| clientCert.validity.notBefore = startDate; | ||
| clientCert.validity.notAfter = endDate; | ||
|
|
||
| clientCert.setSubject(clientCertReq.subject.attributes); | ||
| clientCert.setIssuer(rootCert.subject.attributes); | ||
| clientCert.setExtensions([ | ||
| { name: 'basicConstraints', cA: false }, | ||
| { name: 'keyUsage', digitalSignature: true, nonRepudiation: true, keyEncipherment: true }, | ||
| ]); | ||
| clientCert.sign(rootKey, md.sha256.create()); | ||
|
|
||
| // Convert the signed client certificate to PEM format | ||
| const clientCertPem = pki.certificateToPem(clientCert); | ||
| return { | ||
| statusCode: 200, | ||
| body: Buffer.from(clientCertPem).toString('base64') | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import fs from 'fs'; | ||
| import child_process from 'child_process'; | ||
| import util from 'util'; | ||
|
|
||
| const exec = util.promisify(child_process.exec); | ||
|
|
||
| export const signHostSSHCertificate = async (callerIdentity, secret, certValidity, certPubkey) => { | ||
|
|
||
| const arn = callerIdentity.GetCallerIdentityResponse.GetCallerIdentityResult.Arn; | ||
| const roleName = arn.match(/\/([^/]+)$/)?.[1]; | ||
|
|
||
| const caKeyPath = "/tmp/host_ca"; | ||
| const publicKeyName = "ssh_host_rsa_key"; | ||
| const publicKeyPath = "/tmp/" + publicKeyName + ".pub"; | ||
| const certificatePath = "/tmp/" + publicKeyName + "-cert.pub"; | ||
| const host_ca = Buffer.from(secret.host_ca, 'base64').toString('utf-8'); | ||
| certPubkey = Buffer.from(certPubkey, 'base64').toString('utf-8'); | ||
| fs.writeFileSync(caKeyPath, host_ca); | ||
| fs.writeFileSync(publicKeyPath, certPubkey); | ||
|
|
||
| let { stdout, stderr } = await exec(`chmod 600 ${caKeyPath}`); | ||
| console.log('stdout:', stdout); | ||
| console.log('stderr:', stderr); | ||
|
|
||
| ({ stdout, stderr } = await exec(`ssh-keygen -s ${caKeyPath} -I host_${roleName} -h -n ${roleName} -V +${certValidity}d ${publicKeyPath}`)); | ||
| console.log('stdout:', stdout); | ||
| console.log('stderr:', stderr); | ||
|
|
||
| const certificate = fs.readFileSync(certificatePath, 'utf8'); | ||
| return certificate; | ||
| }; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.