// swift-tools-version:6.2import PackageDescription let package = Package( name: "howto", platforms: [.macOS(.v15)], dependencies: [ .package(url: "https://github.com/apple/swift-crypto.git", exact: "5.0.3"), ], targets: [ .executableTarget(name: "webhook", dependencies: [.product(name: "Crypto", package: "swift-crypto")]), ]) import Crypto // CryptoKit's API, also on Linuximport Foundation /// Sender: HMAC-SHA256 over the exact body bytes, sent as "sha256=<hex>".func sign(secret: SymmetricKey, rawBody: Data) -> String { let mac = HMAC<SHA256>.authenticationCode(for: rawBody, using: secret) return "sha256=" + mac.map { String(format: "%02x", $0) }.joined()} /// Receiver: recompute over the raw body; isValidAuthenticationCode compares in constant time.func verify(secret: SymmetricKey, rawBody: Data, header: String?) -> Bool { guard let header, header.hasPrefix("sha256="), let received = Data(hex: header.dropFirst(7)) else { return false // a missing or malformed header fails, it never skips the check } return HMAC<SHA256>.isValidAuthenticationCode(received, authenticating: rawBody, using: secret)} extension Data { /// The bytes of an even-length string of hex digits; nil for anything else. init?<S: StringProtocol>(hex: S) { guard hex.count % 2 == 0, hex.allSatisfy(\.isHexDigit) else { return nil } var bytes: [UInt8] = [] var index = hex.startIndex while index < hex.endIndex { let next = hex.index(index, offsetBy: 2) bytes.append(UInt8(hex[index..<next], radix: 16)!) // safe: two hex digits index = next } self.init(bytes) }} How it works
SymmetricKey(data:)wraps the secret's bytes;HMAC<SHA256>.authenticationCode(for: rawBody, using: key)returns the 32-byte MAC, printed as lower-case hex aftersha256=.HMAC<SHA256>.isValidAuthenticationCode(received, authenticating: rawBody, using: key)recomputes the MAC and compares with a constant-time comparison (CRYPTO_memcmpfrom BoringSSL in swift-crypto); a MAC of the wrong length is simply invalid.Data(hex:)decodes only an even number of hex digits and returnsnilotherwise, soverifyrejects every malformed header in oneguard.- On Apple platforms
import CryptoKitoffers the same types; the package keeps the code identical on Linux servers. The run checks GitHub's example —Hello, World!with the secretIt's a Secret to Everybodygivessha256=757107ea…3e17. - Compute the MAC over the request body bytes exactly as received; decoding the body into a
Codablevalue and encoding it again changes the bytes.
Gotchas
- Swift's integer parser accepts a sign:
UInt8("+f", radix: 16)is15. A hex decoder built on it alone would read+fas a byte; checkisHexDigitfirst, asData(hex:)does (it returnsnilfor"+f"). - Compare MACs with
isValidAuthenticationCode, not by turning both into hex strings: a sender's upper-case hex (sha256=757107EA…, accepted above) would not be equal to your lower-case string, and==on strings is not constant-time. - A truncated header decodes to fewer than 32 bytes and is rejected (
truncated header: false); never shorten the computed MAC to the received length to make a comparison succeed.