|
@@ -0,0 +1,208 @@
|
|
|
|
|
+package handshake
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "bytes"
|
|
|
|
|
+ "crypto/mlkem"
|
|
|
|
|
+ "net/netip"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+ "time"
|
|
|
|
|
+
|
|
|
|
|
+ "github.com/flynn/noise"
|
|
|
|
|
+ "github.com/slackhq/nebula/cert"
|
|
|
|
|
+ ct "github.com/slackhq/nebula/cert_test"
|
|
|
|
|
+ "github.com/slackhq/nebula/header"
|
|
|
|
|
+ "github.com/stretchr/testify/assert"
|
|
|
|
|
+ "github.com/stretchr/testify/require"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+func newKEMMachine(t *testing.T, cs *testCertState, v CertVerifier, initiator bool, index uint32) *Machine {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ m, err := NewMachine(
|
|
|
|
|
+ cs.version, cs.getCredential, v,
|
|
|
|
|
+ func() (uint32, error) { return index, nil },
|
|
|
|
|
+ initiator, header.HandshakeIXPSK2MLKEM1024,
|
|
|
|
|
+ )
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ return m
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type kemFixture struct {
|
|
|
|
|
+ initCS, respCS *testCertState
|
|
|
|
|
+ v CertVerifier
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func newKEMFixture(t *testing.T, cipher noise.CipherFunc) kemFixture {
|
|
|
|
|
+ ca, _, caKey, _ := ct.NewTestCaCert(
|
|
|
|
|
+ cert.Version2, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil,
|
|
|
|
|
+ )
|
|
|
|
|
+ return kemFixture{
|
|
|
|
|
+ initCS: newTestCertStateWithCipher(t, ca, caKey, "initiator", []netip.Prefix{netip.MustParsePrefix("10.0.0.1/24")}, cipher),
|
|
|
|
|
+ respCS: newTestCertStateWithCipher(t, ca, caKey, "responder", []netip.Prefix{netip.MustParsePrefix("10.0.0.2/24")}, cipher),
|
|
|
|
|
+ v: testVerifier(ct.NewTestCAPool(ca)),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func requireKeysMatch(t *testing.T, a, b *Result) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ c, err := a.EKey.Encrypt(nil, nil, []byte("hello"))
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ p, err := b.DKey.Decrypt(nil, nil, c)
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ assert.Equal(t, []byte("hello"), p)
|
|
|
|
|
+
|
|
|
|
|
+ c, err = b.EKey.Encrypt(nil, nil, []byte("world"))
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ p, err = a.DKey.Decrypt(nil, nil, c)
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ assert.Equal(t, []byte("world"), p)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestKEMHandshake(t *testing.T) {
|
|
|
|
|
+ for _, cipher := range []noise.CipherFunc{noise.CipherChaChaPoly, noise.CipherAESGCM} {
|
|
|
|
|
+ t.Run(cipher.CipherName(), func(t *testing.T) {
|
|
|
|
|
+ f := newKEMFixture(t, cipher)
|
|
|
|
|
+ initM := newKEMMachine(t, f.initCS, f.v, true, 1000)
|
|
|
|
|
+ respM := newKEMMachine(t, f.respCS, f.v, false, 2000)
|
|
|
|
|
+
|
|
|
|
|
+ msg1, err := initM.Initiate(nil)
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ assert.Equal(t, header.HandshakeIXPSK2MLKEM1024, header.MessageSubType(msg1[1]))
|
|
|
|
|
+ assert.Greater(t, len(msg1), header.Len+mlkem.EncapsulationKeySize1024)
|
|
|
|
|
+
|
|
|
|
|
+ msg2, respR, err := respM.ProcessPacket(nil, msg1)
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ require.NotNil(t, respR)
|
|
|
|
|
+ assert.Greater(t, len(msg2), header.Len+mlkem.CiphertextSize1024)
|
|
|
|
|
+
|
|
|
|
|
+ _, initR, err := initM.ProcessPacket(nil, msg2)
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ require.NotNil(t, initR)
|
|
|
|
|
+
|
|
|
|
|
+ assert.Equal(t, "responder", initR.RemoteCert.Certificate.Name())
|
|
|
|
|
+ assert.Equal(t, "initiator", respR.RemoteCert.Certificate.Name())
|
|
|
|
|
+ assert.Equal(t, uint32(2000), initR.RemoteIndex)
|
|
|
|
|
+ assert.Equal(t, uint32(1000), respR.RemoteIndex)
|
|
|
|
|
+ requireKeysMatch(t, initR, respR)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestKEMSecretMatters checks the ML-KEM secret really reaches the transport
|
|
|
|
|
+// keys: an initiator holding a different decapsulation key cannot complete even
|
|
|
|
|
+// though every DH input is genuine.
|
|
|
|
|
+func TestKEMSecretMatters(t *testing.T) {
|
|
|
|
|
+ f := newKEMFixture(t, noise.CipherChaChaPoly)
|
|
|
|
|
+ initM := newKEMMachine(t, f.initCS, f.v, true, 1000)
|
|
|
|
|
+ respM := newKEMMachine(t, f.respCS, f.v, false, 2000)
|
|
|
|
|
+
|
|
|
|
|
+ msg1, err := initM.Initiate(nil)
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ msg2, _, err := respM.ProcessPacket(nil, msg1)
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+
|
|
|
|
|
+ other, err := mlkem.GenerateKey1024()
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ initM.kemKey = other
|
|
|
|
|
+
|
|
|
|
|
+ _, res, err := initM.ProcessPacket(nil, msg2)
|
|
|
|
|
+ require.Error(t, err)
|
|
|
|
|
+ assert.Nil(t, res)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestKEMTamperedCiphertext(t *testing.T) {
|
|
|
|
|
+ f := newKEMFixture(t, noise.CipherChaChaPoly)
|
|
|
|
|
+ initM := newKEMMachine(t, f.initCS, f.v, true, 1000)
|
|
|
|
|
+ respM := newKEMMachine(t, f.respCS, f.v, false, 2000)
|
|
|
|
|
+
|
|
|
|
|
+ msg1, err := initM.Initiate(nil)
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ msg2, respR, err := respM.ProcessPacket(nil, msg1)
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+
|
|
|
|
|
+ t.Run("truncated", func(t *testing.T) {
|
|
|
|
|
+ _, res, err := initM.ProcessPacket(nil, msg2[:header.Len+mlkem.CiphertextSize1024-1])
|
|
|
|
|
+ require.ErrorIs(t, err, ErrPacketTooShort)
|
|
|
|
|
+ assert.Nil(t, res)
|
|
|
|
|
+ assert.False(t, initM.Failed())
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ t.Run("flipped ciphertext bit", func(t *testing.T) {
|
|
|
|
|
+ bad := bytes.Clone(msg2)
|
|
|
|
|
+ bad[header.Len+10] ^= 1
|
|
|
|
|
+ _, res, err := initM.ProcessPacket(nil, bad)
|
|
|
|
|
+ require.Error(t, err)
|
|
|
|
|
+ assert.Nil(t, res)
|
|
|
|
|
+ assert.False(t, initM.Failed(), "a forged ciphertext must not kill the handshake")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ t.Run("genuine response still completes", func(t *testing.T) {
|
|
|
|
|
+ _, initR, err := initM.ProcessPacket(nil, msg2)
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ require.NotNil(t, initR)
|
|
|
|
|
+ requireKeysMatch(t, initR, respR)
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestKEMBadMsg1 drives processPayload by hand. With the psk modifier msg1's
|
|
|
|
|
+// payload is encrypted under a key derived from the public ephemeral, so a
|
|
|
|
|
+// forged payload would need re-encrypting; the parsing rules are what matter.
|
|
|
|
|
+func TestKEMBadMsg1(t *testing.T) {
|
|
|
|
|
+ f := newKEMFixture(t, noise.CipherChaChaPoly)
|
|
|
|
|
+ msg1Flags := subtypeInfos[header.HandshakeIXPSK2MLKEM1024].msgs[0]
|
|
|
|
|
+ cert := f.initCS.creds[cert.Version2]
|
|
|
|
|
+ payload := func(kemKey []byte) []byte {
|
|
|
|
|
+ return MarshalPayload(nil, Payload{
|
|
|
|
|
+ Cert: cert.Bytes,
|
|
|
|
|
+ CertVersion: uint32(cert.Cert.Version()),
|
|
|
|
|
+ InitiatorIndex: 1000,
|
|
|
|
|
+ Time: 1,
|
|
|
|
|
+ KEMKey: kemKey,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ dk, err := mlkem.GenerateKey1024()
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+
|
|
|
|
|
+ t.Run("missing KEM key", func(t *testing.T) {
|
|
|
|
|
+ m := newKEMMachine(t, f.respCS, f.v, false, 2000)
|
|
|
|
|
+ err := m.processPayload(payload(nil), msg1Flags)
|
|
|
|
|
+ require.ErrorIs(t, err, ErrUnexpectedContent)
|
|
|
|
|
+ assert.True(t, m.Failed())
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ t.Run("invalid KEM key", func(t *testing.T) {
|
|
|
|
|
+ m := newKEMMachine(t, f.respCS, f.v, false, 2000)
|
|
|
|
|
+ // Coefficients of 0xfff exceed the modulus, which ML-KEM must reject.
|
|
|
|
|
+ bad := bytes.Repeat([]byte{0xff}, mlkem.EncapsulationKeySize1024)
|
|
|
|
|
+ err := m.kemEncapsulate(bad)
|
|
|
|
|
+ require.ErrorIs(t, err, ErrInvalidKEMKey)
|
|
|
|
|
+ err = m.kemEncapsulate(bad[:100])
|
|
|
|
|
+ require.ErrorIs(t, err, ErrInvalidKEMKey)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ t.Run("valid KEM key", func(t *testing.T) {
|
|
|
|
|
+ m := newKEMMachine(t, f.respCS, f.v, false, 2000)
|
|
|
|
|
+ require.NoError(t, m.kemEncapsulate(dk.EncapsulationKey().Bytes()))
|
|
|
|
|
+ assert.Len(t, m.kemCiphertext, mlkem.CiphertextSize1024)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ t.Run("KEM key on the classical subtype", func(t *testing.T) {
|
|
|
|
|
+ m := newTestMachine(t, f.respCS, f.v, false, 2000)
|
|
|
|
|
+ classical := subtypeInfos[header.HandshakeIXPSK0].msgs[0]
|
|
|
|
|
+ err := m.processPayload(payload(dk.EncapsulationKey().Bytes()), classical)
|
|
|
|
|
+ require.ErrorIs(t, err, ErrUnexpectedContent)
|
|
|
|
|
+ assert.True(t, m.Failed())
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestKEMDistinctFromClassical makes sure the two subtypes cannot be mixed: a
|
|
|
|
|
+// classical responder cannot answer a KEM initiator.
|
|
|
|
|
+func TestKEMDistinctFromClassical(t *testing.T) {
|
|
|
|
|
+ f := newKEMFixture(t, noise.CipherChaChaPoly)
|
|
|
|
|
+ initM := newKEMMachine(t, f.initCS, f.v, true, 1000)
|
|
|
|
|
+ respM := newTestMachine(t, f.respCS, f.v, false, 2000)
|
|
|
|
|
+
|
|
|
|
|
+ msg1, err := initM.Initiate(nil)
|
|
|
|
|
+ require.NoError(t, err)
|
|
|
|
|
+ _, _, err = respM.ProcessPacket(nil, msg1)
|
|
|
|
|
+ require.ErrorIs(t, err, ErrSubtypeMismatch)
|
|
|
|
|
+}
|