Skip to content

Instantly share code, notes, and snippets.

@santrancisco
Created March 25, 2026 07:36
Show Gist options
  • Select an option

  • Save santrancisco/5d98d4ba5da30323a06653a3c7d6f9ea to your computer and use it in GitHub Desktop.

Select an option

Save santrancisco/5d98d4ba5da30323a06653a3c7d6f9ea to your computer and use it in GitHub Desktop.
fileshare s3
#!/usr/bin/env bash
# =============================================================================
# kms_s3_upload_utils.sh
# Utilities for KMS public-key retrieval, S3 presigned URL generation,
# and envelope-encrypted file upload.
# =============================================================================
set -euo pipefail
# ---------------------------------------------------------------------------
# PART 1 — generate_upload_credentials
# Pulls the public key from a KMS asymmetric key and generates a presigned
# S3 PUT URL, then returns both as a JSON payload.
#
# Usage:
# generate_upload_credentials <kms_key_arn> <s3_bucket> <s3_object_key> [ttl_seconds]
#
# Example:
# generate_upload_credentials \
# "arn:aws:kms:ap-southeast-1:123456789012:key/mrk-abc123" \
# "my-secure-bucket" \
# "uploads/payload.enc" \
# 3600
# ---------------------------------------------------------------------------
generate_upload_credentials() {
local kms_arn="${1:?KMS ARN required}"
local bucket="${2:?S3 bucket required}"
local object_key="${3:?S3 object key required}"
local ttl="${4:-3600}" # default 1 hour
# --- 1a. Fetch the public key from KMS -----------------------------------
local kms_response
kms_response=$(aws kms get-public-key \
--key-id "$kms_arn" \
--output json)
# The public key is returned as a base64-encoded DER blob.
local pubkey_b64
pubkey_b64=$(echo "$kms_response" | jq -r '.PublicKey')
local key_spec
key_spec=$(echo "$kms_response" | jq -r '.KeySpec')
local key_usage
key_usage=$(echo "$kms_response" | jq -r '.KeyUsage')
# Convert base64-DER → PEM so openssl can consume it later.
local pubkey_pem
pubkey_pem=$(printf '%s\n' \
"-----BEGIN PUBLIC KEY-----" \
"$(echo "$pubkey_b64" | fold -w 64)" \
"-----END PUBLIC KEY-----")
# --- 1b. Generate presigned S3 PUT URL -----------------------------------
# Resolve bucket region: env var takes priority, otherwise auto-detect.
local bucket_region="${S3_BUCKET_REGION:-}"
if [[ -z "$bucket_region" ]]; then
bucket_region=$(aws s3api get-bucket-location \
--bucket "$bucket" \
--query 'LocationConstraint' \
--output text)
# us-east-1 returns "None" from get-bucket-location
[[ "$bucket_region" == "None" ]] && bucket_region="us-east-1"
# echo "[info] Auto-detected bucket region: $bucket_region (set S3_BUCKET_REGION to override)"
fi
local presigned_url
presigned_url=$(AWS_DEFAULT_REGION="$bucket_region" aws s3 presign \
"s3://${bucket}/${object_key}" \
--expires-in "$ttl")
# --- 1c. Assemble JSON response ------------------------------------------
jq -n \
--arg kms_arn "$kms_arn" \
--arg key_spec "$key_spec" \
--arg key_usage "$key_usage" \
--arg pubkey_pem "$pubkey_pem" \
--arg url "$presigned_url" \
--arg bucket "$bucket" \
--arg object_key "$object_key" \
--arg region "$bucket_region" \
--argjson ttl "$ttl" \
'{
kms_arn: $kms_arn,
key_spec: $key_spec,
key_usage: $key_usage,
public_key_pem: $pubkey_pem,
upload: {
presigned_url: $url,
bucket: $bucket,
object_key: $object_key,
region: $region,
expires_in_seconds: $ttl
}
}'
}
# ---------------------------------------------------------------------------
# PART 2 — encrypt_and_upload
# Reads the JSON produced by generate_upload_credentials, performs
# envelope encryption on a local file, and uploads the result to S3.
#
# Envelope encryption layout
# ┌─────────────────────────────────────────────┐
# │ envelope.enc │
# │ ├── encrypted_dek.bin (RSA-OAEP wrapped) │
# │ ├── iv.bin (AES-256-CBC IV) │
# │ └── ciphertext.bin (AES-256-CBC data) │
# └─────────────────────────────────────────────┘
# All three are tar'd into a single .enc.tar before upload.
#
# Usage:
# encrypt_and_upload <credentials_json_file> <plaintext_file>
#
# Example:
# encrypt_and_upload credentials.json secret_document.pdf
# ---------------------------------------------------------------------------
encrypt_and_upload() {
local creds_file="${1:?Credentials JSON file required}"
local plaintext_file="${2:?Plaintext file required}"
[[ -f "$creds_file" ]] || { echo "ERROR: credentials file not found: $creds_file"; exit 1; }
[[ -f "$plaintext_file" ]] || { echo "ERROR: plaintext file not found: $plaintext_file"; exit 1; }
# --- 2a. Parse credentials -----------------------------------------------
local pubkey_pem bucket object_key bucket_region
pubkey_pem=$(jq -r '.public_key_pem' "$creds_file")
bucket=$(jq -r '.upload.bucket' "$creds_file")
object_key=$(jq -r '.upload.object_key' "$creds_file")
bucket_region=$(jq -r '.upload.region' "$creds_file")
[[ "$pubkey_pem" != "null" ]] || { echo "ERROR: public_key_pem missing in credentials"; exit 1; }
[[ "$bucket" != "null" ]] || { echo "ERROR: upload.bucket missing in credentials"; exit 1; }
[[ "$object_key" != "null" ]] || { echo "ERROR: upload.object_key missing in credentials"; exit 1; }
[[ "$bucket_region" != "null" ]] || { echo "ERROR: upload.region missing in credentials"; exit 1; }
# --- 2b. Create a secure temp workspace ----------------------------------
local tmpdir=""
tmpdir=$(mktemp -d)
trap '[[ -n "$tmpdir" ]] && rm -rf "$tmpdir"' EXIT
local pem_file="$tmpdir/recipient_public.pem"
local dek_file="$tmpdir/dek.bin" # plaintext DEK (32 bytes)
local enc_dek_file="$tmpdir/encrypted_dek.bin"
local iv_file="$tmpdir/iv.bin" # AES IV (16 bytes)
local ciphertext_file="$tmpdir/ciphertext.bin"
local bundle_file="$tmpdir/envelope.enc.tar"
echo "$pubkey_pem" > "$pem_file"
# --- 2c. Generate a random 256-bit Data Encryption Key (DEK) + IV --------
echo "[1/5] Generating DEK and IV..."
openssl rand 32 > "$dek_file"
openssl rand 16 > "$iv_file"
# --- 2d. Wrap the DEK with the KMS RSA public key (RSA-OAEP SHA-256) -----
echo "[2/5] Encrypting DEK with KMS public key (RSA-OAEP)..."
openssl pkeyutl \
-encrypt \
-pubin \
-inkey "$pem_file" \
-in "$dek_file" \
-out "$enc_dek_file" \
-pkeyopt rsa_padding_mode:oaep \
-pkeyopt rsa_oaep_md:sha256 \
-pkeyopt rsa_mgf1_md:sha256
# --- 2e. Encrypt the plaintext file with AES-256-CBC ---------------------
echo "[3/5] Encrypting file with AES-256-CBC..."
openssl enc -aes-256-cbc -nosalt \
-in "$plaintext_file" \
-out "$ciphertext_file" \
-K "$(xxd -p -c 256 "$dek_file")" \
-iv "$(xxd -p -c 256 "$iv_file")"
# Wipe the plaintext DEK from disk immediately after use
openssl rand 32 > "$dek_file" && rm -f "$dek_file"
# --- 2f. Bundle all envelope parts into a single tar archive -------------
echo "[4/5] Bundling envelope..."
tar -cf "$bundle_file" \
-C "$tmpdir" \
encrypted_dek.bin iv.bin ciphertext.bin
local bundle_size
bundle_size=$(wc -c < "$bundle_file" | tr -d ' ')
echo " Bundle size: ${bundle_size} bytes"
# --- 2g. Upload using AWS CLI — avoids ALL presigned URL signature issues
# aws s3 cp handles SigV4 signing internally, so there's no risk of curl
# adding unexpected headers (Content-Type, Transfer-Encoding, etc.) that
# weren't included in the presigned signature.
echo "[5/5] Uploading to S3..."
if aws s3 cp "$bundle_file" "s3://${bucket}/${object_key}" \
--region "$bucket_region" \
--content-type "application/octet-stream"; then
echo ""
echo "✅ Upload successful"
echo " Object: s3://${bucket}/${object_key}"
else
echo "❌ Upload failed"
exit 1
fi
if [[ "$http_status" == "200" ]]; then
echo ""
echo "✅ Upload successful (HTTP $http_status)"
echo " Object: $(jq -r '.upload.bucket' "$creds_file")/$(jq -r '.upload.object_key' "$creds_file")"
else
echo "❌ Upload failed (HTTP $http_status)"
exit 1
fi
}
# ---------------------------------------------------------------------------
# PART 3 — download_and_decrypt
# Downloads the envelope bundle from S3, uses KMS to unwrap the DEK,
# then decrypts the payload with openssl.
#
# Usage:
# download_and_decrypt <kms_key_arn> <s3_bucket> <s3_object_key> <output_file>
#
# Example:
# download_and_decrypt \
# "arn:aws:kms:ap-southeast-1:123456789012:key/mrk-abc123" \
# "my-secure-bucket" \
# "uploads/payload.enc.tar" \
# decrypted_output.pdf
# ---------------------------------------------------------------------------
download_and_decrypt() {
local kms_arn="${1:?KMS ARN required}"
local bucket="${2:?S3 bucket required}"
local object_key="${3:?S3 object key required}"
local output_file="${4:?Output file path required}"
local tmpdir=""
tmpdir=$(mktemp -d)
trap '[[ -n "$tmpdir" ]] && rm -rf "$tmpdir"' EXIT
local bundle_file="$tmpdir/envelope.enc.tar"
local enc_dek_file="$tmpdir/encrypted_dek.bin"
local iv_file="$tmpdir/iv.bin"
local ciphertext_file="$tmpdir/ciphertext.bin"
local dek_file="$tmpdir/dek.bin"
# --- 3a. Download the bundle from S3 -------------------------------------
echo "[1/4] Downloading from S3..."
aws s3 cp "s3://${bucket}/${object_key}" "$bundle_file"
# --- 3b. Extract the three envelope components ---------------------------
echo "[2/4] Extracting envelope..."
tar -xf "$bundle_file" -C "$tmpdir"
for f in encrypted_dek.bin iv.bin ciphertext.bin; do
[[ -f "$tmpdir/$f" ]] || { echo "ERROR: missing $f in bundle"; exit 1; }
done
# --- 3c. Unwrap the DEK via KMS Decrypt ----------------------------------
echo "[3/4] Decrypting DEK via KMS..."
# KMS expects the ciphertext as base64; we pipe the raw binary through base64.
local enc_dek_b64
enc_dek_b64=$(base64 < "$enc_dek_file")
aws kms decrypt \
--key-id "$kms_arn" \
--ciphertext-blob "fileb://$enc_dek_file" \
--encryption-algorithm RSAES_OAEP_SHA_256 \
--query 'Plaintext' \
--output text \
| base64 --decode > "$dek_file"
# --- 3d. Decrypt the ciphertext with AES-256-CBC -------------------------
echo "[4/4] Decrypting payload..."
openssl enc -d -aes-256-cbc -nosalt \
-in "$ciphertext_file" \
-out "$output_file" \
-K "$(xxd -p -c 256 "$dek_file")" \
-iv "$(xxd -p -c 256 "$iv_file")"
# Wipe DEK immediately
openssl rand 32 > "$dek_file" && rm -f "$dek_file"
echo ""
echo "✅ Decryption successful → $output_file"
}
# ---------------------------------------------------------------------------
# CLI entrypoint
# ---------------------------------------------------------------------------
usage() {
cat <<EOF
Usage:
$0 gen-creds <kms_key_arn> <s3_bucket> <s3_object_key> [ttl_seconds]
$0 encrypt <credentials_json_file> <plaintext_file>
$0 decrypt <kms_key_arn> <s3_bucket> <s3_object_key> <output_file>
Examples:
# Step 1 — generate credentials and save to file
$0 gen-creds \\
arn:aws:kms:ap-southeast-1:123456789012:key/mrk-abc \\
my-bucket uploads/payload.enc.tar 3600 > credentials.json
# Step 2 — encrypt a file and upload it
$0 encrypt credentials.json ./sensitive_data.pdf
# Step 3 — download and decrypt on the receiving end
$0 decrypt \\
arn:aws:kms:ap-southeast-1:123456789012:key/mrk-abc \\
my-bucket uploads/payload.enc.tar \\
./recovered_output.pdf
EOF
exit 1
}
case "${1:-}" in
gen-creds) shift; generate_upload_credentials "$@" ;;
encrypt) shift; encrypt_and_upload "$@" ;;
decrypt) shift; download_and_decrypt "$@" ;;
*) usage ;;
esac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment