Kyber-K2SO is Symbolic Software’s clean Go implementation of the post-quantum key encapsulation mechanism that won the NIST post-quantum cryptography competition. Today, we’re releasing version 1.0, which upgrades the implementation from Kyber v3 to ML-KEM (FIPS 203), the official NIST standard finalized in August 2024.

Why ML-KEM?

While Kyber was the competition submission, ML-KEM is the standardized version. The two are closely related but not identical. ML-KEM incorporates refinements based on years of cryptanalysis and implementation feedback. Most importantly, ML-KEM is the version that will be widely deployed in protocols like TLS 1.3 and other security-critical applications.

By upgrading to ML-KEM, Kyber-K2SO ensures interoperability with other conforming implementations and alignment with the official NIST standard.

What’s New in Version 1.0

ML-KEM (FIPS 203) Compliance

The core implementation now follows the FIPS 203 specification. We’ve replaced the old Kyber v3 test vectors with the official ML-KEM test vectors from C2SP/CCTV, which are the reference intermediate test vectors for FIPS 203 compliance testing:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func TestMLKEM768Vector(t *testing.T) {
	dkBytes, err := hex.DecodeString(mlkem768TestVector.dk)
	if err != nil {
		t.Fatal(err)
	}
	cBytes, err := hex.DecodeString(mlkem768TestVector.c)
	if err != nil {
		t.Fatal(err)
	}
	expectedK, err := hex.DecodeString(mlkem768TestVector.K)
	if err != nil {
		t.Fatal(err)
	}

	var dk [Kyber768SKBytes]byte
	var c [Kyber768CTBytes]byte
	copy(dk[:], dkBytes)
	copy(c[:], cBytes)

	K, err := KemDecrypt768(c, dk)
	if err != nil {
		t.Fatal(err)
	}

	if subtle.ConstantTimeCompare(K[:], expectedK) == 0 {
		t.Errorf("ML-KEM-768 test vector failed\nExpected: %x\nGot: %x", expectedK, K[:])
	}
}

All three security levels pass their respective test vectors: ML-KEM-512, ML-KEM-768, and ML-KEM-1024.

Best-Effort Secret Zeroization

Cryptographic implementations should clear sensitive data from memory as soon as it’s no longer needed. Version 1.0 introduces systematic zeroization of secrets and intermediate values throughout the KEM operations:

1
2
3
4
5
6
// byteopsZeroBytes zeroes a byte slice to clear sensitive data from memory.
func byteopsZeroBytes(b []byte) {
	for i := range b {
		b[i] = 0
	}
}

This function is now called after encryption and decryption operations to clear intermediate values like the message hash, key derivation inputs, and the implicit rejection key:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func KemEncrypt768(publicKey [Kyber768PKBytes]byte) (
	[Kyber768CTBytes]byte, [KyberSSBytes]byte, error,
) {
	// ... encryption logic ...

	byteopsZeroBytes(d[:])
	byteopsZeroBytes(m[:])
	byteopsZeroBytes(krInput[:])
	byteopsZeroBytes(kr[:])
	return ciphertextFixedLength, sharedSecretFixedLength, err
}

Additionally, if random number generation fails during key generation, the partially-constructed private key is now explicitly zeroed before returning an error:

1
2
3
4
5
6
7
_, err = rand.Read(privateKeyFixedLength[skStart:])
if err != nil {
	for i := range privateKeyFixedLength {
		privateKeyFixedLength[i] = 0
	}
	return privateKeyFixedLength, publicKeyFixedLength, err
}

We call this “best-effort” because Go’s garbage collector and compiler optimizations may still leave copies of sensitive data in memory. However, explicit zeroization remains a valuable defense-in-depth measure.

Defense in Depth: Bounded Rejection Sampling

The matrix generation routine uses rejection sampling to uniformly sample polynomial coefficients. While the probability of needing more than one iteration is astronomically low (approximately 10^-82), version 1.0 adds an explicit iteration bound as a safety measure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
r[i][j], ctr = indcpaRejUniform(buf[:504], 504, paramsN)
// Retry with remaining buffer bytes if needed
// Bound iterations as a safety measure (probability of needing >1 iteration is ~10^-82)
for iterations := 0; ctr < paramsN && iterations < 100; iterations++ {
	missing, ctrn := indcpaRejUniform(buf[504:], 168, paramsN-ctr)
	for k := ctr; k < paramsN; k++ {
		r[i][j][k] = missing[k-ctr]
	}
	ctr += ctrn
}

This prevents any theoretical infinite loop scenario, even though such a scenario is practically impossible with a correctly functioning random number generator.

Performance Improvements

Version 1.0 includes several performance optimizations that reduce heap allocations and improve efficiency:

Fixed-size arrays instead of slices: Internal buffers are now declared as fixed-size arrays where possible, reducing pressure on the garbage collector:

1
2
3
4
5
// Before
buf := make([]byte, 672)

// After
var buf [672]byte

Pre-computed Barrett reduction constant: The Barrett reduction now uses a pre-computed constant rather than computing it at runtime:

1
2
3
4
5
6
// Before
var v int16 = int16(((uint32(1) << 26) + uint32(paramsQ/2)) / uint32(paramsQ))
t = int16(int32(v) * int32(a) >> 26)

// After
t := int16((int32(paramsBarrettV) * int32(a)) >> 26)

Streamlined key construction: Private key assembly now uses direct copy operations into the fixed-length output array, eliminating intermediate slice allocations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Before
privateKey := append(indcpaPrivateKey, indcpaPublicKey...)
privateKey = append(privateKey, pkh[:]...)
privateKey = append(privateKey, rnd...)
copy(privateKeyFixedLength[:], privateKey)

// After
skStart := copy(privateKeyFixedLength[:], indcpaPrivateKey)
skStart += copy(privateKeyFixedLength[skStart:], indcpaPublicKey)
skStart += copy(privateKeyFixedLength[skStart:], pkh[:])
_, err = rand.Read(privateKeyFixedLength[skStart:])

These changes result in measurably faster operations across all security levels.

Code Quality Improvements

The polynomial type definition has been corrected to use the semantically appropriate constant:

1
2
3
4
5
// Before
type poly [paramsPolyBytes]int16

// After
type poly [paramsN]int16

Since paramsN (256) represents the polynomial degree and is the actual dimension of coefficient arrays, this change makes the code more self-documenting without affecting functionality.

Upgrading

To upgrade to version 1.0:

1
go get -u github.com/symbolicsoft/kyber-k2so

The API remains unchanged. If you were previously using Kyber-K2SO with Kyber v3, your code will continue to work without modification. However, the key material and ciphertexts are now ML-KEM format, which means they are not interoperable with Kyber v3 implementations.

Acknowledgements

We thank the C2SP project for maintaining the official test vectors that made compliance testing straightforward.

Want to work together?

Choose Symbolic Software as your trusted partner in enhancing security and fostering integrity within the digital ecosystem.

Start a Project