How do I compute an HMAC in EK9, and how do I verify one safely?

← Advanced Type System · Ref: Q1387

HMAC is a KEYED message authentication code. It proves INTEGRITY AND AUTHENTICITY, because producing one requires the key:

  mac <- HMAC().SHA256(key, message)   // both Bytes, result Bytes

That is the whole API. There is no constructor argument - the key is a parameter of SHA256, not state on the object.

WHY OCTETS RATHER THAN STRING

Derivation chains feed one MAC's OUTPUT in as the next one's KEY. AWS SigV4 is the canonical shape: secret -> date -> region -> service -> request. A String API would force a hex round trip at every link, and a MAC's output is not text in any encoding. Key and message are Bytes for that reason.

VERIFY WITH constantTimeEquals, NEVER WITH ==
Both answer the same question, but == is free to stop at the first differing byte. How long it took then tells an attacker how many leading bytes they guessed correctly, which forges a tag one byte at a time. Bytes.constantTimeEquals folds in the XOR at EVERY position, and the length difference too, so there is nothing to time.

TRI-STATE

An unset key or an unset message gives an unset result - never a MAC computed over a substitute value. An EMPTY key is a different thing from an unset one and is accepted, because empty is SET.

THIS TYPE USED TO BE MISNAMED

Until the Bytes family existed there was no honest way to express a keyed MAC, so this type carried the UNKEYED hashes instead - code reaching for 'HMAC' got a bare digest and the authenticity it assumed was imaginary. Those hashes now live on Digest (Q1386).

The example below uses RFC 4231 test case 2, a PUBLISHED vector, so it agrees with every conforming implementation or it is wrong. A value captured from our own output would pass equally happily over a subtly broken MAC. See Q1383 for Bytes. Use 'ek9 -h HMAC' for the full API.

Example

defines module qa.hmac.usage

  defines program

    HmacUsage()
      stdout <- Stdout()

      //An HMAC is KEYED. Producing one requires the secret, so it proves integrity AND
      //authenticity - the thing a bare Digest cannot do, because anyone can recompute that.
      //RFC 4231 test case 2: a PUBLISHED vector, so this agrees with every conforming
      //implementation or it is wrong.
      key <- Bytes().withUtf8("Jefe")
      message <- Bytes().withUtf8("what do ya want for nothing?")
      mac <- HMAC().SHA256(key, message)
      stdout.println(`mac: ${mac}`)
      stdout.println(`length: ${length mac}`)

      //🔑 The key MUST participate. Change it and the whole value changes - which is exactly
      //what an attacker cannot do without knowing it.
      forged <- HMAC().SHA256(Bytes().withUtf8("not-the-key"), message)
      stdout.println(`key matters: ${mac <> forged}`)

      //⚠️ VERIFY WITH constantTimeEquals, NEVER ==. Both answer the same question, but ==
      //is free to stop at the first differing byte, and how long that took tells an attacker
      //how many leading bytes they guessed right - a tag forged one byte at a time.
      stdout.println(`verified: ${mac.constantTimeEquals(HMAC().SHA256(key, message))}`)
      stdout.println(`rejected: ${mac.constantTimeEquals(forged)}`)

      //🔑 KEY AND MESSAGE ARE OCTETS because derivation chains feed one MAC's OUTPUT in as the
      //next one's KEY. AWS SigV4 is the canonical shape; a String API would force a hex
      //round trip at every link.
      dateKey <- HMAC().SHA256(Bytes().withUtf8("AWS4-secret"), Bytes().withUtf8("20260924"))
      regionKey <- HMAC().SHA256(dateKey, Bytes().withUtf8("eu-west-2"))
      signingKey <- HMAC().SHA256(regionKey, Bytes().withUtf8("aws4_request"))
      stdout.println(`derived length: ${length signingKey}`)
      stdout.println(`chain diverges: ${signingKey <> dateKey}`)

      //Unset in, unset out - never a MAC computed over a substitute value.
      stdout.println(`unset key: ${HMAC().SHA256(Bytes(), message)?}`)

      //An EMPTY key is a different thing from an unset one, and is accepted - empty is SET.
      stdout.println(`empty key: ${HMAC().SHA256(Bytes().withUtf8(""), message)?}`)

Common mistakes

E50060 — HMAC takes no constructor arguments and names no algorithm - SHA256 IS the method. The key is a parameter of that method, which is what lets one object serve many keys.

Incorrect:

mac <- HMAC(key, "SHA256")

Correct:

mac <- HMAC().SHA256(key, message)

E07620 — This is the error you get when the received tag is a String of hex rather than Bytes - and it is worth fixing in the right direction. Parse the tag into Bytes and compare with constantTimeEquals; == is free to short-circuit and leaks how many leading bytes matched.

Incorrect:

mac.toText("hex") == HMAC().SHA256(key, message)

Correct:

mac.constantTimeEquals(HMAC().SHA256(key, message))
Other ways to ask this
  • How do I sign a request in EK9?
  • How do I authenticate a message with a shared secret?
  • How do I build an AWS SigV4 signing key?
  • Why should I not compare a MAC with ==?

Coming from another language?

Java: Mac.getInstance("HmacSHA256") plus a SecretKeySpec, two checked exceptions for conditions that cannot arise, a mutable byte[] out, and MessageDigest.isEqual for the constant-time compare that nobody remembers to use. Python: hmac.new(key, msg, hashlib.sha256).hexdigest() with hmac.compare_digest as a separate opt-in. Go: hmac.New(sha256.New, key) then hmac.Equal - the standard library is explicit that == is wrong here. Node: crypto.createHmac plus crypto.timingSafeEqual, which throws if the lengths differ. EK9: one method, both arguments tri-state Bytes, and constantTimeEquals sits on Bytes itself so the safe comparison is the one already to hand.

Keywords: hmac, secret, timing, sigv4, verify, constanttime, authentication, keyed, mac, signing