diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/aws/secrets_client_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/aws/secrets_client_spec.cr new file mode 100644 index 00000000..114674d0 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/aws/secrets_client_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/aws/signer_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/aws/signer_spec.cr new file mode 100644 index 00000000..4e80e61d --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/aws/signer_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/rotators/aws_secrets_spec.cr b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/rotators/aws_secrets_spec.cr new file mode 100644 index 00000000..0f2e591d --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/spec/unit/rotators/aws_secrets_spec.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/aws/secrets_client.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/aws/secrets_client.cr new file mode 100644 index 00000000..c106ca21 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/aws/secrets_client.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/aws/signer.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/aws/signer.cr new file mode 100644 index 00000000..5e4c8bdd --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/aws/signer.cr @@ -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 diff --git a/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/aws_secrets.cr b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/aws_secrets.cr new file mode 100644 index 00000000..cf2fc273 --- /dev/null +++ b/PROJECTS/intermediate/credential-rotation-enforcer/src/cre/rotators/aws_secrets.cr @@ -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