Created
May 12, 2026 13:43
-
-
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.
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
| # 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 |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Example code: