Skip to content

Commit 8d9fc67

Browse files
author
Maitreya Ranganath
committed
Initial commit of Secrets Manager SSH rotation code
1 parent a2dc66f commit 8d9fc67

File tree

10 files changed

+1010
-0
lines changed

10 files changed

+1010
-0
lines changed

deployer.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env bash
2+
S3Bucket=BUCKET_NAME
3+
REGION=us-east-2
4+
5+
FILE="$(uuidgen).yaml"
6+
PREFIX=rotatessh
7+
8+
cd lambda/
9+
pip install -r requirements.txt -t "$PWD" --upgrade
10+
cd ..
11+
aws cloudformation package --region $REGION --template-file secretsmanager_rotate_ssh_keys.template --s3-bucket $S3Bucket --s3-prefix $PREFIX --output-template-file $FILE
12+
aws cloudformation deploy --region $REGION --template-file $FILE --stack-name RotateSSH --capabilities CAPABILITY_NAMED_IAM

lambda/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
paramiko

lambda/rotate.py

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
# Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: MIT-0
3+
import boto3
4+
import logging
5+
import os
6+
import json
7+
import re
8+
9+
import ssh
10+
from ssm import SSM
11+
12+
# Top-level element names for JSON structure in SecretString
13+
PUBLIC_KEY ='PublicKey'
14+
PRIVATE_KEY = 'PrivateKey'
15+
16+
# Global environment variables
17+
USERNAME = os.environ['USERNAME']
18+
TARGETS = ""
19+
20+
def lambda_handler(event, context):
21+
"""Secrets Manager Rotation Template
22+
23+
This is a template for creating an AWS Secrets Manager rotation lambda
24+
25+
Args:
26+
event (dict): Lambda dictionary of event parameters. These keys must include the following:
27+
- SecretId: The secret ARN or identifier
28+
- ClientRequestToken: The ClientRequestToken of the secret version
29+
- Step: The rotation step (one of createSecret, setSecret, testSecret, or finishSecret)
30+
31+
context (LambdaContext): The Lambda runtime information
32+
33+
Raises:
34+
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
35+
36+
ValueError: If the secret is not properly configured for rotation
37+
38+
KeyError: If the event parameters do not contain the expected keys
39+
40+
"""
41+
print(json.dumps(event))
42+
tag_name = os.environ['TAGNAME']
43+
tag_value = os.environ['TAGVALUE']
44+
45+
global TARGETS
46+
TARGETS = [
47+
{
48+
"Key" : "tag:" + tag_name,
49+
"Values" : [
50+
tag_value
51+
]
52+
}
53+
]
54+
55+
print(json.dumps(TARGETS))
56+
arn = event['SecretId']
57+
token = event['ClientRequestToken']
58+
step = event['Step']
59+
60+
# Setup the client
61+
service_client = boto3.client('secretsmanager', endpoint_url=os.environ['SECRETS_MANAGER_ENDPOINT'])
62+
63+
# Make sure the version is staged correctly
64+
metadata = service_client.describe_secret(SecretId=arn)
65+
if not metadata['RotationEnabled']:
66+
print("Secret %s is not enabled for rotation." % arn)
67+
raise ValueError("Secret %s is not enabled for rotation." % arn)
68+
versions = metadata['VersionIdsToStages']
69+
if token not in versions:
70+
print("Secret version %s has no stage for rotation of secret %s." % (token, arn))
71+
raise ValueError("Secret version %s has no stage for rotation of secret %s." % (token, arn))
72+
if "AWSCURRENT" in versions[token]:
73+
print("Secret version %s already set as AWSCURRENT for secret %s." % (token, arn))
74+
return
75+
elif "AWSPENDING" not in versions[token]:
76+
print("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn))
77+
raise ValueError("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn))
78+
79+
if step == "createSecret":
80+
create_secret(service_client, arn, token, context)
81+
82+
elif step == "setSecret":
83+
set_secret(service_client, arn, token, context)
84+
85+
elif step == "testSecret":
86+
test_secret(service_client, arn, token, context)
87+
88+
elif step == "finishSecret":
89+
finish_secret(service_client, arn, token, context)
90+
91+
else:
92+
raise ValueError("Invalid step parameter")
93+
94+
95+
def create_secret(service_client, arn, token, context):
96+
"""Create the secret
97+
98+
This method first checks for the existence of a secret for the passed in token. If one does not exist, it will generate a
99+
new secret and put it with the passed in token.
100+
101+
Args:
102+
service_client (client): The secrets manager service client
103+
104+
arn (string): The secret ARN or other identifier
105+
106+
token (string): The ClientRequestToken associated with the secret version
107+
108+
Raises:
109+
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
110+
111+
"""
112+
# Make sure the current secret exists
113+
current_dict = get_secret_dict(service_client, arn, "AWSCURRENT")
114+
115+
# Now try to get the secret version, if that fails, put a new secret
116+
try:
117+
service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage="AWSPENDING")
118+
print("createSecret: Successfully retrieved secret for %s." % arn)
119+
except service_client.exceptions.ResourceNotFoundException:
120+
121+
# generate a key-pair
122+
print("createSecret: Generating a key pair with token %s." % (token))
123+
[priv, pub] = ssh.generate_key_pair(token)
124+
125+
current_dict[PRIVATE_KEY] = priv
126+
current_dict[PUBLIC_KEY] = pub
127+
128+
secret_string = json.dumps(current_dict)
129+
130+
# save the key-pair
131+
service_client.put_secret_value(SecretId=arn, ClientRequestToken=token, SecretString=secret_string, VersionStages=['AWSPENDING'])
132+
print("createSecret: Successfully put secret for ARN %s and version %s." % (arn, token))
133+
134+
135+
def set_secret(service_client, arn, token, context):
136+
"""Set the secret
137+
138+
This method should set the AWSPENDING secret in the service that the secret belongs to. For example, if the secret is a database
139+
credential, this method should take the value of the AWSPENDING secret and set the user's password to this value in the database.
140+
141+
Args:
142+
service_client (client): The secrets manager service client
143+
144+
arn (string): The secret ARN or other identifier
145+
146+
token (string): The ClientRequestToken associated with the secret version
147+
148+
"""
149+
# This is where the secret should be set in the service
150+
pending = service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage="AWSPENDING")
151+
152+
pending_version = pending['VersionId']
153+
154+
pending_dict = get_secret_dict(service_client, arn, "AWSPENDING")
155+
156+
ssm = SSM(context, TARGETS, USERNAME)
157+
158+
print("setSecret: Invoking Systems Manager to add the new public key with token %s." % pending_version)
159+
command_id = ssm.add_public_key(pending_dict[PUBLIC_KEY], pending_version)
160+
print("setSecret: Waiting for Systems Manager command %s to complete." % (command_id))
161+
ssm.wait_completion(command_id)
162+
print("setSecret: Systems Manager command %s completed successfully." % (command_id))
163+
164+
165+
def test_secret(service_client, arn, token, context):
166+
"""Test the secret
167+
168+
This method should validate that the AWSPENDING secret works in the service that the secret belongs to. For example, if the secret
169+
is a database credential, this method should validate that the user can login with the password in AWSPENDING and that the user has
170+
all of the expected permissions against the database.
171+
172+
Args:
173+
service_client (client): The secrets manager service client
174+
175+
arn (string): The secret ARN or other identifier
176+
177+
token (string): The ClientRequestToken associated with the secret version
178+
179+
"""
180+
command = 'hostname'
181+
pending_dict = get_secret_dict(service_client, arn, "AWSPENDING")
182+
print("testSecret: getting instance IDs for version %s" % (token))
183+
ssm = SSM(context, TARGETS, USERNAME)
184+
ip_addresses = ssm.get_addrs_for_add_key(token)
185+
186+
print("testSecret: Performing SSH test by invoking command '%s'." % (command))
187+
ssh.run_command(ip_addresses, USERNAME, pending_dict[PRIVATE_KEY], command)
188+
189+
def finish_secret(service_client, arn, token, context):
190+
"""Finish the secret
191+
192+
This method finalizes the rotation process by marking the secret version passed in as the AWSCURRENT secret.
193+
194+
Args:
195+
service_client (client): The secrets manager service client
196+
197+
arn (string): The secret ARN or other identifier
198+
199+
token (string): The ClientRequestToken associated with the secret version
200+
201+
Raises:
202+
ResourceNotFoundException: If the secret with the specified arn does not exist
203+
204+
"""
205+
# First describe the secret to get the current version
206+
metadata = service_client.describe_secret(SecretId=arn)
207+
208+
new_version = token
209+
current_version = None
210+
for version in metadata["VersionIdsToStages"]:
211+
if "AWSCURRENT" in metadata["VersionIdsToStages"][version]:
212+
if version == token:
213+
# The correct version is already marked as current, return
214+
print("finishSecret: Version %s already marked as AWSCURRENT for %s" % (version, arn))
215+
return
216+
current_version = version
217+
break
218+
219+
# Finalize by staging the secret version current
220+
service_client.update_secret_version_stage(SecretId=arn, VersionStage="AWSCURRENT", MoveToVersionId=new_version, RemoveFromVersionId=current_version)
221+
print("finishSecret: Successfully set AWSCURRENT stage to version %s for secret %s." % (new_version, arn))
222+
223+
# after change above:
224+
prior_version = current_version
225+
226+
new_dict = get_secret_dict(service_client, arn, "AWSCURRENT")
227+
228+
ssm = SSM(context, TARGETS, USERNAME)
229+
230+
print("finishSecret: Invoking Systems Manager to delete the old public key with token %s." % (prior_version))
231+
command_id = ssm.del_public_key(prior_version)
232+
print("finishSecret: Waiting for Systems Manager command %s to complete." % (command_id))
233+
ssm.wait_completion(command_id)
234+
print("finishSecret: Systems Manager command %s completed successfully." % (command_id))
235+
236+
def get_secret_dict(service_client, arn, stage, token=None):
237+
"""Gets the secret dictionary corresponding for the secret arn, stage, and token
238+
239+
This helper function gets credentials for the arn and stage passed in and returns the dictionary by parsing the JSON string
240+
241+
Args:
242+
service_client (client): The secrets manager service client
243+
244+
arn (string): The secret ARN or other identifier
245+
246+
token (string): The ClientRequestToken associated with the secret version, or None if no validation is desired
247+
248+
stage (string): The stage identifying the secret version
249+
250+
Returns:
251+
SecretDictionary: Secret dictionary
252+
253+
Raises:
254+
ResourceNotFoundException: If the secret with the specified arn and stage does not exist
255+
256+
ValueError: If the secret is not valid JSON
257+
258+
KeyError: If the secret json does not contain the expected keys
259+
260+
"""
261+
262+
# Only do VersionId validation against the stage if a token is passed in
263+
if token:
264+
secret = service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage=stage)
265+
else:
266+
secret = service_client.get_secret_value(SecretId=arn, VersionStage=stage)
267+
plaintext = secret['SecretString']
268+
secret_dict = json.loads(plaintext)
269+
270+
# Parse and return the secret JSON string
271+
return secret_dict
272+

lambda/ssh.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: MIT-0
3+
from cryptography.hazmat.primitives import serialization as crypto_serialization
4+
from cryptography.hazmat.primitives.asymmetric import rsa
5+
from cryptography.hazmat.backends import default_backend as crypto_default_backend
6+
import paramiko
7+
import io
8+
import boto3
9+
import json
10+
11+
class InvalidParameterError(Exception):
12+
def __init__(self, message):
13+
self.message = message
14+
15+
class SSHCommandError(Exception):
16+
def __init__(self, ip, nested_exception):
17+
self.nested_exception = nested_exception
18+
self.ip = ip
19+
20+
21+
def generate_key_pair(comment):
22+
key = rsa.generate_private_key(
23+
backend=crypto_default_backend(),
24+
public_exponent=65537,
25+
key_size=2048
26+
)
27+
private_key = key.private_bytes(
28+
crypto_serialization.Encoding.PEM,
29+
crypto_serialization.PrivateFormat.TraditionalOpenSSL,
30+
crypto_serialization.NoEncryption())
31+
public_key = key.public_key().public_bytes(
32+
crypto_serialization.Encoding.OpenSSH,
33+
crypto_serialization.PublicFormat.OpenSSH
34+
)
35+
36+
private_key_str = private_key.decode('utf-8')
37+
public_key_str = public_key.decode('utf-8') + " " + comment
38+
39+
return [private_key_str, public_key_str]
40+
41+
def run_command(ip_addresses, username, private_key, command):
42+
private_key_str = io.StringIO()
43+
private_key_str.write(private_key)
44+
private_key_str.seek(0)
45+
46+
key = paramiko.RSAKey.from_private_key(private_key_str)
47+
48+
client = paramiko.client.SSHClient()
49+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
50+
51+
# connect and execute the command
52+
for ip in ip_addresses:
53+
try:
54+
print("SSH: Connecting to %s as user %s." % (ip, username))
55+
client.connect(ip,
56+
username = username,
57+
pkey = key,
58+
look_for_keys = False)
59+
stdin, stdout, stderr = client.exec_command(command)
60+
print("SSH: Successfully executed command '%s' on %s as user %s." % (command, ip, username))
61+
finally:
62+
client.close()

0 commit comments

Comments
 (0)