From 5c5254dc187516052f6708a56a6f5d19fd0850b4 Mon Sep 17 00:00:00 2001 From: JackDoan Date: Wed, 14 Oct 2026 11:03:04 -0500 Subject: [PATCH] Load v3 certificates and composite keys, speak v2 messages over v3 tunnels, and match ca_sha on either CA fingerprint --- connection_manager.go | 2 +- firewall.go | 8 ++++ firewall_test.go | 59 ++++++++++++++++++++++++++- lighthouse.go | 4 +- pki.go | 93 +++++++++++++++++++++++++++++++++++++++---- relay_manager.go | 4 +- 6 files changed, 156 insertions(+), 14 deletions(-) diff --git a/connection_manager.go b/connection_manager.go index 4ec9f614..a09da3a3 100644 --- a/connection_manager.go +++ b/connection_manager.go @@ -272,7 +272,7 @@ func (cm *connectionManager) migrateRelayUsed(oldhostinfo, newhostinfo *HostInfo InitiatorRelayIndex: index, } - switch newhostinfo.GetCert().Certificate.Version() { + switch protocolVersion(newhostinfo.GetCert().Certificate.Version()) { case cert.Version1: if !relayFrom.Is4() { cm.l.Error("can not migrate v1 relay with a v6 network because the relay is not running a current nebula version") diff --git a/firewall.go b/firewall.go index 5fb2d6de..9c83cacd 100644 --- a/firewall.go +++ b/firewall.go @@ -764,6 +764,14 @@ func (fc *FirewallCA) match(p firewall.Packet, c *cert.CachedCertificate, caPool return false } + // A version 3 certificate names its CA by the fingerprint of the CA's v2 form, + // which is matched above. A rule may also use the v3 CA's own fingerprint. + if c.Certificate.Version() == cert.Version3 { + if t, ok := fc.CAShas[s.Fingerprint]; ok && t.match(p, c) { + return true + } + } + return fc.CANames[s.Certificate.Name()].match(p, c) } diff --git a/firewall_test.go b/firewall_test.go index b8727915..a4ee09ba 100644 --- a/firewall_test.go +++ b/firewall_test.go @@ -11,6 +11,7 @@ import ( "github.com/gaissmai/bart" "github.com/slackhq/nebula/cert" + "github.com/slackhq/nebula/cert_test" "github.com/slackhq/nebula/config" "github.com/slackhq/nebula/firewall" "github.com/slackhq/nebula/iputil" @@ -1256,7 +1257,7 @@ func TestNewFirewallFromConfig(t *testing.T) { l := test.NewLogger() // Test a bad rule definition c := &dummyCert{} - cs, err := newCertState(cert.Version2, nil, c, false, cert.Curve_CURVE25519, nil, "aes") + cs, err := newCertState(cert.Version2, nil, c, nil, false, cert.Curve_CURVE25519, nil, "aes") require.NoError(t, err) conf := config.NewC(test.NewLogger()) @@ -1745,3 +1746,59 @@ func resetConntrack(fw *Firewall) { fw.Conntrack.Conns = map[firewall.Packet]*conn{} fw.Conntrack.Unlock() } + +// A version 3 certificate's CA answers to both its fingerprints in ca_sha rules: the v2 form's, which is what the +// certificate names as issuer, and its own. A v2 certificate only to the v2 one. +func TestFirewall_CAShaV3(t *testing.T) { + l := test.NewLogger() + ca, _, caKey, _ := cert_test.NewTestCaCert(cert.Version3, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil) + host, _, _, _ := cert_test.NewTestCert(cert.Version3, cert.Curve_CURVE25519, ca, caKey, "host", time.Time{}, time.Time{}, []netip.Prefix{netip.MustParsePrefix("1.2.3.4/24")}, nil, nil) + cp := cert.NewCAPool() + require.NoError(t, cp.AddCA(ca)) + require.NoError(t, cp.AddCA(cert.V2Form(ca))) + v3Sha, err := ca.Fingerprint() + require.NoError(t, err) + v2Sha, err := cert.V2Form(ca).Fingerprint() + require.NoError(t, err) + + myVpnNetworksTable := new(bart.Lite) + myVpnNetworksTable.Insert(netip.MustParsePrefix("1.1.1.1/8")) + p := firewall.Packet{ + LocalAddr: netip.MustParseAddr("1.2.3.4"), + RemoteAddr: netip.MustParseAddr("1.2.3.4"), + LocalPort: 10, + RemotePort: 90, + Protocol: iputil.IPProtocolUDP, + } + + for _, tc := range []struct { + name string + c cert.Certificate + caSha string + match bool + }{ + {"v3 by v2 sha", host, v2Sha, true}, + {"v3 by v3 sha", host, v3Sha, true}, + {"v2 form by v2 sha", cert.V2Form(host), v2Sha, true}, + {"v2 form by v3 sha", cert.V2Form(host), v3Sha, false}, + } { + t.Run(tc.name, func(t *testing.T) { + cc, err := cp.VerifyCertificate(time.Now(), tc.c) + require.NoError(t, err) + h := HostInfo{ + ConnectionState: &ConnectionState{peerCert: cc}, + vpnAddrs: []netip.Addr{netip.MustParseAddr("1.2.3.4")}, + } + h.buildNetworks(myVpnNetworksTable, tc.c) + + fw := NewFirewall(l, time.Second, time.Minute, time.Hour, tc.c) + require.NoError(t, fw.AddRule(true, firewall.ProtoAny, 0, 0, nil, "", "", "", "", tc.caSha)) + err = fw.Drop(p, true, &h, cp, nil) + if tc.match { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, ErrNoMatchingRule) + } + }) + } +} diff --git a/lighthouse.go b/lighthouse.go index 619c16a0..3869f518 100644 --- a/lighthouse.go +++ b/lighthouse.go @@ -839,7 +839,7 @@ func (lh *LightHouse) innerQueryServer(addr netip.Addr, nb, out []byte) { for _, lhVpnAddr := range lighthouses { hi := lh.ifce.GetHostInfo(lhVpnAddr) if hi != nil { - v = hi.ConnectionState.myCert.Version() + v = protocolVersion(hi.ConnectionState.myCert.Version()) } else { v = lh.ifce.GetCertState().initiatingVersion } @@ -1001,7 +1001,7 @@ func (lh *LightHouse) SendUpdate() { var v cert.Version hi := lh.ifce.GetHostInfo(lhVpnAddr) if hi != nil { - v = hi.ConnectionState.myCert.Version() + v = protocolVersion(hi.ConnectionState.myCert.Version()) } else { v = lh.ifce.GetCertState().initiatingVersion } diff --git a/pki.go b/pki.go index 67826116..01bb6d46 100644 --- a/pki.go +++ b/pki.go @@ -38,6 +38,11 @@ type CertState struct { v2Cert cert.Certificate v2Credential *handshake.Credential + // v3Cert, when present, is what v2Cert is the v2 form of. It is only used by + // hybrid handshakes, every other handshake presents v2Cert. + v3Cert cert.Certificate + v3Credential *handshake.Credential + initiatingVersion cert.Version privateKey []byte pkcs11Backed bool @@ -223,6 +228,8 @@ func (cs *CertState) GetCredential(v cert.Version) *handshake.Credential { return cs.v1Credential case cert.Version2: return cs.v2Credential + case cert.Version3: + return cs.v3Credential } return nil } @@ -233,11 +240,23 @@ func (cs *CertState) getCertificate(v cert.Version) cert.Certificate { return cs.v1Cert case cert.Version2: return cs.v2Cert + case cert.Version3: + return cs.v3Cert } return nil } +// protocolVersion is the version of the messages spoken over a tunnel built on a +// certificate of version v. Version 3 only changes the handshake, everything +// after it is version 2. +func protocolVersion(v cert.Version) cert.Version { + if v == cert.Version3 { + return cert.Version2 + } + return v +} + // newCipherSuite builds the noise.CipherSuite for the given curve and cipher. // When fips140Enforced is true (FIPS 140-only mode), non-approved algorithms // (Curve25519 and ChaChaPoly) are rejected with an error. Callers pass @@ -287,7 +306,14 @@ func (cs *CertState) MarshalJSON() ([]byte, error) { msg = append(msg, b) } - if cs.v2Cert != nil { + // A v3 certificate shows its v2 form's fingerprint itself. + if cs.v3Cert != nil { + b, err := cs.v3Cert.MarshalJSON() + if err != nil { + return nil, err + } + msg = append(msg, b) + } else if cs.v2Cert != nil { b, err := cs.v2Cert.MarshalJSON() if err != nil { return nil, err @@ -329,7 +355,7 @@ func newCertStateFromConfig(c *config.C, cipher string) (*CertState, error) { } } - var crt, v1, v2 cert.Certificate + var crt, v1, v2, v3 cert.Certificate for { // Load the certificate crt, rawCert, err = loadCertificate(rawCert) @@ -352,6 +378,11 @@ func newCertStateFromConfig(c *config.C, cipher string) (*CertState, error) { return nil, fmt.Errorf("v2 certificate already found in pki.cert") } v2 = crt + case cert.Version3: + if v3 != nil { + return nil, fmt.Errorf("v3 certificate already found in pki.cert") + } + v3 = crt default: return nil, fmt.Errorf("unknown certificate version %v", crt.Version()) } @@ -361,6 +392,25 @@ func newCertStateFromConfig(c *config.C, cipher string) (*CertState, error) { } } + if v3 != nil { + // A v3 certificate brings its own v2 form. Listing that too is fine, anything else is a mistake. + form := cert.V2Form(v3) + if v2 != nil { + want, err := form.Fingerprint() + if err != nil { + return nil, err + } + got, err := v2.Fingerprint() + if err != nil { + return nil, err + } + if want != got { + return nil, errors.New("the v2 certificate in pki.cert is not the v2 form of the v3 certificate") + } + } + v2 = form + } + if v1 == nil && v2 == nil { return nil, errors.New("no certificates found in pki.cert") } @@ -386,10 +436,12 @@ func newCertStateFromConfig(c *config.C, cipher string) (*CertState, error) { return nil, fmt.Errorf("unknown pki.initiating_version: %v", rawInitiatingVersion) } - return newCertState(initiatingVersion, v1, v2, isPkcs11, curve, rawKey, cipher) + return newCertState(initiatingVersion, v1, v2, v3, isPkcs11, curve, rawKey, cipher) } -func newCertState(dv cert.Version, v1, v2 cert.Certificate, pkcs11backed bool, privateKeyCurve cert.Curve, privateKey []byte, cipher string) (*CertState, error) { +// newCertState builds the handshake credentials. v3, when present, must be what v2 is the v2 form of. privateKey is +// then the composite key, classical || ML-KEM-1024 seed, and v1 and v2 get its classical half. +func newCertState(dv cert.Version, v1, v2, v3 cert.Certificate, pkcs11backed bool, privateKeyCurve cert.Curve, privateKey []byte, cipher string) (*CertState, error) { cs := CertState{ privateKey: privateKey, pkcs11Backed: pkcs11backed, @@ -415,11 +467,36 @@ func newCertState(dv cert.Version, v1, v2 cert.Certificate, pkcs11backed bool, p cs.initiatingVersion = dv } + classicalKey := privateKey + if !pkcs11backed && len(privateKey) > cert.MLKEM1024PrivateKeySize { + classicalKey = privateKey[:len(privateKey)-cert.MLKEM1024PrivateKeySize] + } + + if v3 != nil { + if pkcs11backed { + return nil, errors.New("v3 certificates need their private key in pki.key, not a PKCS#11 token") + } + if err := v3.VerifyPrivateKey(privateKeyCurve, privateKey); err != nil { + return nil, fmt.Errorf("private key is not a pair with public key in v3 nebula cert: %w", err) + } + + v3hs, err := v3.MarshalForHandshakes() + if err != nil { + return nil, fmt.Errorf("error marshalling v3 certificate for handshake: %w", err) + } + ncs, err := newCipherSuite(v3.Curve(), false, cipher, fips140.Enforced()) + if err != nil { + return nil, err + } + cs.v3Cert = v3 + cs.v3Credential = handshake.NewCredential(v3, v3hs, privateKey, ncs) + } + if v1 != nil { if pkcs11backed { //NOTE: We do not currently have a method to verify a public private key pair when the private key is in an hsm } else { - if err := v1.VerifyPrivateKey(privateKeyCurve, privateKey); err != nil { + if err := v1.VerifyPrivateKey(privateKeyCurve, classicalKey); err != nil { return nil, fmt.Errorf("private key is not a pair with public key in nebula cert: %w", err) } } @@ -433,7 +510,7 @@ func newCertState(dv cert.Version, v1, v2 cert.Certificate, pkcs11backed bool, p return nil, err } cs.v1Cert = v1 - cs.v1Credential = handshake.NewCredential(v1, v1hs, privateKey, ncs) + cs.v1Credential = handshake.NewCredential(v1, v1hs, classicalKey, ncs) if cs.initiatingVersion == 0 { cs.initiatingVersion = cert.Version1 @@ -444,7 +521,7 @@ func newCertState(dv cert.Version, v1, v2 cert.Certificate, pkcs11backed bool, p if pkcs11backed { //NOTE: We do not currently have a method to verify a public private key pair when the private key is in an hsm } else { - if err := v2.VerifyPrivateKey(privateKeyCurve, privateKey); err != nil { + if err := v2.VerifyPrivateKey(privateKeyCurve, classicalKey); err != nil { return nil, fmt.Errorf("private key is not a pair with public key in nebula cert: %w", err) } } @@ -458,7 +535,7 @@ func newCertState(dv cert.Version, v1, v2 cert.Certificate, pkcs11backed bool, p return nil, err } cs.v2Cert = v2 - cs.v2Credential = handshake.NewCredential(v2, v2hs, privateKey, ncs) + cs.v2Credential = handshake.NewCredential(v2, v2hs, classicalKey, ncs) if cs.initiatingVersion == 0 { cs.initiatingVersion = cert.Version2 diff --git a/relay_manager.go b/relay_manager.go index 5d0bbeea..6dab7f45 100644 --- a/relay_manager.go +++ b/relay_manager.go @@ -119,7 +119,7 @@ func (rm *relayManager) StartRelays(f *Interface, vpnIp netip.Addr, hh *Handshak InitiatorRelayIndex: idx, } - switch relayHostInfo.GetCert().Certificate.Version() { + switch protocolVersion(relayHostInfo.GetCert().Certificate.Version()) { case cert.Version1: if !f.myVpnAddrs[0].Is4() { hl.Error("can not establish v1 relay with a v6 network because the relay is not running a current nebula version") @@ -178,7 +178,7 @@ func (rm *relayManager) StartRelays(f *Interface, vpnIp netip.Addr, hh *Handshak InitiatorRelayIndex: existingRelay.LocalIndex, } - switch relayHostInfo.GetCert().Certificate.Version() { + switch protocolVersion(relayHostInfo.GetCert().Certificate.Version()) { case cert.Version1: if !f.myVpnAddrs[0].Is4() { hl.Error("can not establish v1 relay with a v6 network because the relay is not running a current nebula version")