feat(rotators): AWS Secrets Manager rotator with SigV4 signer + secrets client

SigV4 implementation per AWS reference: canonical request -> string-to-sign
-> HMAC-derived signing key (kSecret -> kDate -> kRegion -> kService ->
kSigning) -> HMAC-SHA256 signature. Includes session token (STS) support.

SecretsManagerClient wraps PutSecretValue, GetSecretValue,
UpdateSecretVersionStage with custom endpoint support (LocalStack).
AWS API errors surface as AwsApiError carrying HTTP status + AWS __type.

AwsSecretsRotator implements 4-step contract:
- generate: PutSecretValue with AWSPENDING stage, captures version_id
- apply: no-op (PutSecretValue already exposed it)
- verify: GetSecretValue by version_id, byte-equal SecretString check
- commit: UpdateSecretVersionStage move AWSCURRENT to new + remove from old
- rollback_apply: UpdateSecretVersionStage remove AWSPENDING from new version

13 unit specs verify SigV4 idempotence + format, client methods (with
WebMock), rotator's full 4-step path, verify-mismatch, rollback_apply,
and can_rotate? gating.
This commit is contained in:
CarterPerez-dev 2026-04-29 01:02:40 -04:00
parent bad50daad2
commit 7e23f58fbb
6 changed files with 580 additions and 0 deletions

View File

@ -0,0 +1,76 @@
# ===================
# ©AngelaMos | 2026
# secrets_client_spec.cr
# ===================
require "../../spec_helper"
require "webmock"
require "../../../src/cre/aws/secrets_client"
WebMock.allow_net_connect = false
private def fresh_client : CRE::Aws::SecretsManagerClient
CRE::Aws::SecretsManagerClient.new(
access_key_id: "AKID",
secret_access_key: "secret",
region: "us-east-1",
)
end
describe CRE::Aws::SecretsManagerClient do
before_each { WebMock.reset }
it "calls PutSecretValue and returns version_id" do
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
.with(headers: {"X-Amz-Target" => "secretsmanager.PutSecretValue"})
.to_return(body: %({"VersionId":"v-123","ARN":"arn:fake"}))
version = fresh_client.put_secret_value("my-secret", "newpassword")
version.version_id.should eq "v-123"
version.secret_string.should eq "newpassword"
end
it "calls GetSecretValue and returns the value" do
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
.with(headers: {"X-Amz-Target" => "secretsmanager.GetSecretValue"})
.to_return(body: %({"VersionId":"v-1","SecretString":"theval"}))
sv = fresh_client.get_secret_value("my-secret")
sv.version_id.should eq "v-1"
sv.secret_string.should eq "theval"
end
it "calls UpdateSecretVersionStage" do
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
.with(headers: {"X-Amz-Target" => "secretsmanager.UpdateSecretVersionStage"})
.to_return(body: "{}")
fresh_client.update_secret_version_stage(
"my-secret",
"AWSCURRENT",
move_to_version_id: "v2",
remove_from_version_id: "v1",
)
end
it "raises AwsApiError on HTTP non-2xx" do
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
.to_return(status: 400, body: %({"__type":"ResourceNotFoundException","message":"nope"}))
expect_raises(CRE::Aws::AwsApiError) do
fresh_client.get_secret_value("missing")
end
end
it "respects custom endpoint (LocalStack)" do
WebMock.stub(:post, "http://localstack-test/")
.with(headers: {"X-Amz-Target" => "secretsmanager.PutSecretValue"})
.to_return(body: %({"VersionId":"local-v1"}))
client = CRE::Aws::SecretsManagerClient.new(
access_key_id: "test", secret_access_key: "test",
region: "us-east-1", endpoint: "http://localstack-test:4566/",
)
client.put_secret_value("any", "val").version_id.should eq "local-v1"
end
end

View File

@ -0,0 +1,69 @@
# ===================
# ©AngelaMos | 2026
# signer_spec.cr
# ===================
require "../../spec_helper"
require "../../../src/cre/aws/signer"
# Reference SigV4 vector from AWS docs:
# https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-test-suite.html
# Using the well-known "get-vanilla" test vector adapted for our API.
describe CRE::Aws::SigV4 do
it "signs a request idempotently for the same time" do
signer = CRE::Aws::SigV4.new(
access_key_id: "AKIDEXAMPLE",
secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
region: "us-east-1",
service: "secretsmanager",
)
headers1 = HTTP::Headers{"Content-Type" => "application/x-amz-json-1.1"}
headers2 = HTTP::Headers{"Content-Type" => "application/x-amz-json-1.1"}
uri = URI.parse("https://secretsmanager.us-east-1.amazonaws.com/")
body = %({"SecretId":"test"})
fixed_time = Time.utc(2026, 4, 28, 12, 0, 0)
signer.sign("POST", uri, headers1, body, fixed_time)
signer.sign("POST", uri, headers2, body, fixed_time)
headers1["Authorization"].should eq headers2["Authorization"]
end
it "produces a well-formed Authorization header" do
signer = CRE::Aws::SigV4.new("AKID", "secret", "us-east-1", "secretsmanager")
h = HTTP::Headers{"Content-Type" => "application/x-amz-json-1.1"}
signer.sign("POST", URI.parse("https://secretsmanager.us-east-1.amazonaws.com/"), h, "{}")
h["Authorization"].should match(/^AWS4-HMAC-SHA256 Credential=AKID\/\d{8}\/us-east-1\/secretsmanager\/aws4_request, SignedHeaders=[^,]+, Signature=[a-f0-9]{64}$/)
h["X-Amz-Date"].should match(/^\d{8}T\d{6}Z$/)
h["X-Amz-Content-SHA256"].size.should eq 64
h["Host"].should eq "secretsmanager.us-east-1.amazonaws.com"
end
it "different bodies produce different signatures" do
signer = CRE::Aws::SigV4.new("AKID", "secret", "us-east-1", "secretsmanager")
h1 = HTTP::Headers{"Content-Type" => "application/x-amz-json-1.1"}
h2 = HTTP::Headers{"Content-Type" => "application/x-amz-json-1.1"}
uri = URI.parse("https://secretsmanager.us-east-1.amazonaws.com/")
fixed = Time.utc(2026, 1, 1)
signer.sign("POST", uri, h1, %({"a":1}), fixed)
signer.sign("POST", uri, h2, %({"a":2}), fixed)
h1["Authorization"].should_not eq h2["Authorization"]
end
it "includes session token header when provided" do
signer = CRE::Aws::SigV4.new(
access_key_id: "AKID",
secret_access_key: "secret",
region: "us-east-1",
service: "secretsmanager",
session_token: "FAKETOKEN",
)
h = HTTP::Headers{"Content-Type" => "application/x-amz-json-1.1"}
signer.sign("POST", URI.parse("https://secretsmanager.us-east-1.amazonaws.com/"), h, "{}")
h["X-Amz-Security-Token"].should eq "FAKETOKEN"
h["Authorization"].should contain "x-amz-security-token"
end
end

View File

@ -0,0 +1,109 @@
# ===================
# ©AngelaMos | 2026
# aws_secrets_spec.cr
# ===================
require "../../spec_helper"
require "webmock"
require "../../../src/cre/rotators/aws_secrets"
WebMock.allow_net_connect = false
private def aws_credential
CRE::Domain::Credential.new(
id: UUID.random,
external_id: "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-db-prod",
kind: CRE::Domain::CredentialKind::AwsSecretsmgr,
name: "my-db-prod",
tags: {
"secret_arn" => "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-db-prod",
"value_length" => "16",
} of String => String,
)
end
private def fresh_client : CRE::Aws::SecretsManagerClient
CRE::Aws::SecretsManagerClient.new(
access_key_id: "AKID",
secret_access_key: "secret",
region: "us-east-1",
)
end
describe CRE::Rotators::AwsSecretsRotator do
before_each { WebMock.reset }
it "executes the full 4-step contract" do
cred = aws_credential
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
.with(headers: {"X-Amz-Target" => "secretsmanager.PutSecretValue"})
.to_return(body: %({"VersionId":"new-v"}))
rotator = CRE::Rotators::AwsSecretsRotator.new(fresh_client)
rotator.can_rotate?(cred).should be_true
new_secret = rotator.generate(cred)
new_secret.metadata["version_id"].should eq "new-v"
new_secret.metadata["secret_arn"].should eq cred.tag("secret_arn")
rotator.apply(cred, new_secret) # no-op
expected_value = String.new(new_secret.ciphertext)
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
.with(headers: {"X-Amz-Target" => "secretsmanager.GetSecretValue"})
.to_return(body: %({"VersionId":"new-v","SecretString":#{expected_value.to_json}}))
rotator.verify(cred, new_secret).should be_true
# Commit: GetSecretValue (current) + UpdateSecretVersionStage (move) + UpdateSecretVersionStage (remove pending)
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
.with(headers: {"X-Amz-Target" => "secretsmanager.GetSecretValue"})
.to_return(body: %({"VersionId":"old-v","SecretString":"oldval"}))
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
.with(headers: {"X-Amz-Target" => "secretsmanager.UpdateSecretVersionStage"})
.to_return(body: "{}")
rotator.commit(cred, new_secret)
end
it "verify returns false on retrieved-value mismatch" do
cred = aws_credential
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
.with(headers: {"X-Amz-Target" => "secretsmanager.GetSecretValue"})
.to_return(body: %({"VersionId":"v","SecretString":"different"}))
rotator = CRE::Rotators::AwsSecretsRotator.new(fresh_client)
s = CRE::Domain::NewSecret.new(
ciphertext: "expected".to_slice,
metadata: {"version_id" => "v", "secret_arn" => cred.tag("secret_arn").not_nil!},
)
rotator.verify(cred, s).should be_false
end
it "rollback_apply removes AWSPENDING stage" do
cred = aws_credential
rotator = CRE::Rotators::AwsSecretsRotator.new(fresh_client)
s = CRE::Domain::NewSecret.new(
ciphertext: "x".to_slice,
metadata: {"version_id" => "v", "secret_arn" => cred.tag("secret_arn").not_nil!},
)
called = false
WebMock.stub(:post, "https://secretsmanager.us-east-1.amazonaws.com/")
.with(headers: {"X-Amz-Target" => "secretsmanager.UpdateSecretVersionStage"})
.to_return { |_req| called = true; HTTP::Client::Response.new(200, body: "{}") }
rotator.rollback_apply(cred, s)
called.should be_true
end
it "can_rotate? returns false without secret_arn tag" do
bad = CRE::Domain::Credential.new(
id: UUID.random, external_id: "x",
kind: CRE::Domain::CredentialKind::AwsSecretsmgr,
name: "x", tags: {} of String => String,
)
CRE::Rotators::AwsSecretsRotator.new(fresh_client).can_rotate?(bad).should be_false
end
end

View File

@ -0,0 +1,97 @@
# ===================
# ©AngelaMos | 2026
# secrets_client.cr
# ===================
require "http/client"
require "json"
require "uuid"
require "./signer"
module CRE::Aws
class AwsApiError < Exception
getter status : Int32
getter aws_code : String?
def initialize(message : String, @status : Int32, @aws_code : String? = nil)
super(message)
end
end
class SecretsManagerClient
AWSCURRENT = "AWSCURRENT"
AWSPENDING = "AWSPENDING"
AWSPREVIOUS = "AWSPREVIOUS"
record SecretVersion, version_id : String, secret_string : String?
def initialize(
@access_key_id : String,
@secret_access_key : String,
@region : String,
@endpoint : String? = nil,
@session_token : String? = nil,
)
@signer = SigV4.new(@access_key_id, @secret_access_key, @region, "secretsmanager", @session_token)
end
# Stages a new secret version with the AWSPENDING label.
def put_secret_value(secret_id : String, secret_string : String, version_stages : Array(String) = [AWSPENDING]) : SecretVersion
payload = {
"SecretId" => secret_id,
"SecretString" => secret_string,
"ClientRequestToken" => UUID.random.to_s,
"VersionStages" => version_stages,
}.to_json
json = call("PutSecretValue", payload)
SecretVersion.new(json["VersionId"].as_s, secret_string)
end
def get_secret_value(secret_id : String, version_id : String? = nil, version_stage : String? = nil) : SecretVersion
payload_h = {"SecretId" => secret_id}
payload_h["VersionId"] = version_id if version_id
payload_h["VersionStage"] = version_stage if version_stage
payload = payload_h.to_json
json = call("GetSecretValue", payload)
SecretVersion.new(
json["VersionId"].as_s,
json["SecretString"]?.try(&.as_s),
)
end
def update_secret_version_stage(secret_id : String, version_stage : String, move_to_version_id : String? = nil, remove_from_version_id : String? = nil) : Nil
payload_h = {
"SecretId" => secret_id,
"VersionStage" => version_stage,
}
payload_h["MoveToVersionId"] = move_to_version_id if move_to_version_id
payload_h["RemoveFromVersionId"] = remove_from_version_id if remove_from_version_id
call("UpdateSecretVersionStage", payload_h.to_json)
end
private def call(action : String, body : String) : JSON::Any
uri = URI.parse(@endpoint || "https://secretsmanager.#{@region}.amazonaws.com/")
headers = HTTP::Headers{
"Content-Type" => "application/x-amz-json-1.1",
"X-Amz-Target" => "secretsmanager.#{action}",
}
@signer.sign("POST", uri, headers, body)
response = HTTP::Client.post(uri.to_s, headers: headers, body: body)
raise AwsApiError.new(error_message(response), response.status_code, error_code(response)) unless response.status_code < 300
response.body.empty? ? JSON::Any.new(Hash(String, JSON::Any).new) : JSON.parse(response.body)
end
private def error_message(resp : HTTP::Client::Response) : String
"AWS #{resp.status_code}: #{resp.body[0, 200]?}"
end
private def error_code(resp : HTTP::Client::Response) : String?
return nil if resp.body.empty?
JSON.parse(resp.body)["__type"]?.try(&.as_s)
rescue
nil
end
end
end

View File

@ -0,0 +1,122 @@
# ===================
# ©AngelaMos | 2026
# signer.cr
# ===================
require "openssl/digest"
require "openssl/hmac"
require "uri"
require "http/headers"
module CRE::Aws
class SignerError < Exception; end
# SigV4 signer per AWS reference:
# https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html
class SigV4
ALGORITHM = "AWS4-HMAC-SHA256"
def initialize(
@access_key_id : String,
@secret_access_key : String,
@region : String,
@service : String,
@session_token : String? = nil,
)
end
record SignedRequest, headers : HTTP::Headers, body : String
# Returns the signed Authorization header value plus modified headers.
# Mutates the headers map in-place to add 'X-Amz-Date', 'Host',
# 'X-Amz-Content-SHA256', 'X-Amz-Security-Token' (if any), 'Authorization'.
def sign(method : String, uri : URI, headers : HTTP::Headers, body : String, now : Time = Time.utc) : Nil
amz_date = now.to_s("%Y%m%dT%H%M%SZ")
date_stamp = now.to_s("%Y%m%d")
headers["Host"] = uri.host.not_nil!
headers["X-Amz-Date"] = amz_date
headers["X-Amz-Security-Token"] = @session_token.not_nil! if @session_token
payload_hash = sha256_hex(body)
headers["X-Amz-Content-SHA256"] = payload_hash
canonical_uri = canonical_path(uri.path.empty? ? "/" : uri.path)
canonical_querystring = canonical_query(uri.query)
canonical_headers, signed_headers = canonical_headers_and_list(headers)
canonical_request = String.build do |s|
s << method.upcase << '\n'
s << canonical_uri << '\n'
s << canonical_querystring << '\n'
s << canonical_headers << '\n'
s << signed_headers << '\n'
s << payload_hash
end
credential_scope = "#{date_stamp}/#{@region}/#{@service}/aws4_request"
string_to_sign = String.build do |s|
s << ALGORITHM << '\n'
s << amz_date << '\n'
s << credential_scope << '\n'
s << sha256_hex(canonical_request)
end
signing_key = derive_signing_key(date_stamp)
signature = OpenSSL::HMAC.hexdigest(:sha256, signing_key, string_to_sign)
auth = String.build do |s|
s << ALGORITHM << ' '
s << "Credential=" << @access_key_id << '/' << credential_scope << ", "
s << "SignedHeaders=" << signed_headers << ", "
s << "Signature=" << signature
end
headers["Authorization"] = auth
end
private def canonical_path(path : String) : String
# AWS: encode each path segment per RFC 3986; '/' kept literal; double-encode for non-S3 services
path.split('/', remove_empty: false).map { |seg| URI.encode_path_segment(seg) }.join('/')
end
private def canonical_query(query : String?) : String
return "" unless query && !query.empty?
params = [] of {String, String}
query.split('&') do |pair|
eq = pair.index('=')
if eq
k = URI.decode_www_form(pair[0, eq])
v = URI.decode_www_form(pair[eq + 1..])
else
k = URI.decode_www_form(pair)
v = ""
end
params << {k, v}
end
params.sort! { |a, b| a[0] <=> b[0] }
params.map { |k, v| "#{URI.encode_path_segment(k)}=#{URI.encode_path_segment(v)}" }.join('&')
end
private def canonical_headers_and_list(headers : HTTP::Headers) : {String, String}
sorted = headers.to_a.map { |name, values|
{name.downcase, values.first.strip.gsub(/\s+/, " ")}
}.sort_by { |entry| entry[0] }
canonical = sorted.map { |k, v| "#{k}:#{v}\n" }.join
list = sorted.map(&.[0]).join(';')
{canonical, list}
end
private def derive_signing_key(date_stamp : String) : Bytes
k_date = OpenSSL::HMAC.digest(:sha256, "AWS4#{@secret_access_key}".to_slice, date_stamp.to_slice)
k_region = OpenSSL::HMAC.digest(:sha256, k_date, @region.to_slice)
k_service = OpenSSL::HMAC.digest(:sha256, k_region, @service.to_slice)
OpenSSL::HMAC.digest(:sha256, k_service, "aws4_request".to_slice)
end
private def sha256_hex(data : String) : String
d = OpenSSL::Digest.new("SHA256")
d.update(data)
d.hexfinal
end
end
end

View File

@ -0,0 +1,107 @@
# ===================
# ©AngelaMos | 2026
# aws_secrets.cr
# ===================
require "../aws/secrets_client"
require "../crypto/random"
require "./rotator"
module CRE::Rotators
# AwsSecretsRotator implements the 4-step rotation contract against AWS Secrets
# Manager, mirroring the standard Rotation Lambda template:
#
# 1. generate -> PutSecretValue with AWSPENDING label, returns version_id
# 2. apply -> no-op (PutSecretValue exposed it; AWSPENDING already attached)
# 3. verify -> GetSecretValue by version_id, confirm decoded value matches
# 4. commit -> UpdateSecretVersionStage move AWSCURRENT to new, AWSPREVIOUS to old
# rollback_apply -> remove AWSPENDING stage from the new version
#
# Required Credential.tags:
# "secret_arn" - the AWS Secrets Manager ARN or name
# "value_length" - optional, bytes of random payload (default 32)
class AwsSecretsRotator < Rotator
register_as :aws_secretsmgr
DEFAULT_BYTES = 32
def initialize(@client : Aws::SecretsManagerClient)
end
def kind : Symbol
:aws_secretsmgr
end
def can_rotate?(c : Domain::Credential) : Bool
c.kind.aws_secretsmgr? && !c.tag("secret_arn").nil?
end
def generate(c : Domain::Credential) : Domain::NewSecret
raise RotatorError.new("missing 'secret_arn' tag") unless can_rotate?(c)
bytes = (c.tag("value_length") || DEFAULT_BYTES.to_s).to_i
raw = CRE::Crypto::Random.bytes(bytes)
new_value = Base64.urlsafe_encode(raw, padding: false)
version = @client.put_secret_value(c.tag("secret_arn").not_nil!, new_value)
Domain::NewSecret.new(
ciphertext: new_value.to_slice,
metadata: {
"version_id" => version.version_id,
"secret_arn" => c.tag("secret_arn").not_nil!,
},
)
end
def apply(c : Domain::Credential, s : Domain::NewSecret) : Nil
_ = {c, s}
# No-op: PutSecretValue with AWSPENDING already made the new version available.
end
def verify(c : Domain::Credential, s : Domain::NewSecret) : Bool
version_id = s.metadata["version_id"]
secret_arn = s.metadata["secret_arn"]
retrieved = @client.get_secret_value(secret_arn, version_id: version_id)
expected = String.new(s.ciphertext)
retrieved.secret_string == expected
rescue
false
end
def commit(c : Domain::Credential, s : Domain::NewSecret) : Nil
_ = c
version_id = s.metadata["version_id"]
secret_arn = s.metadata["secret_arn"]
# Find the current version_id
current = @client.get_secret_value(secret_arn, version_stage: Aws::SecretsManagerClient::AWSCURRENT)
old_version_id = current.version_id
# Move AWSCURRENT to new, removing it from old (atomic per AWS API)
@client.update_secret_version_stage(
secret_arn,
Aws::SecretsManagerClient::AWSCURRENT,
move_to_version_id: version_id,
remove_from_version_id: old_version_id,
)
# Remove AWSPENDING from new version (it's now AWSCURRENT)
@client.update_secret_version_stage(
secret_arn,
Aws::SecretsManagerClient::AWSPENDING,
remove_from_version_id: version_id,
) rescue nil
end
def rollback_apply(c : Domain::Credential, s : Domain::NewSecret) : Nil
_ = c
version_id = s.metadata["version_id"]?
secret_arn = s.metadata["secret_arn"]?
return unless version_id && secret_arn
@client.update_secret_version_stage(
secret_arn,
Aws::SecretsManagerClient::AWSPENDING,
remove_from_version_id: version_id,
) rescue nil
end
end
end