diff --git a/handshake/credential.go b/handshake/credential.go index 2b47ce95..c5476b6f 100644 --- a/handshake/credential.go +++ b/handshake/credential.go @@ -14,39 +14,61 @@ type Credential struct { Cert cert.Certificate // the certificate Bytes []byte // pre-marshaled certificate bytes privateKey []byte // static private key (public half lives in Cert) + publicKey []byte // static public key, the classical half for a v3 Cert + kemKey noise.KEMKey // static ML-KEM-1024 keypair, only for a v3 Cert cipherSuite noise.CipherSuite // pre-built cipher suite (DH + cipher + hash) } // NewCredential creates a Credential with all material needed for handshake // participation. The cipherSuite should be pre-built by the caller with the // appropriate DH function, cipher, and hash. +// +// For a version 3 certificate privateKey is the composite private key, +// classical || ML-KEM-1024 seed, matching the composite Cert.PublicKey(). func NewCredential( c cert.Certificate, hsBytes []byte, privateKey []byte, cipherSuite noise.CipherSuite, ) *Credential { - return &Credential{ + hc := &Credential{ Cert: c, Bytes: hsBytes, privateKey: privateKey, + publicKey: c.PublicKey(), cipherSuite: cipherSuite, } + if v2 := cert.V2Form(c); v2 != nil { + classical := v2.PublicKey() + hc.publicKey = classical + hc.kemKey = noise.KEMKey{ + Private: privateKey[len(privateKey)-cert.MLKEM1024PrivateKeySize:], + Public: c.PublicKey()[len(classical):], + } + hc.privateKey = privateKey[:len(privateKey)-cert.MLKEM1024PrivateKeySize] + } + return hc } // buildHandshakeState creates a noise.HandshakeState from this credential. // pskPlacement selects the noise psk modifier; 0 means none. A psk for -// placements 2 and up is supplied later with SetPresharedKey. -func (hc *Credential) buildHandshakeState(initiator bool, pattern noise.HandshakePattern, pskPlacement int) (*noise.HandshakeState, error) { - return noise.NewHandshakeState(noise.Config{ +// placements 2 and up is supplied later with SetPresharedKey. A hybrid +// pattern also gets ML-KEM-1024 and the static ML-KEM key. +func (hc *Credential) buildHandshakeState(initiator bool, info subtypeInfo) (*noise.HandshakeState, error) { + cfg := noise.Config{ CipherSuite: hc.cipherSuite, Random: rand.Reader, - Pattern: pattern, + Pattern: info.pattern, Initiator: initiator, - StaticKeypair: noise.DHKey{Private: hc.privateKey, Public: hc.Cert.PublicKey()}, + StaticKeypair: noise.DHKey{Private: hc.privateKey, Public: hc.publicKey}, PresharedKey: []byte{}, - PresharedKeyPlacement: pskPlacement, - }) + PresharedKeyPlacement: info.pskPlacement, + } + if info.hybrid { + cfg.KEMFunc = KEMMLKEM1024 + cfg.StaticKEMKeypair = hc.kemKey + } + return noise.NewHandshakeState(cfg) } // GetCredentialFunc returns the handshake credential for the given version, diff --git a/handshake/errors.go b/handshake/errors.go index 3bdcc947..2de955da 100644 --- a/handshake/errors.go +++ b/handshake/errors.go @@ -19,4 +19,5 @@ var ( ErrAsymmetricCipherKeys = errors.New("noise produced only one cipher key") ErrMultiMessageUnsupported = errors.New("multi-message handshake patterns are not yet supported by the manager") ErrSubtypeMismatch = errors.New("packet subtype does not match handshake machine subtype") + ErrCertVersionMismatch = errors.New("handshake subtype does not allow this certificate version") ) diff --git a/handshake/hybrid_test.go b/handshake/hybrid_test.go new file mode 100644 index 00000000..a398e185 --- /dev/null +++ b/handshake/hybrid_test.go @@ -0,0 +1,259 @@ +package handshake + +import ( + "crypto/mlkem" + "net/netip" + "testing" + "time" + + "github.com/DefinedNet/noise" + "github.com/slackhq/nebula/cert" + ct "github.com/slackhq/nebula/cert_test" + "github.com/slackhq/nebula/header" + "github.com/slackhq/nebula/noiseutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func hybridCipherSuite(curve cert.Curve) noise.CipherSuite { + if curve == cert.Curve_P256 { + return noise.NewCipherSuite(noiseutil.DHP256, noise.CipherAESGCM, noise.HashSHA256) + } + return noise.NewCipherSuite(noise.DH25519, noise.CipherChaChaPoly, noise.HashSHA256) +} + +// newHybridCertState builds a peer holding a v3 certificate, with credentials for it and for its v2 form, the way +// the pki does. +func newHybridCertState(t *testing.T, curve cert.Curve, ca cert.Certificate, caKey []byte, name string, network string) (*testCertState, []byte) { + t.Helper() + c, _, rawPrivKey, _ := ct.NewTestCert( + cert.Version3, curve, ca, caKey, + name, ca.NotBefore(), ca.NotAfter(), []netip.Prefix{netip.MustParsePrefix(network)}, nil, nil, + ) + priv, _, _, err := cert.UnmarshalPrivateKeyFromPEM(rawPrivKey) + require.NoError(t, err) + return hybridCertStateFor(t, curve, c, priv), priv +} + +func hybridCertStateFor(t *testing.T, curve cert.Curve, c cert.Certificate, priv []byte) *testCertState { + t.Helper() + hs3, err := c.MarshalForHandshakes() + require.NoError(t, err) + v2 := cert.V2Form(c) + hs2, err := v2.MarshalForHandshakes() + require.NoError(t, err) + + ncs := hybridCipherSuite(curve) + return &testCertState{ + version: cert.Version3, + creds: map[cert.Version]*Credential{ + cert.Version3: NewCredential(c, hs3, priv, ncs), + cert.Version2: NewCredential(v2, hs2, priv[:len(priv)-cert.MLKEM1024PrivateKeySize], ncs), + }, + } +} + +func newHybridMachine(t *testing.T, cs *testCertState, verifier CertVerifier, initiator bool, localIndex uint32) *Machine { + t.Helper() + m, err := NewMachine( + cert.Version3, cs.getCredential, + verifier, func() (uint32, error) { return localIndex, nil }, + initiator, header.HandshakeIXHybridMLKEM1024, + ) + require.NoError(t, err) + return m +} + +type hybridPeers struct { + pool *cert.CAPool + ca cert.Certificate + caKey []byte + initCS, respCS *testCertState + initPriv, respPriv []byte +} + +func newHybridPeers(t *testing.T, curve cert.Curve) hybridPeers { + t.Helper() + ca, _, caKey, _ := ct.NewTestCaCert(cert.Version3, curve, time.Now().Add(-time.Hour), time.Now().Add(time.Hour), nil, nil, nil) + pool := cert.NewCAPool() + require.NoError(t, pool.AddCA(ca)) + initCS, initPriv := newHybridCertState(t, curve, ca, caKey, "initiator", "10.0.0.1/24") + respCS, respPriv := newHybridCertState(t, curve, ca, caKey, "responder", "10.0.0.2/24") + return hybridPeers{pool: pool, ca: ca, caKey: caKey, initCS: initCS, respCS: respCS, initPriv: initPriv, respPriv: respPriv} +} + +func TestHybridIX(t *testing.T) { + for _, curve := range []cert.Curve{cert.Curve_CURVE25519, cert.Curve_P256} { + t.Run(curve.String(), func(t *testing.T) { + p := newHybridPeers(t, curve) + v := testVerifier(p.pool) + initM := newHybridMachine(t, p.initCS, v, true, 1000) + respM := newHybridMachine(t, p.respCS, v, false, 2000) + + msg1, err := initM.Initiate(nil) + require.NoError(t, err) + + // The responder answers msg1 but can not complete until msg3. + msg2, respResult, err := respM.ProcessPacket(nil, msg1) + require.NoError(t, err) + require.Nil(t, respResult) + require.NotEmpty(t, msg2) + + // The initiator completes by writing msg3. + msg3, initResult, err := initM.ProcessPacket(nil, msg2) + require.NoError(t, err) + require.NotNil(t, initResult) + require.NotEmpty(t, msg3) + + out, respResult, err := respM.ProcessPacket(nil, msg3) + require.NoError(t, err) + require.Nil(t, out) + require.NotNil(t, respResult) + + t.Logf("msg1 %d B, msg2 %d B, msg3 %d B", len(msg1), len(msg2), len(msg3)) + for i, msg := range [][]byte{msg1, msg2, msg3} { + h := &header.H{} + require.NoError(t, h.Parse(msg)) + assert.Equal(t, header.HandshakeIXHybridMLKEM1024, h.Subtype) + assert.Equal(t, uint64(i+1), h.MessageCounter) + } + + assert.Equal(t, uint32(2000), initResult.RemoteIndex) + assert.Equal(t, uint32(1000), respResult.RemoteIndex) + assert.Equal(t, cert.Version3, initResult.RemoteCert.Certificate.Version()) + assert.Equal(t, cert.Version3, respResult.RemoteCert.Certificate.Version()) + assert.Equal(t, "responder", initResult.RemoteCert.Certificate.Name()) + assert.Equal(t, "initiator", respResult.RemoteCert.Certificate.Name()) + assert.Equal(t, cert.Version3, initResult.MyCert.Version()) + assert.Equal(t, uint64(3), initResult.MessageIndex) + assert.Equal(t, uint64(3), respResult.MessageIndex) + + // The keys pair up in both directions. + for _, pair := range [][2]*Result{{initResult, respResult}, {respResult, initResult}} { + ct, err := pair[0].EKey.Encrypt(nil, nil, []byte("hello")) + require.NoError(t, err) + pt, err := pair[1].DKey.Decrypt(nil, nil, ct) + require.NoError(t, err) + assert.Equal(t, []byte("hello"), pt) + } + }) + } +} + +// A hybrid handshake needs v3 certificates, and a v3 certificate needs a hybrid handshake. +func TestHybridIX_CertVersions(t *testing.T) { + p := newHybridPeers(t, cert.Curve_CURVE25519) + v := testVerifier(p.pool) + + _, err := NewMachine(cert.Version2, p.initCS.getCredential, v, func() (uint32, error) { return 1, nil }, true, header.HandshakeIXHybridMLKEM1024) + require.ErrorIs(t, err, ErrCertVersionMismatch) + _, err = NewMachine(cert.Version3, p.initCS.getCredential, v, func() (uint32, error) { return 1, nil }, true, header.HandshakeIXPSK0) + require.ErrorIs(t, err, ErrCertVersionMismatch) + _, err = NewMachine(cert.Version3, p.initCS.getCredential, v, func() (uint32, error) { return 1, nil }, true, header.HandshakeIXPSK2MLKEM1024) + require.ErrorIs(t, err, ErrCertVersionMismatch) + + // The v2 form still does a classical handshake, here with a peer that only has v2. + ca2 := cert.V2Form(p.ca) + pool2 := cert.NewCAPool() + require.NoError(t, pool2.AddCA(ca2)) + classicalCAKey := p.caKey[:len(p.caKey)-cert.MLDSA87PrivateKeySize] + v2Peer := newTestCertState(t, ca2, classicalCAKey, "v2peer", []netip.Prefix{netip.MustParsePrefix("10.0.0.3/24")}) + p.initCS.version = cert.Version2 + initResult, respResult := doFullHandshake(t, p.initCS, v2Peer, pool2) + assert.Equal(t, cert.Version2, initResult.RemoteCert.Certificate.Version()) + assert.Equal(t, cert.Version2, respResult.RemoteCert.Certificate.Version()) + assert.Equal(t, cert.Version2, initResult.MyCert.Version()) +} + +// A responder that only trusts the v2 form of the CA can not verify a v3 certificate. +func TestHybridIX_V2TrustOnly(t *testing.T) { + p := newHybridPeers(t, cert.Curve_CURVE25519) + pool2 := cert.NewCAPool() + require.NoError(t, pool2.AddCA(cert.V2Form(p.ca))) + + initM := newHybridMachine(t, p.initCS, testVerifier(p.pool), true, 1000) + respM := newHybridMachine(t, p.respCS, testVerifier(pool2), false, 2000) + msg1, err := initM.Initiate(nil) + require.NoError(t, err) + _, _, err = respM.ProcessPacket(nil, msg1) + require.ErrorIs(t, err, cert.ErrCaNotFound) +} + +// A corrupted msg3 is dropped without killing the responder, and the real one still completes. +func TestHybridIX_CorruptMsg3(t *testing.T) { + p := newHybridPeers(t, cert.Curve_CURVE25519) + v := testVerifier(p.pool) + initM := newHybridMachine(t, p.initCS, v, true, 1000) + respM := newHybridMachine(t, p.respCS, v, false, 2000) + + msg1, err := initM.Initiate(nil) + require.NoError(t, err) + msg2, _, err := respM.ProcessPacket(nil, msg1) + require.NoError(t, err) + msg3, _, err := initM.ProcessPacket(nil, msg2) + require.NoError(t, err) + + bad := append([]byte(nil), msg3...) + bad[header.Len+10] ^= 1 + _, res, err := respM.ProcessPacket(nil, bad) + require.Error(t, err) + require.Nil(t, res) + require.False(t, respM.Failed()) + + _, res, err = respM.ProcessPacket(nil, msg3) + require.NoError(t, err) + require.NotNil(t, res) +} + +// withKEMKey returns a peer holding c and its classical private key, but some other ML-KEM key: someone who broke +// the classical key, say with a quantum computer, and wants to be that peer. +func withKEMKey(t *testing.T, curve cert.Curve, c cert.Certificate, priv []byte) *testCertState { + t.Helper() + dk, err := mlkem.GenerateKey1024() + require.NoError(t, err) + forged := append(append([]byte(nil), priv[:len(priv)-cert.MLKEM1024PrivateKeySize]...), dk.Bytes()...) + return hybridCertStateFor(t, curve, c, forged) +} + +// The classical private key alone does not get anyone through as the initiator: msg2 is unreadable without the +// initiator's ML-KEM key. +func TestHybridIX_ImpersonateInitiator(t *testing.T) { + p := newHybridPeers(t, cert.Curve_CURVE25519) + v := testVerifier(p.pool) + victim := p.initCS.creds[cert.Version3].Cert + attacker := withKEMKey(t, cert.Curve_CURVE25519, victim, p.initPriv) + + initM := newHybridMachine(t, attacker, v, true, 1000) + respM := newHybridMachine(t, p.respCS, v, false, 2000) + msg1, err := initM.Initiate(nil) + require.NoError(t, err) + msg2, _, err := respM.ProcessPacket(nil, msg1) + require.NoError(t, err) + + _, res, err := initM.ProcessPacket(nil, msg2) + require.Error(t, err) + require.Nil(t, res) +} + +// The classical private key alone does not get anyone through as the responder either: the initiator completes, +// but msg3 is unreadable without the responder's ML-KEM key, so no packet under the new keys can ever come back. +func TestHybridIX_ImpersonateResponder(t *testing.T) { + p := newHybridPeers(t, cert.Curve_CURVE25519) + v := testVerifier(p.pool) + victim := p.respCS.creds[cert.Version3].Cert + attacker := withKEMKey(t, cert.Curve_CURVE25519, victim, p.respPriv) + + initM := newHybridMachine(t, p.initCS, v, true, 1000) + respM := newHybridMachine(t, attacker, v, false, 2000) + msg1, err := initM.Initiate(nil) + require.NoError(t, err) + msg2, _, err := respM.ProcessPacket(nil, msg1) + require.NoError(t, err) + msg3, initResult, err := initM.ProcessPacket(nil, msg2) + require.NoError(t, err) + require.NotNil(t, initResult) + + _, res, err := respM.ProcessPacket(nil, msg3) + require.Error(t, err) + require.Nil(t, res) +} diff --git a/handshake/kem.go b/handshake/kem.go index a657ecef..a301a1f6 100644 --- a/handshake/kem.go +++ b/handshake/kem.go @@ -6,6 +6,9 @@ import ( "crypto/sha256" "errors" "fmt" + "io" + + "github.com/DefinedNet/noise" ) // kemPSKSalt separates the psk derivation from any other use of the secret. @@ -66,3 +69,39 @@ func (m *Machine) kemDecapsulate(msg []byte) ([]byte, error) { } return msg[mlkem.CiphertextSize1024:], nil } + +// KEMMLKEM1024 is ML-KEM-1024 for the noise library's hybrid patterns. Private +// keys are the 64 byte seed, the same form v3 private keys store. +var KEMMLKEM1024 noise.KEMFunc = kemMLKEM1024{} + +type kemMLKEM1024 struct{} + +// GenerateKeypair ignores random, crypto/mlkem always draws from crypto/rand. +func (kemMLKEM1024) GenerateKeypair(_ io.Reader) (noise.KEMKey, error) { + dk, err := mlkem.GenerateKey1024() + if err != nil { + return noise.KEMKey{}, fmt.Errorf("mlkem1024: GenerateKey: %w", err) + } + return noise.KEMKey{Private: dk.Bytes(), Public: dk.EncapsulationKey().Bytes()}, nil +} + +func (kemMLKEM1024) Encapsulate(pubkey []byte) ([]byte, []byte, error) { + ek, err := mlkem.NewEncapsulationKey1024(pubkey) + if err != nil { + return nil, nil, fmt.Errorf("%w: %w", ErrInvalidKEMKey, err) + } + ss, ct := ek.Encapsulate() + return ct, ss, nil +} + +func (kemMLKEM1024) Decapsulate(privkey, ciphertext []byte) ([]byte, error) { + dk, err := mlkem.NewDecapsulationKey1024(privkey) + if err != nil { + return nil, fmt.Errorf("mlkem1024: NewDecapsulationKey: %w", err) + } + return dk.Decapsulate(ciphertext) +} + +func (kemMLKEM1024) PublicKeyLen() int { return mlkem.EncapsulationKeySize1024 } +func (kemMLKEM1024) CiphertextLen() int { return mlkem.CiphertextSize1024 } +func (kemMLKEM1024) KEMName() string { return "MLKEM1024" } diff --git a/handshake/machine.go b/handshake/machine.go index 0162ee70..9636ad8a 100644 --- a/handshake/machine.go +++ b/handshake/machine.go @@ -65,6 +65,7 @@ type Machine struct { msgs []msgFlags myVersion cert.Version subtype header.MessageSubType + hybrid bool indexAllocated bool remoteCertSet bool payloadSet bool @@ -91,12 +92,18 @@ func NewMachine( return nil, err } + // Version 3 certificates go with hybrid patterns and nothing else, the + // classical and phase 1 subtypes use the v2 form instead. + if info.hybrid != (version == cert.Version3) { + return nil, fmt.Errorf("%w: %v", ErrCertVersionMismatch, version) + } + cred := getCred(version) if cred == nil { return nil, fmt.Errorf("%w: %v", ErrNoCredential, version) } - hs, err := cred.buildHandshakeState(initiator, info.pattern, info.pskPlacement) + hs, err := cred.buildHandshakeState(initiator, info) if err != nil { return nil, fmt.Errorf("build noise state: %w", err) } @@ -105,6 +112,7 @@ func NewMachine( hs: hs, subtype: subtype, msgs: info.msgs, + hybrid: info.hybrid, getCred: getCred, allocIndex: allocIndex, verifier: verifier, @@ -226,11 +234,6 @@ func (m *Machine) ProcessPacket(out, packet []byte) ([]byte, *Result, error) { return nil, nil, ErrInitiateNotCalled } - // The (eKey, dKey) ordering here is correct for IX, where the initiator - // completes the handshake by reading the responder's stage-2 message. - // noise returns (cs1, cs2) where cs1 is the initiator->responder cipher. - // For 3-message patterns where a responder finishes by reading the final - // message, this ordering would be wrong; revisit when XX/pqIX lands. noiseMsg := packet[header.Len:] if m.myMsgFlags().kemCiphertext { var err error @@ -242,7 +245,8 @@ func (m *Machine) ProcessPacket(out, packet []byte) ([]byte, *Result, error) { } } - msg, eKey, dKey, err := m.hs.ReadMessage(nil, noiseMsg) + msg, cs1, cs2, err := m.hs.ReadMessage(nil, noiseMsg) + eKey, dKey := m.cipherStates(cs1, cs2) if err != nil { // Noise ReadMessage failed. The noise library checkpoints and rolls back // on failure, so the Machine is still alive. The caller can retry with @@ -272,7 +276,7 @@ func (m *Machine) ProcessPacket(out, packet []byte) ([]byte, *Result, error) { } // ReadMessage didn't complete, produce the next outgoing message - out, dk, ek, err := m.buildResponse(out) + out, ek, dk, err := m.buildResponse(out) if err != nil { m.failed = true return nil, nil, err @@ -292,6 +296,15 @@ func (m *Machine) ProcessPacket(out, packet []byte) ([]byte, *Result, error) { return out, nil, nil } +// cipherStates orders the cipher states noise returns, (cs1, cs2) with cs1 the +// initiator to responder direction, into this side's (eKey, dKey). +func (m *Machine) cipherStates(cs1, cs2 *noise.CipherState) (*noise.CipherState, *noise.CipherState) { + if m.result.Initiator { + return cs1, cs2 + } + return cs2, cs1 +} + func (m *Machine) completed(eKey, dKey *noise.CipherState) *Result { m.kemKey = nil m.kemCiphertext = nil @@ -372,6 +385,14 @@ func (m *Machine) processPayload(msg []byte, flags msgFlags) error { } func (m *Machine) validateCert(payload Payload) error { + // A v3 certificate only ever comes with a hybrid pattern, and a hybrid + // pattern only ever with one. The composite static key would not recombine + // with anything else anyway, but say so plainly. + if m.hybrid != (cert.Version(payload.CertVersion) == cert.Version3) { + m.failed = true + return fmt.Errorf("%w: %v", ErrCertVersionMismatch, payload.CertVersion) + } + cred := m.getCred(m.myVersion) if cred == nil { m.failed = true @@ -487,17 +508,11 @@ func (m *Machine) buildResponse(out []byte) ([]byte, *noise.CipherState, *noise. // noise.WriteMessage appends the encrypted handshake message to out, // reusing capacity when present. - // - // The (dKey, eKey) ordering here is correct for IX, where the responder - // completes the handshake by writing the stage-2 message. noise returns - // (cs1, cs2) where cs1 is the initiator->responder cipher (which is the - // responder's decrypt key). For 3-message patterns where an initiator - // finishes by writing the final message, this ordering would be wrong; - // revisit when XX/pqIX lands. - out, dKey, eKey, err := m.hs.WriteMessage(out, hsBytes) + out, cs1, cs2, err := m.hs.WriteMessage(out, hsBytes) if err != nil { return nil, nil, nil, fmt.Errorf("noise WriteMessage: %w", err) } - return out, dKey, eKey, nil + eKey, dKey := m.cipherStates(cs1, cs2) + return out, eKey, dKey, nil } diff --git a/handshake/patterns.go b/handshake/patterns.go index 34a900e0..e22ea3e7 100644 --- a/handshake/patterns.go +++ b/handshake/patterns.go @@ -21,6 +21,10 @@ type subtypeInfo struct { pattern noise.HandshakePattern pskPlacement int // noise psk modifier position, 0 means no psk token msgs []msgFlags + + // hybrid patterns carry composite static keys and need version 3 + // certificates on both sides; every other pattern refuses them. + hybrid bool } // subtypeInfos defines the noise pattern and message content layout for each @@ -51,6 +55,29 @@ var subtypeInfos = map[header.MessageSubType]subtypeInfo{ }, }, + // Hybrid IX (github.com/DefinedNet/noise, see its PQ_RESEARCH.md): + // msg1 (I->R): e, s payload + cert + // msg2 (R->I): ekem, skem, e, ee, se, s, es payload + cert + // msg3 (I->R): skem + // e and s are composite, classical || ML-KEM-1024. The static ML-KEM key is + // the post-quantum half of the v3 certificate's public key, so it travels in + // s and the certificate leaves it out. The responder authenticates the + // initiator when msg3 decrypts, and the initiator the responder when the + // first packet under the new keys does. + // + // TODO: the responder's e also carries an ephemeral ML-KEM key that no token + // ever encapsulates to, 1568 bytes of msg2 and a key generation for nothing. + // Dropping it needs a change to the noise library. + header.HandshakeIXHybridMLKEM1024: { + pattern: noise.HandshakeHybridIX, + hybrid: true, + msgs: []msgFlags{ + {expectsPayload: true, expectsCert: true}, + {expectsPayload: true, expectsCert: true}, + {}, + }, + }, + // XX: 3 messages // msg1 (I->R): payload only // msg2 (R->I): payload + cert diff --git a/header/header.go b/header/header.go index 4faed77a..e8e5829d 100644 --- a/header/header.go +++ b/header/header.go @@ -72,12 +72,17 @@ const ( // in through the psk2 slot. Its messages exceed the MTU and travel as // fragments (see handshake/fragment). HandshakeIXPSK2MLKEM1024 MessageSubType = 2 + + // HandshakeIXHybridMLKEM1024 is the hybrid IX of github.com/DefinedNet/noise: + // IX with X25519 or P256 and ML-KEM-1024 side by side, authenticated by + // version 3 certificates. It takes three messages and travels as fragments. + HandshakeIXHybridMLKEM1024 MessageSubType = 3 ) // IsFragmented reports whether handshake messages of this subtype travel as // fragments. func IsFragmented(st MessageSubType) bool { - return st == HandshakeIXPSK2MLKEM1024 + return st == HandshakeIXPSK2MLKEM1024 || st == HandshakeIXHybridMLKEM1024 } var ErrHeaderTooShort = errors.New("header is too short") @@ -99,8 +104,9 @@ var subTypeMap = map[MessageType]*map[MessageSubType]string{ Test: &subTypeTestMap, CloseTunnel: &subTypeNoneMap, Handshake: { - HandshakeIXPSK0: "ix_psk0", - HandshakeIXPSK2MLKEM1024: "ix_psk2_mlkem1024", + HandshakeIXPSK0: "ix_psk0", + HandshakeIXPSK2MLKEM1024: "ix_psk2_mlkem1024", + HandshakeIXHybridMLKEM1024: "ix_hybrid_mlkem1024", }, Control: &subTypeNoneMap, } @@ -211,7 +217,7 @@ func IsValidSubType(t MessageType, s MessageSubType) bool { case Message: return s == MessageNone || s == MessageRelay case Handshake: - return s == HandshakeIXPSK0 || s == HandshakeIXPSK2MLKEM1024 + return s == HandshakeIXPSK0 || s == HandshakeIXPSK2MLKEM1024 || s == HandshakeIXHybridMLKEM1024 case Test: return s == TestReply || s == TestRequest case Control, CloseTunnel, RecvError, LightHouse: diff --git a/header/header_test.go b/header/header_test.go index 576c231e..505040d3 100644 --- a/header/header_test.go +++ b/header/header_test.go @@ -96,8 +96,9 @@ func TestTypeMap(t *testing.T) { Test: &subTypeTestMap, CloseTunnel: &subTypeNoneMap, Handshake: { - HandshakeIXPSK0: "ix_psk0", - HandshakeIXPSK2MLKEM1024: "ix_psk2_mlkem1024", + HandshakeIXPSK0: "ix_psk0", + HandshakeIXPSK2MLKEM1024: "ix_psk2_mlkem1024", + HandshakeIXHybridMLKEM1024: "ix_hybrid_mlkem1024", }, Control: &subTypeNoneMap, }, subTypeMap)