Last active
March 16, 2026 13:37
-
-
Save jpawlowski/b5c789980f59206b76a4d0f9809a8755 to your computer and use it in GitHub Desktop.
Verify HMAC signature of incoming Azure Automation webhook requests.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| function Get-HmacSignedHeaders { | |
| <# | |
| .SYNOPSIS | |
| Generates signed headers for HMAC authentication for Azure Automation webhook requests. | |
| .DESCRIPTION | |
| Generates signed headers for HMAC authentication based on a shared secret (provided as SecureString), webhook URL, and request body content. | |
| Includes timestamp, nonce, and content hash, all covered in the HMAC signature. | |
| SecureString is converted as late as possible and securely cleared after use. | |
| This function is intended to be used when calling Azure Automation webhooks or other APIs requiring signed requests for enhanced security. | |
| ------------------------------------------ | |
| 🔐 Secure Shared Secret Retrieval Options: | |
| ------------------------------------------ | |
| Always retrieve secrets securely from encrypted sources, ensuring they are stored and used as SecureStrings: | |
| 1️⃣ Azure Key Vault (Recommended for cloud/hybrid environments) | |
| Connect-AzAccount -Identity | |
| $sharedSecret = (Get-AzKeyVaultSecret -VaultName "<YourVaultName>" -Name "HmacSharedSecret").SecretValue | |
| 2️⃣ Azure Automation Encrypted Variable (inside Automation Account only) | |
| $sharedSecret = (Get-AutomationVariable -Name "SharedSecret") | ConvertTo-SecureString -AsPlainText -Force | |
| 3️⃣ Windows Credential Manager (if running on Windows, via SecretManagement module) | |
| Import-Module Microsoft.PowerShell.SecretManagement | |
| $sharedSecret = (Get-StoredCredential -Target "HmacSharedSecret").Password | |
| 4️⃣ Encrypted local file (protected by ACLs; not recommended for cloud, acceptable in controlled environments) | |
| $sharedSecret = (Get-Content -Path "C:\Secrets\HmacSecret.txt" -Raw) | ConvertTo-SecureString -AsPlainText -Force | |
| ❌ For demonstration purposes only (NEVER hardcode secrets in production): | |
| $sharedSecret = ConvertTo-SecureString -String "MySuperSecretKey" -AsPlainText -Force | |
| .EXAMPLE | |
| # Example usage: | |
| $sharedSecret = (Get-AutomationVariable -Name "SharedSecret") | ConvertTo-SecureString -AsPlainText -Force | |
| $webhookUrl = "https://<your-webhook-url>/webhooks" | |
| $body = '{"param1":"value1"}' | |
| $headers = Get-HmacSignedHeaders -SharedSecret $sharedSecret ` | |
| -WebhookUrl $webhookUrl ` | |
| -Body $body | |
| [void]$sharedSecret.Dispose() | |
| Invoke-RestMethod -Method POST ` | |
| -Uri $webhookUrl ` | |
| -Headers $headers ` | |
| -Body $body ` | |
| -ContentType 'application/json; charset=utf-8' | |
| .FUNCTIONALITY | |
| Security, HMAC Authentication | |
| .NOTES | |
| Author: Julian Pawlowski | |
| Company Name: Workoho GmbH | |
| Created: 2025-03-17 | |
| Updated: 2026-03-16 | |
| #> | |
| param( | |
| # Shared secret used for HMAC signature (SecureString) | |
| [Parameter(Mandatory)][securestring]$SharedSecret, | |
| # Full webhook URL to which the request will be sent | |
| [Parameter(Mandatory)][string]$WebhookUrl, | |
| # Request body content as a string (usually JSON or similar) | |
| [Parameter(Mandatory)][string]$Body, | |
| # Algorithm to use for HMAC signature (default: HMACSHA256) | |
| [string]$Algorithm = "HMACSHA256", | |
| # Optional: Provide custom nonce (for testing/debugging); defaults to a new random GUID if omitted | |
| [string]$Nonce | |
| ) | |
| if ($Algorithm -notin @('HMACSHA256', 'HMACSHA512')) { | |
| throw "Unsupported algorithm: $Algorithm" | |
| } | |
| # Parse URL components | |
| $uri = [System.Uri]$WebhookUrl | |
| $method = "POST" | |
| $path = $uri.AbsolutePath | |
| $webHost = $uri.Host | |
| # Timestamp header (RFC1123 format) | |
| $date = (Get-Date).ToUniversalTime().ToString("R") | |
| # FIX: Properly dispose SHA256 object to avoid resource leak | |
| $bodyBytes = [Text.Encoding]::UTF8.GetBytes($Body) | |
| $sha256 = $null | |
| try { | |
| $sha256 = [System.Security.Cryptography.SHA256]::Create() | |
| $contentHash = [Convert]::ToBase64String($sha256.ComputeHash($bodyBytes)) | |
| } | |
| finally { | |
| if ($null -ne $sha256) { $sha256.Dispose() } | |
| } | |
| # Generate nonce if not provided | |
| if (-not $Nonce) { | |
| $Nonce = [Guid]::NewGuid().ToString() | |
| } | |
| # Define signed headers and order | |
| $signedHeadersList = @('x-ms-date', 'Host', 'x-ms-content-sha256', 'x-ms-nonce') | |
| $signedHeaders = ($signedHeadersList -join ';') | |
| # Build canonical message | |
| $canonicalMessage = "$method`n$path`n$date;$webHost;$contentHash;$Nonce" | |
| # FIX: Use byte[] for secret handling instead of intermediate string. | |
| # .NET strings are immutable and cannot be reliably cleared from memory. | |
| # We use SecureStringToBSTR → PtrToStringBSTR → char[] → byte[] and | |
| # clear every mutable buffer in the finally block. | |
| # PtrToStringBSTR does produce an intermediate .NET string (unavoidable | |
| # without platform-specific BSTR length tricks), but we immediately copy | |
| # into a char[] and let the string become GC-eligible. This is the best | |
| # cross-platform compromise that works on Windows, macOS, and Linux. | |
| $secretBytes = $null | |
| $secretChars = $null | |
| $bstr = [IntPtr]::Zero | |
| try { | |
| $hmac = New-Object ("System.Security.Cryptography.$Algorithm") | |
| # SecureString → BSTR → string → char[] → byte[] | |
| $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SharedSecret) | |
| $plainSecret = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) | |
| $secretChars = $plainSecret.ToCharArray() | |
| $plainSecret = $null # release reference; string itself is GC-eligible now | |
| $secretBytes = [Text.Encoding]::UTF8.GetBytes($secretChars) | |
| # Set key and compute signature | |
| $hmac.Key = $secretBytes | |
| $signature = [Convert]::ToBase64String($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonicalMessage))) | |
| } | |
| finally { | |
| # Zero out the BSTR (cross-platform safe) | |
| if ($bstr -ne [IntPtr]::Zero) { | |
| [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) | |
| } | |
| # Zero out the intermediate char array | |
| if ($null -ne $secretChars) { | |
| [Array]::Clear($secretChars, 0, $secretChars.Length) | |
| $secretChars = $null | |
| } | |
| # Zero out the secret bytes | |
| if ($null -ne $secretBytes) { | |
| [Array]::Clear($secretBytes, 0, $secretBytes.Length) | |
| $secretBytes = $null | |
| } | |
| # Cleanup HMAC object | |
| if ($null -ne $hmac) { | |
| if ($null -ne $hmac.Key) { | |
| [Array]::Clear($hmac.Key, 0, $hmac.Key.Length) | |
| } | |
| $hmac.Dispose() | |
| $hmac = $null | |
| } | |
| } | |
| # Prepare headers (Host excluded intentionally) | |
| $headers = @{ | |
| 'x-ms-date' = $date | |
| 'x-ms-content-sha256' = $contentHash | |
| 'x-ms-nonce' = $Nonce | |
| 'x-authorization' = "HMAC-$($Algorithm.Replace('HMAC','')) SignedHeaders=$signedHeaders&Signature=$signature" | |
| } | |
| return $headers | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment