Go Back

Encrypt then Mac using OpenSSL (+ Powershell)

Executive Summary

This post is a summary of what I learned while solving a set of “encryption & decryption exploration” problems. Instead of using a high-level crypto library, I implemented an Encrypt-then-MAC scheme using AES-256-CBC + HMAC-SHA-256 in PowerShell on top of OpenSSL.

In practice, you should probably just use an AEAD mode like AES-GCM or ChaCha20-Poly1305. But going through the pain of building CBC-HMAC by hand was a good way to really understand what AEADs are doing for us.

Scheme & Threat Model

High-Level Flow

Sender:
   IV ← random
   C  = AES-CBC_Ke(IV, M)
   T  = HMAC_Km(IV || C)
   Output: IV || C || T

Receiver:
   Parse IV', C', T'
   Verify T' == HMAC_Km(IV' || C')
   If valid: decrypt C'
   Else: reject

Threat Model (IND-CCA)

The system is analyzed under an IND-CCA (chosen-ciphertext attack) adversary. The attacker can read and modify all ciphertexts, try arbitrary variations, and observe whether the system accepts or rejects them. Keys remain secret, but responses, timing, and error messages are visible unless intentionally hidden.

Under this model, the order of operations matters. Verification needs to happen before any decryption attempt. If padding or MAC errors are exposed differently, CBC’s malleability turns the system into a decryption oracle, violating IND-CCA security.

While this is a small PowerShell exercise, it reflects the same reasoning behind modern AEAD designs. AEAD modes exist partly to prevent all the subtle issues that manually combining CBC and HMAC can introduce.

Demo: CBC-HMAC Powershell Scripts

Hands-On CBC-HMAC in PowerShell

Setup: PowerShell + OpenSSL

The implementation lives in two scripts:

Both scripts assume the openssl CLI is available in PATH. AES-256-CBC is delegated to openssl enc, and PowerShell handles key derivation, HMAC computation, and the custom file format.

Key derivation from a master key

Instead of directly using a single random key for both encryption and MAC, the script generates a 32-byte master key and then stretches it with SHA-512 into two separate keys: an encryption key and a MAC key. This gives us key separation without needing an extra KDF library.

# In enc_cbc_hmac.ps1

# Generate 32-byte master key
$master = New-Object byte[] 32
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($master)

# Derive EncKey and MacKey via SHA-512(master)
$sha = [Security.Cryptography.SHA512]::Create()
$h   = $sha.ComputeHash($master)
$encKey = $h[0..31]   # first 32 bytes  → AES key
$macKey = $h[32..63]  # next 32 bytes   → HMAC key

# Save master key as base64 so it can be used later for decryption
$masterB64 = [Convert]::ToBase64String($master)
$keyFile   = Join-Path $env:TEMP ("enc_key_{0}.txt" -f ([guid]::NewGuid()))
[IO.File]::WriteAllText($keyFile, $masterB64)
Write-Host "Master key (base64) written to: $keyFile"

On the decryption side, the script reverses this process: it reads the base64 string from the key file, decodes it back into 32 bytes, and runs the same SHA-512 computation to recover EncKey and MacKey. As long as both sides use the same derivation function, they stay in sync.

# In dec_cbc_hmac.ps1

$masterB64 = (Get-Content $KeyB64File -Raw).Trim()

[byte[]] $master = [Convert]::FromBase64String($masterB64)
$sha = [Security.Cryptography.SHA512]::Create()
$h = $sha.ComputeHash($master)
$encKey = $h[0..31]
$macKey = $h[32..63]

Encryption: AES-256-CBC + HMAC(iv || ct)

For encryption, the flow in enc_cbc_hmac.ps1 looks like this:

  1. Generate a random 16-byte IV.
  2. Encrypt the plaintext file with openssl enc -aes-256-cbc using EncKey and the IV.
  3. Compute tag = HMAC-SHA-256(MacKey, IV || ciphertext) in PowerShell.
  4. Write everything into a custom .pak file with a small header.
# Generate random IV
[byte[]] $iv = New-Object byte[] 16
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($iv)
$ivHex = ($iv | ForEach-Object { $_.ToString("x2") }) -join ""

# Encrypt with openssl (AES-256-CBC)
$encKeyHex = ($encKey | ForEach-Object { $_.ToString("x2") }) -join ""
$ctTemp = [IO.Path]::GetTempFileName()
Remove-Item $ctTemp -Force
$ctTemp = "$ctTemp.bin"

$opensslArgs = @(
  "enc", "-aes-256-cbc",
  "-K",  $encKeyHex,
  "-iv", $ivHex,
  "-in", $PlainFile,
  "-out", $ctTemp
)

Write-Host "Running: openssl $($opensslArgs -join ' ')"
& openssl $opensslArgs | Out-Null

[byte[]] $ct = [IO.File]::ReadAllBytes($ctTemp)

# HMAC(iv || ct)
$hmac = New-Object Security.Cryptography.HMACSHA256 -ArgumentList (,[byte[]]$macKey)
[byte[]] $tag = $hmac.ComputeHash($iv + $ct)
Write-Host "tag(enc) : $([Convert]::ToBase64String($tag))"

PAK file format

Instead of just dumping IV, ciphertext, and tag, the script adds a tiny header with some metadata. The resulting layout is:

[ magic(4) | version(1) | ivLen(1) | tagLen(1) | ctLen(4, big endian) | IV | C | Tag ]
    

In PowerShell, this is constructed by creating a header byte array and then concatenating everything:

[byte[]] $magic = [Text.Encoding]::ASCII.GetBytes("PAK1")
[byte]   $version = 1
[byte]   $ivLen   = [byte]$iv.Length
[byte]   $tagLen  = [byte]$tag.Length

[byte[]] $ctLenBytes = [BitConverter]::GetBytes([uint32]$ct.Length)
if ([BitConverter]::IsLittleEndian) {
    [Array]::Reverse($ctLenBytes)  # store as big endian
}

# header: 4 + 1 + 1 + 1 + 4 = 11 bytes
[byte[]] $header = New-Object byte[] 11
$offset = 0
[Array]::Copy($magic,   0, $header, $offset, 4); $offset += 4
$header[$offset] = $version; $offset += 1
$header[$offset] = $ivLen;   $offset += 1
$header[$offset] = $tagLen;  $offset += 1
[Array]::Copy($ctLenBytes, 0, $header, $offset, 4); $offset += 4

# Final layout: header || IV || ciphertext || tag
$totalLen = $header.Length + $iv.Length + $ct.Length + $tag.Length
[byte[]] $pak = New-Object byte[] $totalLen

$pos = 0
[Array]::Copy($header, 0, $pak, $pos, $header.Length); $pos += $header.Length
[Array]::Copy($iv,     0, $pak, $pos, $iv.Length);     $pos += $iv.Length
[Array]::Copy($ct,     0, $pak, $pos, $ct.Length);     $pos += $ct.Length
[Array]::Copy($tag,    0, $pak, $pos, $tag.Length);    $pos += $tag.Length

[IO.File]::WriteAllBytes($OutPakFile, $pak)

Decryption: verify-then-decrypt

The decryption script dec_cbc_hmac.ps1 basically does the reverse:

  1. Read the whole .pak file into a byte array.
  2. Parse and validate the header (magic, version, lengths).
  3. Split the body into IV, ciphertext, and tag.
  4. Recompute HMAC over iv || ct and compare in constant time.
  5. If and only if the MAC matches, call openssl enc -d -aes-256-cbc to get the plaintext.

The “constant-time-ish” comparison is implemented manually:

function CtEq([byte[]] $a, [byte[]] $b) {
    if ($a.Length -ne $b.Length) { return $false }
    $diff = 0
    for ($i = 0; $i -lt $a.Length; $i++) {
        $diff = $diff -bor ($a[$i] -bxor $b[$i])
    }
    return ($diff -eq 0)
}

# Verify HMAC(iv || ct)
$hmac = New-Object Security.Cryptography.HMACSHA256 -ArgumentList (,[byte[]]$macKey)
[byte[]] $tagCalc = $hmac.ComputeHash($iv + $ct)
Write-Host "tag(calc): $([Convert]::ToBase64String($tagCalc))"

if (-not (CtEq $tagFile $tagCalc)) {
    throw "HMAC verification FAILED. Aborting."
}

Only after the HMAC passes, the script writes ct to a temp file and calls openssl enc -d -aes-256-cbc with the recovered key and IV:

$encKeyHex = ($encKey | ForEach-Object { $_.ToString("x2") }) -join ""
$ivHex     = ($iv     | ForEach-Object { $_.ToString("x2") }) -join ""

$ctTmp = [IO.Path]::GetTempFileName()
Remove-Item $ctTmp -Force
$ctTmp = "$ctTmp.bin"
[IO.File]::WriteAllBytes($ctTmp, $ct)

$opensslArgs = @(
  "enc", "-d", "-aes-256-cbc",
  "-K",  $encKeyHex,
  "-iv", $ivHex,
  "-in", $ctTmp,
  "-out", $OutPlainFile
)

Write-Host "Running: openssl $($opensslArgs -join ' ')"
& openssl $opensslArgs | Out-Null
Write-Host "Decryption Complete: $OutPlainFile"

Takeaways

I probably won’t write another CBC-HMAC scheme from scratch anytime soon, but this exercise definitely changed how I look at “just use AES-GCM” advice. Now it feels like earned convenience, not blind trust.