From 63e43d751ce33dd9cd12ac385720ce28764486eb Mon Sep 17 00:00:00 2001 From: blackshirt Date: Thu, 25 Jun 2026 02:59:15 +0700 Subject: [PATCH] Add an AES-GCM support into the `aes` module (#27484) * Create aes_gcm * Rename aes_gcm to aes_gcm.v * add an aes gcm test * run format on new gcm file * Add a check for counter overflow * Add support for 24-byte key * moves gf_mult into branchless variant * fix typos, add safety guard on array limit, use predefined constants, etc --------- Co-authored-by: blackshirt --- vlib/crypto/aes/aes_gcm.v | 290 +++++++++++++++++++++++++++++++++ vlib/crypto/aes/aes_gcm_test.v | 205 +++++++++++++++++++++++ 2 files changed, 495 insertions(+) create mode 100644 vlib/crypto/aes/aes_gcm.v create mode 100644 vlib/crypto/aes/aes_gcm_test.v diff --git a/vlib/crypto/aes/aes_gcm.v b/vlib/crypto/aes/aes_gcm.v new file mode 100644 index 000000000..394ed8ca3 --- /dev/null +++ b/vlib/crypto/aes/aes_gcm.v @@ -0,0 +1,290 @@ +// Copyright (c) 2026 blackshirt. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. +// +// AES-GCM (NIST SP 800-38D) built on top of the pure-V `crypto.aes` block +// cipher. Its build to support for QUIC protocol and another cases. +// QUIC (RFC 9001) mandates AES-128-GCM for Initial packets and allows +// AES-128-GCM / AES-256-GCM for 1-RTT packets. The GHASH multiplication uses +// the straightforward bit-by-bit algorithm, which is constant in structure and +// easy to audit (performance is adequate for handshakes and modest streams). +module aes + +import crypto.cipher + +// gcm_tag_size is the size of the GCM authentication tag in bytes. +pub const gcm_tag_size = 16 + +// gcm_nonce_size is the size of the GCM nonce in bytes (only support 96-bit nonce variant). +pub const gcm_nonce_size = 12 + +// zero_block is a preallocated 16-bytes of zeros block. Its currently sized by Aes block size. +const zero_block = []u8{len: block_size} + +// maximum plaintext length per invocation GCM: MUST be <= 2^36 - 32 octets. +const max_plaintext_size = u64(1) << 36 - 32 + +// maximum ciphertext length per invocation GCM: MUST be <= 2^36 - 16 octets. +const max_ciphertext_size = u64(1) << 36 - 16 + +// maximum length of additional authenticated data. Even technically its was not limited, +// we give it a limit to reduce the risk. Intended purposes of additional data was +// as an additional authenticated data included into output, not act as a primary input. +const max_aad_size = max_u32 + +// AesGcm holds the AES block cipher and the derived GHASH subkey for a key. +@[heap; noinit] +pub struct AesGcm implements cipher.AEAD { +mut: + block cipher.Block + h []u8 // GHASH subkey H = E(K, 0^128) +} + +// new_aes_gcm builds AES-GCM state for a 16, 24 or 32-byte key. +@[direct_array_access] +pub fn new_aes_gcm(key []u8) !&AesGcm { + if key.len != 16 && key.len != 24 && key.len != 32 { + return error('AES-GCM key must be 16, 24 or 32 bytes, got ${key.len}') + } + block := new_cipher(key) + mut h := zero_block.clone() + block.encrypt(mut h, zero_block) + return &AesGcm{ + block: block + h: h + } +} + +// nonce_size returns the size of nonce (in bytes). +pub fn (g &AesGcm) nonce_size() int { + return gcm_nonce_size +} + +// overhead returns the maximum difference between the lengths of a plaintext and its ciphertext. +pub fn (g &AesGcm) overhead() int { + return gcm_tag_size +} + +// encrypt encrypts `plaintext` with the given 12-byte `nonce` and additional +// authenticated data `ad`, returning ciphertext with the 16-byte tag appended. +@[direct_array_access] +pub fn (g &AesGcm) encrypt(plaintext []u8, nonce []u8, ad []u8) ![]u8 { + // In V language, unlike C and Go, int is always a 32 bit integer, so technically safe + // to assume if the arrays length not would exceed 32-bit integer (its maybe changed on the future). + // For safety purposes, we check agains the limit. + if u64(plaintext.len) > max_plaintext_size { + return error('AES-GCM encrypt: plaintext size exceed the limit') + } + if u64(ad.len) > max_aad_size { + return error('AES-GCM encrypt: additional data size exceed the limit') + } + if nonce.len != gcm_nonce_size { + return error('AES-GCM nonce must be ${gcm_nonce_size} bytes') + } + j0 := j0_from_nonce(nonce) + mut ctr := j0.clone() + inc32(mut ctr) + ciphertext := g.gctr(ctr, plaintext) + + mut ghash_in := pad_block(ad) + ghash_in << pad_block(ciphertext) + ghash_in << len_block(ad.len, ciphertext.len) + s := g.ghash(zero_block, ghash_in) + tag := g.gctr(j0, s) + + mut out := []u8{cap: ciphertext.len + gcm_tag_size} + out << ciphertext + out << tag + return out +} + +// decrypt verifies and decrypts `ciphertext` (which must include the trailing +// 16-byte tag) using `nonce` and additional authenticated data `ad`. +@[direct_array_access] +pub fn (g &AesGcm) decrypt(ciphertext []u8, nonce []u8, ad []u8) ![]u8 { + // Check the array size for safety + if u64(ciphertext.len) > max_ciphertext_size { + return error('AES-GCM decrypt: ciphertext size exceed the limit') + } + if u64(ad.len) > max_aad_size { + return error('AES-GCM decrypt: additional data size exceed the limit') + } + if nonce.len != gcm_nonce_size { + return error('AES-GCM nonce must be ${gcm_nonce_size} bytes') + } + if ciphertext.len < gcm_tag_size { + return error('AES-GCM ciphertext shorter than tag') + } + ct := ciphertext[..ciphertext.len - gcm_tag_size] + tag := ciphertext[ciphertext.len - gcm_tag_size..] + j0 := j0_from_nonce(nonce) + + mut ghash_in := pad_block(ad) + ghash_in << pad_block(ct) + ghash_in << len_block(ad.len, ct.len) + s := g.ghash(zero_block, ghash_in) + expected := g.gctr(j0, s) + if !bytes_equal(expected, tag) { + return error('AES-GCM authentication failed') + } + mut ctr := j0.clone() + inc32(mut ctr) + return g.gctr(ctr, ct) +} + +// ghash computes GHASH_H over `data`, which the caller must already have padded +// to a multiple of the block size, starting from the running value `y`. +@[direct_array_access] +fn (g &AesGcm) ghash(y []u8, data []u8) []u8 { + mut acc := y.clone() + mut off := 0 + for off < data.len { + for j in 0 .. block_size { + acc[j] ^= data[off + j] + } + acc = gf_mult(acc, g.h) + off += block_size + } + return acc +} + +// gctr applies the GCM counter mode to `input` starting from counter block +// `icb`, returning the keystream-XORed output. +@[direct_array_access] +fn (g &AesGcm) gctr(icb []u8, input []u8) []u8 { + if input.len == 0 { + return []u8{} + } + mut out := []u8{len: input.len} + mut ctr := icb.clone() + mut ks := zero_block.clone() + mut off := 0 + for off < input.len { + g.block.encrypt(mut ks, ctr) + n := if input.len - off < block_size { input.len - off } else { block_size } + for j in 0 .. n { + out[off + j] = input[off + j] ^ ks[j] + } + inc32(mut ctr) + off += block_size + } + return out +} + +// Helpers + +// gf_mult multiplies two 16-byte blocks in GF(2^128) using the reduction +// polynomial from NIST SP 800-38D (the bit-reflected x^128 + x^7 + x^2 + x + 1). +@[direct_array_access] +fn gf_mult(x []u8, y []u8) []u8 { + mut z := zero_block.clone() + mut v := y.clone() + + // Process all 128 bits + for i in 0 .. 128 { + // Extract bit i from x (MSB first, left to right) + // byte index: i >> 3 or (i / 8) + // bit position: 7 - (i & 7) (7, 6, 5, ..., 0) + bit := (x[i >> 3] >> (7 - u8(i & 7))) & 1 + + // Create constant-time mask + // If bit = 1: mask = 0xFF (all 1s) + // If bit = 0: mask = 0x00 (all 0s) + // This is constant-time: -(i8(bit)) produces correct result + mask := u8(-(i8(bit))) + + // Conditional XOR: z ^= v * bit (constant-time) + // Instead of: if bit == 1 { z[j] ^= v[j] } + // We do: z[j] ^= (v[j] & mask) + for j in 0 .. block_size { + z[j] ^= (v[j] & mask) + } + + // Right shift v by 1 bit (always performed) + // This is constant-time - no branches + lsb := v[15] & 1 // Save LSB before shift + + for j := 15; j > 0; j-- { + v[j] = (v[j] >> 1) | ((v[j - 1] & 1) << 7) + } + v[0] >>= 1 + + // Polynomial reduction (constant-time) + // If lsb = 1: v[0] ^= 0xe1 + // If lsb = 0: v[0] unchanged + // Polynomial: x^128 + x^7 + x^2 + x + 1 = 0xe1 in lowest bits + // + // Instead of: if lsb == 1 { v[0] ^= 0xe1 } + // We do: v[0] ^= (0xe1 & mask_for_lsb) + red_mask := u8(-(i8(lsb))) + v[0] ^= (0xe1 & red_mask) + } + return z +} + +// pad_block returns `data` right-padded with zeros to a multiple of 16 bytes. +@[direct_array_access] +fn pad_block(data []u8) []u8 { + temp := data.clone() + rem := data.len % block_size + if rem == 0 { + return temp + } + mut out := []u8{cap: data.len + block_size - rem} + out << temp + out << []u8{len: block_size - rem} // padding + return out +} + +// inc32 increments the last 32 bits of the 16-byte counter block in place. +@[direct_array_access] +fn inc32(mut ctr []u8) { + mut c := (u32(ctr[12]) << 24) | (u32(ctr[13]) << 16) | (u32(ctr[14]) << 8) | u32(ctr[15]) + // detect for wrapping u32 counter + if (c + 1) == 0 { + panic('AES-GCM: inc32 overflow the counter') + } + c += 1 + ctr[12] = u8(c >> 24) + ctr[13] = u8(c >> 16) + ctr[14] = u8(c >> 8) + ctr[15] = u8(c) +} + +// len_block returns the GHASH length block: the bit lengths of the AAD and the +// ciphertext, each encoded as a big-endian 64-bit integer. +fn len_block(ad_len int, ct_len int) []u8 { + a := u64(ad_len) * 8 + c := u64(ct_len) * 8 + mut b := zero_block.clone() + for i in 0 .. 8 { + b[7 - i] = u8(a >> (8 * u32(i))) + b[15 - i] = u8(c >> (8 * u32(i))) + } + return b +} + +// j0 derives the pre-counter block for a 96-bit nonce: nonce || 0^31 || 1. +@[direct_array_access] +fn j0_from_nonce(nonce []u8) []u8 { + mut j := zero_block.clone() + for i in 0 .. 12 { + j[i] = nonce[i] + } + j[15] = 1 + return j +} + +// bytes_equal compares two equal-length byte slices in constant time. +// similar to `crypto.internal.subtle.constant_time_compare()` ones +@[direct_array_access] +fn bytes_equal(a []u8, b []u8) bool { + if a.len != b.len { + return false + } + mut diff := u8(0) + for i in 0 .. a.len { + diff |= a[i] ^ b[i] + } + return diff == 0 +} diff --git a/vlib/crypto/aes/aes_gcm_test.v b/vlib/crypto/aes/aes_gcm_test.v new file mode 100644 index 000000000..36cf6e923 --- /dev/null +++ b/vlib/crypto/aes/aes_gcm_test.v @@ -0,0 +1,205 @@ +module aes + +import encoding.hex + +// Test vectors from "The Galois/Counter Mode of Operation (GCM)", +// and NIST SP 800-38D worked examples. +fn test_aes128_gcm_vector() { + key := hex.decode('feffe9928665731c6d6a8f9467308308')! + iv := hex.decode('cafebabefacedbaddecaf888')! + plaintext := + hex.decode('d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39')! + ad := hex.decode('feedfacedeadbeeffeedfacedeadbeefabaddad2')! + want_ct := + hex.decode('42831ec2217774244b7221b784d0d49ce3aa212f2c02a4e035c17e2329aca12e21d514b25466931c7d8f6a5aac84aa051ba30b396a0aac973d58e091')! + want_tag := hex.decode('5bc94fbc3221a5db94fae95ae7121a47')! + + mut g := new_aes_gcm(key)! + out := g.encrypt(plaintext, iv, ad)! + ct := out[..out.len - 16] + tag := out[out.len - 16..] + assert ct == want_ct + assert tag == want_tag + + dec := g.decrypt(out, iv, ad)! + assert dec == plaintext +} + +fn test_aes256_gcm_vector() { + key := hex.decode('feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308')! + iv := hex.decode('cafebabefacedbaddecaf888')! + plaintext := + hex.decode('d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39')! + ad := hex.decode('feedfacedeadbeeffeedfacedeadbeefabaddad2')! + want_ct := + hex.decode('522dc1f099567d07f47f37a32a84427d643a8cdcbfe5c0c97598a2bd2555d1aa8cb08e48590dbb3da7b08b1056828838c5f61e6393ba7a0abcc9f662')! + want_tag := hex.decode('76fc6ece0f4e1768cddf8853bb2d551b')! + + mut g := new_aes_gcm(key)! + out := g.encrypt(plaintext, iv, ad)! + assert out[..out.len - 16] == want_ct + assert out[out.len - 16..] == want_tag + assert g.decrypt(out, iv, ad)! == plaintext +} + +fn test_gcm_tamper_detected() { + key := hex.decode('00000000000000000000000000000000')! + iv := hex.decode('000000000000000000000000')! + mut g := new_aes_gcm(key)! + out := g.encrypt('hello'.bytes(), iv, []u8{})! + mut bad := out.clone() + bad[0] ^= 0xff + if _ := g.decrypt(bad, iv, []u8{}) { + assert false, 'tampered ciphertext should fail authentication' + } +} + +// The test materials taken and adapted from CAVP Testing: Block Cipher Modes. +// Its only take the samples, not all tests was performed, some of them was not supported. +// See https://csrc.nist.gov/CSRC/media/Projects/Cryptographic-Algorithm-Validation-Program/documents/mac/gcmtestvectors.zip +fn test_avcp_1() ! { + // [Keylen = 128] + // [IVlen = 96] + // [PTlen = 0] + // [AADlen = 0] + // [Taglen = 128] + + // Count = 0 + key := hex.decode('11754cd72aec309bf52f7687212e8957')! + iv := hex.decode('3c819d9a9bed087615030b65')! + pt := hex.decode('')! + aad := hex.decode('')! + ct := hex.decode('')! + tag := hex.decode('250327c674aaf477aef2675748cf6971')! + + mut g := new_aes_gcm(key)! + out := g.encrypt(pt, iv, aad)! + + // ciphertext only contains the tag + assert out == tag +} + +fn test_avcp_2() ! { + // [Keylen = 128] + // [IVlen = 96] + // [PTlen = 128] + // [AADlen = 128] + // [Taglen = 120] // 15 bytes + // + // Count = 0 + key := hex.decode('89c54b0d3bc3c397d5039058c220685f')! + iv := hex.decode('bc7f45c00868758d62d4bb4d')! + pt := hex.decode('582670b0baf5540a3775b6615605bd05')! + aad := hex.decode('48d16cda0337105a50e2ed76fd18e114')! + ct := hex.decode('fc2d4c4eee2209ddbba6663c02765e69')! + tag := hex.decode('55e783b00156f5da0446e2970b877f')! + + mut g := new_aes_gcm(key)! + out := g.encrypt(pt, iv, aad)! + assert out[0..pt.len] == ct + // the tag len was truncated to 15-bytes + assert out[pt.len..pt.len + 15].hex() == tag.hex() + + mut g2 := new_aes_gcm(key)! + ptx := g2.decrypt(out, iv, aad)! + assert ptx == pt +} + +struct ItemTest { + count int + key string + iv string + pt string + aad string + ct string + tag string +} + +fn test_avcp_3() ! { + // [Keylen = 128] + // [IVlen = 96] + // [PTlen = 128] + // [AADlen = 160] + // [Taglen = 128] + tag_len := 16 + + for t in tests_data { + key := hex.decode(t.key)! + iv := hex.decode(t.iv)! + pt := hex.decode(t.pt)! + aad := hex.decode(t.aad)! + ct := hex.decode(t.ct)! + tag := hex.decode(t.tag)! + + mut g := new_aes_gcm(key)! + out := g.encrypt(pt, iv, aad)! + assert out[0..pt.len] == ct + assert out[pt.len..pt.len + tag_len] == tag + } +} + +const tests_data = [ + ItemTest{ + count: 0 + key: 'd4a22488f8dd1d5c6c19a7d6ca17964c' + iv: 'f3d5837f22ac1a0425e0d1d5' + pt: '7b43016a16896497fb457be6d2a54122' + aad: 'f1c5d424b83f96c6ad8cb28ca0d20e475e023b5a' + ct: 'c2bd67eef5e95cac27e3b06e3031d0a8' + tag: 'f23eacf9d1cdf8737726c58648826e9c' + }, + ItemTest{ + count: 1 + key: 'e8899345e4d89b76f7695ddf2a24bb3c' + iv: '9dfaeb5d73372ceb06ca7bbe' + pt: 'c2807e403e9babf645268c92bc9d1de6' + aad: 'fed0b45a9a7b07c6da5474907f5890e317e74a42' + ct: '8e44bf07454255aa9e36eb34cdfd0036' + tag: '2f501e5249aa595a53e1985e90346a22' + }, + ItemTest{ + count: 2 + key: 'c1629d6320b9da80a23c81be53f0ef57' + iv: 'b8615f6ffa30668947556cd8' + pt: '65771ab52532c9cdfcb3a9eb7b8193df' + aad: '5f2955e4301852a70684f978f89e7a61531f0861' + ct: 'c2a72d693181c819f69b42b52088d3a2' + tag: 'cadaee305d8bb6d70259a6503280d99a' + }, + ItemTest{ + count: 3 + key: '196ed78281bb7543d60e68cca2aaa941' + iv: '6e7d2c8f135715532a075c50' + pt: '15b42e7ea21a8ad5dcd7a9bba0253d44' + aad: 'd6fc98c632d2e2641041ff7384d92a8358ae9abe' + ct: '06e5cc81c2d022cb2b5de5a881c62d09' + tag: '28e8cad3346ce583d5eebaa796e50974' + }, + ItemTest{ + count: 4 + key: '55fe8a1bdc6806ed2f4a84891db943a0' + iv: 'af4d0ba0a90f1e713d71ae94' + pt: '81315972f0b1aeaa005363e9eca09d7a' + aad: '677cd4e6c0a67913085dba4cc2a778b894e174ad' + ct: 'c47bcb27c5a8d9beb19fee38b90861b7' + tag: 'e061ee4868edf2d969e875b8685ca8a9' + }, + ItemTest{ + count: 5 + key: '6d86a855508657f804091be2290a17e0' + iv: '65dce18a4461afd83f1480f5' + pt: '0423bd1c8aea943637c7c3b0ca61d54b' + aad: 'e0ef8f0e1f442a2c090568d2af336ec59f57c896' + ct: '53505d449369c9bcd8a138740ea6602e' + tag: '86f928b4532825af9cac3820234afe73' + }, + ItemTest{ + count: 6 + key: '66bd7b5dfd0aaaed8bb8890eee9b9c9a' + iv: '6e92bf7e8fd0fb932451fdf2' + pt: '8005865c8794b79612447f5ef33397d0' + aad: '60459c681bda631ece1aacca4a7b1b369c56d2bb' + ct: '83b99253de05625aa8e68490bb368bb9' + tag: '65d444b02a23e854a85423217562d07f' + }, +] -- 2.39.5