How Go's crypto Package Uses AES-NI and SHA-NI
Go detects AES-NI, SHA-NI and AVX2 at startup with CPUID, then dispatches crypto/aes and crypto/sha256 to hand-written assembly that chains AESENC and SHA256RNDS2 instructions, with a table-driven pure-Go fallback for CPUs without them.
TL;DR:
crypto/aesandcrypto/sha256are thin shims overcrypto/internal/fips140, where aCPUIDcheck at start-up decides whether your machine has AES-NI, SHA-NI and AVX2. If it does, an AES block is tenAESENCinstructions and a SHA-256 block is sixteenSHA256RNDS2instructions. If it does not, a table-driven fallback runs — and that fallback is not constant-time.
Go’s crypto package is fast for a boring reason: on any CPU made in the last fifteen years, the interesting arithmetic happens in a handful of instructions that do exactly what the algorithm says. There is no software AES, no bitsliced cleverness, no hand-rolled GF(2^128) arithmetic on the hot path. The CPU does it.
The interesting part is how the choice is made, and the fact that the layout of the tree changed dramatically a few releases ago.
The public packages are now shims
crypto/aes used to own the assembly. It no longer does:
// src/crypto/aes/aes.go:32
// NewCipher creates and returns a new [cipher.Block].
// The key argument must be the AES key,
// either 16, 24, or 32 bytes to select
// AES-128, AES-192, or AES-256.
func NewCipher(key []byte) (cipher.Block, error) {
k := len(key)
switch k {
default:
return nil, KeySizeError(k)
case 16, 24, 32:
break
}
if boring.Enabled {
return boring.NewAESCipher(key)
}
return aes.New(key)
}aes.New is crypto/internal/fips140/aes.New. The same is true of crypto/sha256,
which forwards to crypto/internal/fips140/sha256. The reason is FIPS 140-3 validation:
the primitives had to be restructured into a single internal package that could be
validated as a unit, with the public packages left as thin, stable API surfaces.
That restructuring also moved the assembly. There is no src/crypto/aes/cipher_amd64.s
any more. The AES-NI code is at
src/crypto/internal/fips140/aes/aes_amd64.s, and the SHA-NI code is at
src/crypto/internal/fips140/sha256/sha256block_amd64.s.
Detecting the hardware
The dispatch decision is made from CPUID, once, at start-up. The bits are defined in
src/internal/cpu/cpu_x86.go:
// src/internal/cpu/cpu_x86.go:125
_, _, ecx1, _ := cpuid(1, 0)
X86.HasSSE3 = isSet(ecx1, cpuid_SSE3)
X86.HasPCLMULQDQ = isSet(ecx1, cpuid_PCLMULQDQ)
X86.HasSSSE3 = isSet(ecx1, cpuid_SSSE3)
X86.HasSSE41 = isSet(ecx1, cpuid_SSE41)
X86.HasSSE42 = isSet(ecx1, cpuid_SSE42)
X86.HasPOPCNT = isSet(ecx1, cpuid_POPCNT)
X86.HasAES = isSet(ecx1, cpuid_AES)cpuid_AES is bit 25 of ECX from leaf 1 — the AES-NI feature bit. The SHA extensions
live in the extended leaf 7 instead, which is why SHA-NI support is detected separately:
// src/internal/cpu/cpu_x86.go:172
X86.HasAVX2 = isSet(ebx7, cpuid_AVX2) && osSupportsAVX
X86.HasBMI2 = isSet(ebx7, cpuid_BMI2)
X86.HasERMS = isSet(ebx7, cpuid_ERMS)
X86.HasADX = isSet(ebx7, cpuid_ADX)
X86.HasSHA = isSet(ebx7, cpuid_SHA)The crypto packages do not import internal/cpu directly; they go through a dependency
shim so that the FIPS package’s dependency graph stays clean:
// src/crypto/internal/fips140deps/cpu/cpu.go:47
X86HasAES = cpu.X86.HasAES
X86HasADX = cpu.X86.HasADX
X86HasAVX = cpu.X86.HasAVX
X86HasAVX2 = cpu.X86.HasAVX2
X86HasBMI2 = cpu.X86.HasBMI2
X86HasPCLMULQDQ = cpu.X86.HasPCLMULQDQ
X86HasSHA = cpu.X86.HasSHA
X86HasSSE41 = cpu.X86.HasSSE41
X86HasSSSE3 = cpu.X86.HasSSSE3And the AES package turns those bits into a single boolean, decided at compile time by build tag and at run time by the CPU:
// src/crypto/internal/fips140/aes/aes_asm.go:5
//go:build (amd64 || arm64 || loong64 || ppc64 || ppc64le) && !purego
package aes
import (
"crypto/internal/fips140deps/cpu"
"crypto/internal/fips140deps/godebug"
"crypto/internal/impl"
)
//go:noescape
func encryptBlockAsm(nr int, xk *uint32, dst, src *byte)
//go:noescape
func decryptBlockAsm(nr int, xk *uint32, dst, src *byte)
//go:noescape
func expandKeyAsm(nr int, key *byte, enc *uint32, dec *uint32)
var supportsAES = cpu.X86HasAES && cpu.X86HasSSE41 && cpu.X86HasSSSE3 ||
cpu.ARM64HasAES || cpu.LOONG64HasLSX || cpu.PPC64 || cpu.PPC64leNote the compound requirement: AES-NI alone is not enough, you also want SSSE3 and
SSE4.1 for the shuffle instructions used around it. Note also the !purego tag — the
purego build tag forces the generic implementation everywhere, and the FIPS module
refuses to run that way at all.
impl.Register("aes", "AES-NI", &supportsAES) records the choice in a registry, which
is how the crypto test suite can force the generic path and check that it agrees with
the hardware path.
The AES-NI encrypt chain
Here is the whole of the hardware AES encryption for one block. This is not a paraphrase; it is the generated assembly:
// src/crypto/internal/fips140/aes/aes_amd64.s:8
// Requires: AES, SSE, SSE2
TEXT ·encryptBlockAsm(SB), NOSPLIT, $0-32
MOVQ nr+0(FP), CX
MOVQ xk+8(FP), AX
MOVQ dst+16(FP), DX
MOVQ src+24(FP), BX
MOVUPS (AX), X1
MOVUPS (BX), X0
ADDQ $0x10, AX
PXOR X1, X0
SUBQ $0x0c, CX
JE Lenc192
JB Lenc128
MOVUPS (AX), X1
AESENC X1, X0
MOVUPS 16(AX), X1
AESENC X1, X0
ADDQ $0x20, AX
Lenc192:
MOVUPS (AX), X1
AESENC X1, X0
MOVUPS 16(AX), X1
AESENC X1, X0
ADDQ $0x20, AX
Lenc128:
MOVUPS (AX), X1
AESENC X1, X0
MOVUPS 16(AX), X1
AESENC X1, X0
MOVUPS 32(AX), X1
AESENC X1, X0
MOVUPS 48(AX), X1
AESENC X1, X0
MOVUPS 64(AX), X1
AESENC X1, X0
MOVUPS 80(AX), X1
AESENC X1, X0
MOVUPS 96(AX), X1
AESENC X1, X0
MOVUPS 112(AX), X1
AESENC X1, X0
MOVUPS 128(AX), X1
AESENC X1, X0
MOVUPS 144(AX), X1Read it as a whole and the algorithm is visible:
PXOR X1, X0— the AddRoundKey of round zero, XORing the plaintext with the first round key.AESENCnine times for AES-128 (ten for AES-192, thirteen for AES-256) — each one is SubBytes, ShiftRows, MixColumns and AddRoundKey in a single instruction, with one cycle of latency each and one instruction of throughput. AES-NI is pipelined: the rounds are independent given the round keys, so the CPU can start round N+1 before round N has finished.AESENCLASTonce — the final round, with no MixColumns, because MixColumns is pointless on the last round.
The branching is on nr, the round count: SUBQ $0x0c, CX then JE Lenc192 / JB Lenc128. AES-128 has 10 rounds, AES-192 has 12, AES-256 has 14. The code falls through
the AES-256 case (two extra AESENCs) into the AES-192 case (two more) into the
AES-128 case (nine more plus the last). One code path, three key sizes, no loops — the
unrolled straight-line code is what lets the CPU’s instruction window stay full.
Decryption is the mirror image, with AESDEC and AESDECLAST.
Key expansion
The round keys are computed once, when the cipher is created. The hardware has a
dedicated instruction for the trickiest part of that — AESKEYGENASSIST:
// src/crypto/internal/fips140/aes/aes_amd64.s:109
// func expandKeyAsm(nr int, key *byte, enc *uint32, dec *uint32)
// Requires: AES, SSE, SSE2
TEXT ·expandKeyAsm(SB), NOSPLIT, $0-32
MOVQ nr+0(FP), CX
MOVQ key+8(FP), AX
MOVQ enc+16(FP), BX
MOVQ dec+24(FP), DX
MOVUPS (AX), X0
// enc
MOVUPS X0, (BX)
ADDQ $0x10, BX
PXOR X4, X4
CMPL CX, $0x0c
JE Lexp_enc192
JB Lexp_enc128
MOVUPS 16(AX), X2
MOVUPS X2, (BX)
ADDQ $0x10, BX
AESKEYGENASSIST $0x01, X2, X1
CALL _expand_key_256a<>(SB)
AESKEYGENASSIST $0x01, X0, X1
CALL _expand_key_256b<>(SB)
AESKEYGENASSIST $0x02, X2, X1
CALL _expand_key_256a<>(SB)
AESKEYGENASSIST $0x02, X0, X1
CALL _expand_key_256b<>(SB)
AESKEYGENASSIST $0x04, X2, X1
CALL _expand_key_256a<>(SB)
AESKEYGENASSIST $0x04, X0, X1
CALL _expand_key_256b<>(SB)AESKEYGENASSIST takes the round constant as an immediate — $0x01, $0x02, $0x04,
up to $0x40 — and produces the SubWord-and-RotWord intermediate value. The rest of the
expansion (the SubWord, the XOR chain, the special 256b path that mixes in the S-box
output for AES-256) is hand-written SSE code.
The decryption key schedule is derived from the encryption one with AESIMC, which
applies the inverse MixColumns to each round key — that is why decryption is a straight
AESDEC chain with the same shape as encryption.
The assembly is generated
One more thing worth knowing: this .s file is not hand-written. It is generated by
Avo, from a Go program that lives in the tree:
// src/crypto/internal/fips140/aes/_asm/standard/aes_amd64.go:42
func encryptBlockAsm() {
Implement("encryptBlockAsm")
Attributes(NOSPLIT)
AllocLocal(0)
Load(Param("nr"), RCX)
Load(Param("xk"), RAX)
Load(Param("dst"), RDX)
Load(Param("src"), RBX)
MOVUPS(Mem{Base: AX}.Offset(0), X1)
MOVUPS(Mem{Base: BX}.Offset(0), X0)
ADDQ(Imm(16), RAX)
PXOR(X1, X0)
SUBQ(Imm(12), RCX)
JE(LabelRef("Lenc192"))
JB(LabelRef("Lenc128"))
Label("Lenc256")
MOVUPS(Mem{Base: AX}.Offset(0), X1)
AESENC(X1, X0)
MOVUPS(Mem{Base: AX}.Offset(16), X1)
AESENC(X1, X0)
ADDQ(Imm(32), RAX)The generated file even says so in its first line:
// src/crypto/internal/fips140/aes/aes_amd64.s:1
// Code generated by command: go run asm_amd64.go -out ../../asm_amd64.s -pkg aes. DO NOT EDIT.Writing assembly in Go means the AES-NI sequence is type-checked, and the same trick is
used for the SHA-256 AVX2 implementation, which is generated by
sha256block_amd64_asm.go.
The modes: CBC, CTR and GCM
AES is a block cipher; the modes of operation are what people actually use.
CBC is done in plain Go, one block at a time, because it is inherently serial:
// src/crypto/internal/fips140/aes/cbc.go:50
func cryptBlocksEncGeneric(b *Block, civ *[BlockSize]byte, dst, src []byte) {
iv := civ[:]
for len(src) > 0 {
// Write the xor to dst, then encrypt in place.
subtle.XORBytes(dst[:BlockSize], src[:BlockSize], iv)
encryptBlock(b, dst[:BlockSize], dst[:BlockSize])
// Move to the next block with this block as the next iv.
iv = dst[:BlockSize]
src = src[BlockSize:]
dst = dst[BlockSize:]CTR is different: it is parallelisable, because each counter block is independent. Go exploits that with eight-block and four-block assembly routines:
// src/crypto/internal/fips140/aes/ctr.go:90
for len(src) >= 8*BlockSize {
ctrBlocks8(&c.b, (*[8 * BlockSize]byte)(dst), (*[8 * BlockSize]byte)(src), ivlo, ivhi)
src = src[8*BlockSize:]
dst = dst[8*BlockSize:]
ivlo, ivhi = add128(ivlo, ivhi, 8)
}
// The tail can have at most 7 = 4 + 2 + 1 blocks.
if len(src) >= 4*BlockSize {
ctrBlocks4(&c.b, (*[4 * BlockSize]byte)(dst), (*[4 * BlockSize]byte)(src), ivlo, ivhi)
src = src[4*BlockSize:]
dst = dst[4*BlockSize:]
ivlo, ivhi = add128(ivlo, ivhi, 4)
}and the assembly keeps four independent AES pipelines in flight, which is exactly the shape AES-NI wants:
// src/crypto/internal/fips140/aes/ctr_amd64.s:187
MOVUPS (CX), X0
AESENC X0, X1
AESENC X0, X2
AESENC X0, X3
AESENC X0, X4
MOVUPS 16(CX), X0
AESENC X0, X1
AESENC X0, X2
AESENC X0, X3
AESENC X0, X4GCM needs both AES and a fast GF(2^128) multiply, which is where PCLMULQDQ (carryless
multiply) comes in:
// src/crypto/internal/fips140/aes/gcm/gcm_asm.go:33
// Keep in sync with crypto/tls.hasAESGCMHardwareSupport.
var supportsAESGCM = cpu.X86HasAES && cpu.X86HasPCLMULQDQ && cpu.X86HasSSE41 && cpu.X86HasSSSE3 ||
cpu.ARM64HasAES && cpu.ARM64HasPMULL
var supportsAESGCMEOR3 = supportsAESGCM && cpu.ARM64HasSHA3On x86 that means AES-NI plus CLMUL plus SSSE3 (for the byte shuffles) plus SSE4.1. Without them, GCM falls back to a bit-by-bit GF(2^128) implementation — dramatically slower, and the reason the AES package’s doc comment is careful to note that on such systems GCM is not constant-time either.
SHA-256 with SHA-NI
SHA-256 gets two hardware paths and one software path, chosen like this:
// src/crypto/internal/fips140/sha256/sha256block_amd64.go:14
var useAVX2 = cpu.X86HasAVX && cpu.X86HasAVX2 && cpu.X86HasBMI2
var useSHANI = cpu.X86HasAVX && cpu.X86HasSHA && cpu.X86HasSSE41 && cpu.X86HasSSSE3
func init() {
impl.Register("sha256", "AVX2", &useAVX2)
impl.Register("sha256", "SHA-NI", &useSHANI)
}
//go:noescape
func blockAVX2(dig *Digest, p []byte)
//go:noescape
func blockSHANI(dig *Digest, p []byte)
func block(dig *Digest, p []byte) {
if useSHANI {
blockSHANI(dig, p)
} else if useAVX2 {
blockAVX2(dig, p)
} else {
blockGeneric(dig, p)
}
}SHA-NI wins when available. The SHA256RNDS2 instruction performs two rounds of the
compression function in one go, and the hardware also implements the message schedule
partially, in SHA256MSG1 and SHA256MSG2.
The prologue shows the layout. The digest state is shuffled into two XMM registers in a big-endian-friendly arrangement, and the round constants are addressed through a pointer:
// src/crypto/internal/fips140/sha256/sha256block_amd64.s:1289
// func blockSHANI(dig *Digest, p []byte)
// Requires: AVX, SHA, SSE2, SSE4.1, SSSE3
TEXT ·blockSHANI(SB), $0-32
MOVQ dig+0(FP), DI
MOVQ p_base+8(FP), SI
MOVQ p_len+16(FP), DX
SHRQ $0x06, DX
SHLQ $0x06, DX
CMPQ DX, $0x00
JEQ done
ADDQ SI, DX
VMOVDQU (DI), X1
VMOVDQU 16(DI), X2
PSHUFD $0xb1, X1, X1
PSHUFD $0x1b, X2, X2
VMOVDQA X1, X7
PALIGNR $0x08, X2, X1
PBLENDW $0xf0, X7, X2
VMOVDQA flip_mask<>+0(SB), X8
LEAQ K256<>+0(SB), AX
roundLoop:
// save hash values for addition after rounds
VMOVDQA X1, X9
VMOVDQA X2, X10
// do rounds 0-59
VMOVDQU (SI), X0
PSHUFB X8, X0
VMOVDQA X0, X3
PADDD (AX), X0
SHA256RNDS2 X0, X1, X2
PSHUFD $0x0e, X0, X0
SHA256RNDS2 X0, X2, X1The PSHUFB X8, X0 is the byte-swap: SHA-256 is defined over big-endian words, x86 is
little-endian, so the message block is flipped on the fly using a mask table.
The loop body is structured in groups of four message words. Each group does two
SHA256RNDS2 instructions (four rounds), and the message schedule is advanced with
SHA256MSG1 and SHA256MSG2:
// src/crypto/internal/fips140/sha256/sha256block_amd64.s:1330
SHA256MSG1 X4, X3
VMOVDQU 32(SI), X0
PSHUFB X8, X0
VMOVDQA X0, X5
PADDD 64(AX), X0
SHA256RNDS2 X0, X1, X2
PSHUFD $0x0e, X0, X0
SHA256RNDS2 X0, X2, X1
SHA256MSG1 X5, X4The PSHUFD $0x0e, X0, X0 between the two SHA256RNDS2 instructions is the trick that
makes the four-round group work: SHA256RNDS2 consumes the message schedule word it is
given and produces a partial sum, and the shuffle redistributes the intermediate state so
that the second instruction in the pair operates on the right half of the state.
The last four rounds are peeled out of the loop, because they are different — no message scheduling, just the final additions:
// src/crypto/internal/fips140/sha256/sha256block_amd64.s:1460
// do rounds 60-63
VMOVDQA X6, X0
PADDD 480(AX), X0
SHA256RNDS2 X0, X1, X2
PSHUFD $0x0e, X0, X0
SHA256RNDS2 X0, X2, X1
// add current hash values with previously saved
PADDD X9, X1
PADDD X10, X2
// advance data pointer; loop until buffer empty
ADDQ $0x40, SI
CMPQ DX, SI
JNE roundLoopSixteen SHA256RNDS2 per 64-byte block — sixty-four rounds, two rounds per instruction —
plus the message schedule in hardware. On a modern x86 that is roughly a cycle per
round, which is the theoretical limit for SHA-256.
The AVX2 path (blockAVX2) is the older trick: it processes four 64-byte blocks at once
by transposing them into a 4x4 matrix of words and running the compression function
four times in parallel with AVX2 vector operations. It is still useful on machines with
AVX2 but no SHA-NI.
The pure-Go fallback, and why it matters
If the CPU has no AES-NI, the package falls back to a table-driven implementation. This is the classic T-table approach:
// src/crypto/internal/fips140/aes/aes_generic.go:41
// Encrypt one block from src into dst, using the expanded key xk.
func encryptBlockGeneric(c *blockExpanded, dst, src []byte) {
checkGenericIsExpected()
xk := c.enc[:]
_ = src[15] // early bounds check
s0 := byteorder.BEUint32(src[0:4])
s1 := byteorder.BEUint32(src[4:8])
s2 := byteorder.BEUint32(src[8:12])
s3 := byteorder.BEUint32(src[12:16])
// First round just XORs input with key.
s0 ^= xk[0]
s1 ^= xk[1]
s2 ^= xk[2]
s3 ^= xk[3]
// Middle rounds shuffle using tables.
k := 4
var t0, t1, t2, t3 uint32
for r := 0; r < c.rounds-1; r++ {
t0 = xk[k+0] ^ te0[uint8(s0>>24)] ^ te1[uint8(s1>>16)] ^ te2[uint8(s2>>8)] ^ te3[uint8(s3)]
t1 = xk[k+1] ^ te0[uint8(s1>>24)] ^ te1[uint8(s2>>16)] ^ te2[uint8(s3>>8)] ^ te3[uint8(s0)]
t2 = xk[k+2] ^ te0[uint8(s2>>24)] ^ te1[uint8(s3>>16)] ^ te2[uint8(s0>>8)] ^ te3[uint8(s1)]
t3 = xk[k+3] ^ te0[uint8(s3>>24)] ^ te1[uint8(s0>>16)] ^ te2[uint8(s1>>8)] ^ te3[uint8(s2)]
k += 4
s0, s1, s2, s3 = t0, t1, t2, t3
}The four 256-entry tables (te0 through te3, in const.go) fold SubBytes,
ShiftRows and MixColumns into a single table lookup per byte. It is fast — a few hundred
MB/s — but the table index depends on the secret key material, so the memory access
pattern leaks it through the cache.
That is why the package doc says this:
// src/crypto/aes/aes.go:5
// Package aes implements AES encryption (formerly Rijndael), as defined in
// U.S. Federal Information Processing Standards Publication 197.
//
// The AES operations in this package are not implemented using constant-time algorithms.
// An exception is when running on systems with enabled hardware support for AES
// that makes these operations constant-time. Examples include amd64 systems using AES-NI
// extensions and s390x systems using Message-Security-Assist extensions.
// On such systems, when the result of NewCipher is passed to cipher.NewGCM,
// the GHASH operation used by GCM is also constant-time.
package aesAnd it is why the assembly build has a tripwire: if the hardware path is available but the generic path runs anyway, the code panics rather than silently losing the constant-time property.
// src/crypto/internal/fips140/aes/aes_asm.go:49
// checkGenericIsExpected is called by the variable-time implementation to make
// sure it is not used when hardware support is available. It shouldn't happen,
// but this way it's more evidently correct.
func checkGenericIsExpected() {
if supportsAES {
panic("crypto/aes: internal error: using generic implementation despite hardware support")
}
}The BoringCrypto path
There is a third implementation, and it is not Go at all. crypto/internal/boring wraps
BoringSSL, and the public packages check a build-time constant before doing anything
else:
// src/crypto/internal/boring/doc.go:10
// Enabled reports whether BoringCrypto is available.
// When enabled is false, all functions in this package panic.
//
// BoringCrypto is only available on linux/amd64 and linux/arm64 systems.
const Enabled = availableboring.Enabled is available, which is false in the default build and true when
the tree is built with the boringcrypto build tag:
// src/crypto/internal/boring/boring.go:5
//go:build boringcrypto && linux && (amd64 || arm64) && !android && !msan
package boring
/*
// goboringcrypto_linux_amd64.syso references pthread functions.
#cgo LDFLAGS: "-pthread"
#include "goboringcrypto.h"
*/
import "C"
import (
"crypto/internal/boring/sig"
_ "crypto/internal/boring/syso"
"crypto/internal/fips140"
"internal/stringslite"
"math/bits"
"unsafe"
)
const available = trueWhen it is enabled, crypto/aes.NewCipher returns a BoringSSL cipher instead, and
crypto/sha256.New returns a BoringSSL hash. That build is used for FIPS-certified
systems that need a validated cryptographic module. It cannot be combined with the
Go 1.24+ fips140 mode:
// src/crypto/internal/boring/boring.go:35
func init() {
if fips140.Enabled {
panic("boringcrypto: cannot use GODEBUG=fips140 with GOEXPERIMENT=boringcrypto")
}
}So there are three worlds: the default Go implementation with hardware acceleration, the
purego build with no assembly at all, and the BoringCrypto build with a C
implementation underneath.
Trade-offs
What the hardware buys:
- AES: roughly 1 cycle per round on a modern x86, versus roughly 10-20 cycles per round for the T-table fallback. That is a 10-20x gap, and it is why TLS throughput on a server is usually not the bottleneck.
- SHA-256 with SHA-NI: about 1.5 cycles per byte, versus about 11 for the AVX2 path and about 15-20 for the generic Go implementation.
- Constant time. AES-NI and SHA-NI have no data-dependent timing. The fallback does.
What it costs:
- Build complexity. Every primitive has an assembly file, a generic file, and build tags to choose between them, for eight architectures.
- A dispatch decision at every call.
encryptBlockbranches onsupportsAES— a global bool, so the branch predicts perfectly, but it is a branch. - Portability. The
puregotag exists because some environments (some emulators, some kernels, some reproducible-build setups) cannot tolerate platform-specific code.
Caveats
The generic fallback is not constant-time. If you are defending against a local attacker who can measure cache timings, the T-table implementation leaks. The package says so, but only in the doc comment, which most people never read.
GCM needs more than AES-NI. supportsAESGCM requires PCLMULQDQ and SSSE3 as
well. A CPU with AES-NI but without CLMUL gets fast AES and slow GCM — and the
constant-time guarantee for GHASH goes away too.
SHA-NI is not universal. It arrived with Ice Lake server and Alder Lake client. On older Intel and on AMD Zen 1 and 2, the AVX2 path runs instead.
purego is slower than you think. It is not “the same algorithm, less optimised” —
it is a different, slower algorithm with different security properties. The FIPS module
refuses to run under purego for exactly this reason:
// src/crypto/internal/fips140/fips140.go:36
// The purego tag changes too much of the implementation to claim the
// validation still applies.
if puregoEnabled {
return errors.New("FIPS 140-3 mode is incompatible with the purego build tag")
}The assembly is generated. If you are reading aes_amd64.s and cannot follow it,
read _asm/standard/aes_amd64.go instead — it is the same program in a language you
already know.
BoringCrypto and fips140 are mutually exclusive. Both want to own the
cryptographic implementation, and the tree panics at init if you try to have both.
crypto/aes is not where the implementation is. If you are grepping for the AES
assembly, look under crypto/internal/fips140/aes/, not crypto/aes/.
Wrapping up
The one thing to remember: crypto/aes and crypto/sha256 are shims. The real
implementations are in crypto/internal/fips140, and the dispatch is a pair of
CPUID checks made once at start-up: AES-NI for the cipher, SHA-NI for the hash.
The part people get wrong: assuming the pure-Go fallback is equivalent. It is not constant-time, and on a machine without the hardware instructions it is one to two orders of magnitude slower — and GCM falls back to a bit-by-bit GF(2^128) multiply.
What to go do about it: run crypto/internal/impl’s registry to see what your
machine actually selected, and compare crypto/aes against the purego build to see
the gap on your own hardware. If you are deploying to a fleet, check the oldest CPU’s
feature bits, because the fallback is where the constant-time guarantee disappears.
Further reading
src/crypto/aes/aes.go— the public shim, and the doc comment about constant time.src/crypto/sha256/sha256.go— the same for SHA-256, including the BoringCrypto branch.src/crypto/internal/fips140/aes/aes_amd64.s— the generated AES-NI assembly:encryptBlockAsm,decryptBlockAsm,expandKeyAsm.src/crypto/internal/fips140/aes/_asm/standard/aes_amd64.go— the Avo program that generates the assembly above.src/crypto/internal/fips140/aes/aes_asm.go—supportsAES, the dispatch, and thecheckGenericIsExpectedtripwire.src/crypto/internal/fips140/aes/aes_generic.go— the T-table fallback.src/crypto/internal/fips140/aes/const.go— the S-boxes and thete0–te3tables.src/crypto/internal/fips140/aes/ctr_amd64.s— four- and eight-block parallel CTR.src/crypto/internal/fips140/aes/gcm/gcm_asm.go— the AES-NI plus CLMUL GCM path.src/crypto/internal/fips140/sha256/sha256block_amd64.s— the SHA-NI and AVX2 implementations.src/crypto/internal/fips140/sha256/sha256block_amd64.go— the SHA-256 dispatch.src/internal/cpu/cpu_x86.go— theCPUIDfeature detection.src/crypto/internal/impl/impl.go— the registry of alternative implementations.src/crypto/internal/boring/boring.go— the BoringCrypto build.