Skip to content

Instantly share code, notes, and snippets.

@joostd
Created May 12, 2026 13:43
Show Gist options
  • Select an option

  • Save joostd/eaddf47a74f5c28634caf7575abb0a6b to your computer and use it in GitHub Desktop.

Select an option

Save joostd/eaddf47a74f5c28634caf7575abb0a6b to your computer and use it in GitHub Desktop.
Extend python-yubihsm with AES in GCM mode, implemented with AES in ECB mode as primitive. No key meterial crosses the USB boundary.
# symmetric_key_gcm.py
#
# AES-GCM extension for the python-yubihsm SymmetricKey class.
#
# The YubiHSM 2 has no native GCM command, so this module builds GCM from
# the two primitives the device does expose:
#
# ENCRYPT_ECB – used to derive H (GHASH subkey) and to generate the
# CTR keystream blocks that encrypt/decrypt the payload.
# ENCRYPT_ECB – also used to encrypt the J0 counter block for the tag.
#
# All AES operations are performed inside the HSM. The only "soft" crypto
# here is the GF(2¹²⁸) multiply used by GHASH and the XOR of the keystream
# with plaintext – neither of those operations involves the key material.
#
# Usage
# -----
# from yubihsm.objects import SymmetricKey
# from symmetric_key_gcm import encrypt_gcm, decrypt_gcm
#
# # monkey-patch onto the class (optional convenience)
# SymmetricKey.encrypt_gcm = encrypt_gcm
# SymmetricKey.decrypt_gcm = decrypt_gcm
#
# ct, tag = key.encrypt_gcm(nonce, plaintext, aad)
# pt = key.decrypt_gcm(nonce, ciphertext, tag, aad)
#
# Alternatively call the functions directly:
# ct, tag = encrypt_gcm(key_obj, nonce, plaintext, aad)
#
# Constraints (inherited from the GCM spec and HSM limits)
# ---------------------------------------------------------
# • nonce : exactly 12 bytes (96-bit). Longer/shorter nonces require
# an extra GHASH pass to derive J0; that is not implemented
# here because 96-bit nonces are universally recommended.
# • aad : 0 .. 2^64-1 bits (effectively unlimited for any real use)
# • plaintext / ciphertext: multiple sizes are fine (no block-alignment
# requirement for the payload, unlike ECB/CBC).
# • tag : 16 bytes (128-bit, the maximum GCM tag size).
# Truncated tags are not offered; truncation weakens security.
#
# Security notes
# --------------
# • Never reuse a (key, nonce) pair. GCM nonce reuse is catastrophic.
# • The key never leaves the HSM; only AES(key, block) outputs cross the
# USB boundary, which is already inside the authenticated SCP03 session.
# • For nonce generation, prefer os.urandom(12) or a 96-bit counter.
# • This code was created with the help of AI. No not use this code without
# a thorough security review.
import os
import struct
from typing import Tuple
# ---------------------------------------------------------------------------
# GF(2¹²⁸) multiply – the only non-trivial software primitive in this file.
# ---------------------------------------------------------------------------
# GCM uses the field GF(2¹²⁸) with the reduction polynomial
# x¹²⁸ + x⁷ + x² + x + 1 (0xe1 << 120 in the "reflected" representation
# used by GHASH).
#
# This is a straightforward bit-at-a-time implementation. For production use
# at high throughput you would want a table-driven or CLMUL/PMULL version,
# but correctness and auditability are prioritised here.
_R = 0xE1000000000000000000000000000000 # reduction polynomial (reflected)
def _gf128_mul(X: int, Y: int) -> int:
"""Multiply two 128-bit integers in GF(2¹²⁸)."""
Z = 0
V = X
for i in range(127, -1, -1):
if (Y >> i) & 1:
Z ^= V
if V & 1:
V = (V >> 1) ^ _R
else:
V >>= 1
return Z
# ---------------------------------------------------------------------------
# GHASH
# ---------------------------------------------------------------------------
def _ghash(H: int, aad: bytes, ciphertext: bytes) -> bytes:
"""
Compute GHASH_H(A, C) per NIST SP 800-38D §6.4.
H : 128-bit integer (GHASH subkey)
aad : additional authenticated data (A)
ciphertext : the ciphertext (C)
Returns a 16-byte tag input block.
"""
def _pad16(b: bytes) -> bytes:
rem = len(b) % 16
return b if rem == 0 else b + b"\x00" * (16 - rem)
X = 0 # running GHASH state
# Process AAD
for i in range(0, len(_pad16(aad)), 16):
block = int.from_bytes(_pad16(aad)[i : i + 16], "big")
X = _gf128_mul(X ^ block, H)
# Process ciphertext
for i in range(0, len(_pad16(ciphertext)), 16):
block = int.from_bytes(_pad16(ciphertext)[i : i + 16], "big")
X = _gf128_mul(X ^ block, H)
# Length block: len(A) || len(C) in bits, each as a 64-bit big-endian int
len_block = (len(aad) * 8) << 64 | (len(ciphertext) * 8)
X = _gf128_mul(X ^ len_block, H)
return X.to_bytes(16, "big")
# ---------------------------------------------------------------------------
# Counter block helpers
# ---------------------------------------------------------------------------
def _j0(nonce: bytes) -> bytes:
"""Return the initial counter block J0 for a 96-bit nonce."""
# For a 96-bit nonce, J0 = nonce || 0x00000001 (SP 800-38D §7.1 step 2)
if len(nonce) != 12:
raise ValueError("Only 96-bit (12-byte) nonces are supported")
return nonce + b"\x00\x00\x00\x01"
def _inc32(counter_block: bytes) -> bytes:
"""Increment the rightmost 32 bits of a 16-byte counter block."""
n = int.from_bytes(counter_block[12:], "big")
n = (n + 1) & 0xFFFFFFFF
return counter_block[:12] + n.to_bytes(4, "big")
# ---------------------------------------------------------------------------
# CTR keystream generation via HSM ECB
# ---------------------------------------------------------------------------
def _ctr_keystream(sym_key, j0: bytes, length: int) -> bytes:
"""
Generate `length` bytes of AES-CTR keystream starting from counter J0+1.
The key never leaves the HSM; we only encrypt the counter blocks and
receive the resulting keystream blocks back over the SCP03 session.
"""
if length == 0:
return b""
n_blocks = (length + 15) // 16
counter_blocks = b""
cb = j0
for _ in range(n_blocks):
cb = _inc32(cb)
counter_blocks += cb
# encrypt_ecb handles chunking for large inputs automatically
keystream = sym_key.encrypt_ecb(counter_blocks)
return keystream[:length]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def encrypt_gcm(
sym_key,
nonce: bytes,
plaintext: bytes,
aad: bytes = b"",
) -> Tuple[bytes, bytes]:
"""Encrypt data using AES-GCM with the YubiHSM as the AES engine.
The AES key never leaves the device. All AES block operations are
performed inside the HSM via ENCRYPT_ECB. GHASH and XOR are done in
software (they do not involve the key).
:param sym_key: A :class:`~yubihsm.objects.SymmetricKey` reference.
:param nonce: 12-byte (96-bit) nonce. Must be unique per (key, message).
:param plaintext: Data to encrypt. Any length is accepted.
:param aad: Additional authenticated data. Authenticated but not
encrypted. Defaults to empty.
:return: ``(ciphertext, tag)`` – both as :class:`bytes`.
``ciphertext`` is the same length as ``plaintext``.
``tag`` is 16 bytes.
"""
if len(nonce) != 12:
raise ValueError("nonce must be exactly 12 bytes")
j0 = _j0(nonce)
# H = AES_K(0^128) – the GHASH subkey.
h_bytes = sym_key.encrypt_ecb(b"\x00" * 16)
H = int.from_bytes(h_bytes, "big")
# Encrypt the plaintext using CTR mode (counter starts at J0+1).
keystream = _ctr_keystream(sym_key, j0, len(plaintext))
ciphertext = bytes(p ^ k for p, k in zip(plaintext, keystream))
# Compute GHASH over (AAD, ciphertext).
S = _ghash(H, aad, ciphertext)
# Tag = AES_K(J0) XOR S.
e_j0 = sym_key.encrypt_ecb(j0)
tag = bytes(a ^ b for a, b in zip(e_j0, S))
return ciphertext, tag
def decrypt_gcm(
sym_key,
nonce: bytes,
ciphertext: bytes,
tag: bytes,
aad: bytes = b"",
) -> bytes:
"""Decrypt and verify data encrypted with AES-GCM.
:param sym_key: A :class:`~yubihsm.objects.SymmetricKey` reference.
:param nonce: 12-byte (96-bit) nonce used during encryption.
:param ciphertext: The ciphertext to decrypt.
:param tag: The 16-byte authentication tag produced by
:func:`encrypt_gcm`.
:param aad: Additional authenticated data. Must match what was
supplied to :func:`encrypt_gcm`.
:return: The decrypted plaintext.
:raises ValueError: If tag verification fails. **No plaintext is
returned when verification fails.**
"""
if len(nonce) != 12:
raise ValueError("nonce must be exactly 12 bytes")
if len(tag) != 16:
raise ValueError("tag must be exactly 16 bytes")
j0 = _j0(nonce)
# H = AES_K(0^128).
h_bytes = sym_key.encrypt_ecb(b"\x00" * 16)
H = int.from_bytes(h_bytes, "big")
# Verify tag before returning any plaintext (Decrypt-then-Verify).
S = _ghash(H, aad, ciphertext)
e_j0 = sym_key.encrypt_ecb(j0)
expected_tag = bytes(a ^ b for a, b in zip(e_j0, S))
# Constant-time comparison to prevent timing oracle attacks.
if not _ct_compare(tag, expected_tag):
raise ValueError("GCM tag verification failed – ciphertext is invalid or tampered")
# Decrypt using CTR mode (same keystream as encryption).
keystream = _ctr_keystream(sym_key, j0, len(ciphertext))
plaintext = bytes(c ^ k for c, k in zip(ciphertext, keystream))
return plaintext
def _ct_compare(a: bytes, b: bytes) -> bool:
"""Constant-time equality check (no short-circuit on mismatch)."""
if len(a) != len(b):
return False
result = 0
for x, y in zip(a, b):
result |= x ^ y
return result == 0
@joostd

joostd commented May 12, 2026

Copy link
Copy Markdown
Author

Example code:

from yubihsm import YubiHsm
from yubihsm.defs import CAPABILITY, ALGORITHM
from yubihsm.objects import SymmetricKey
from symmetric_key_gcm import encrypt_gcm, decrypt_gcm

SymmetricKey.encrypt_gcm = encrypt_gcm
SymmetricKey.decrypt_gcm = decrypt_gcm

hsm = YubiHsm.connect('yhusb://')
session = hsm.create_session_derived(1, 'password')

key_bytes = session.get_pseudo_random(32)
hsm_key = SymmetricKey.put(session, 0, 'Mykey', 1, CAPABILITY.ENCRYPT_ECB, ALGORITHM.AES256, key_bytes)

nonce = session.get_pseudo_random(12)
data = b'Some secret data to be encrypted and decrypted using AES in GCM mode'
aad = b"authenticated but unencrypted data"  # Associated Data (optional)

ciphertext, tag = hsm_key.encrypt_gcm(nonce, data, aad)
plaintext = hsm_key.decrypt_gcm(nonce, ciphertext, tag, aad)
print(plaintext.decode())

hsm_key.delete()
session.close()
hsm.close()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment