Skip to content

Commit d2beb29

Browse files
Merge pull request #5 from disentangle-network/feature/pq-certificates
Post-quantum certificate support (ML-DSA-87 + ML-KEM-1024)
2 parents 51308b8 + d43cf56 commit d2beb29

File tree

12 files changed

+231
-14
lines changed

12 files changed

+231
-14
lines changed

cert/cert_v1.pb.go

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cert/cert_v1.proto

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ option go_package = "github.com/slackhq/nebula/cert";
88
enum Curve {
99
CURVE25519 = 0;
1010
P256 = 1;
11+
// Post-Quantum: ML-DSA-87 (signing) + ML-KEM-1024 (key agreement)
12+
PQ = 2;
1113
}
1214

1315
message RawNebulaCertificate {

cert/cert_v2.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import (
1515
"slices"
1616
"time"
1717

18+
"github.com/cloudflare/circl/kem/mlkem/mlkem1024"
19+
"github.com/cloudflare/circl/sign/mldsa/mldsa87"
1820
"golang.org/x/crypto/cryptobyte"
1921
"golang.org/x/crypto/cryptobyte/asn1"
2022
"golang.org/x/crypto/curve25519"
@@ -159,6 +161,16 @@ func (c *certificateV2) CheckSignature(key []byte) bool {
159161
}
160162
hashed := sha256.Sum256(b)
161163
return ecdsa.VerifyASN1(pubKey, hashed[:], c.signature)
164+
case Curve_PQ:
165+
// ML-DSA-87 (FIPS 204) verification
166+
if len(key) != mldsa87.PublicKeySize {
167+
return false
168+
}
169+
var pk mldsa87.PublicKey
170+
var pkBuf [mldsa87.PublicKeySize]byte
171+
copy(pkBuf[:], key)
172+
pk.Unpack(&pkBuf)
173+
return mldsa87.Verify(&pk, b, nil, c.signature)
162174
default:
163175
return false
164176
}
@@ -192,6 +204,19 @@ func (c *certificateV2) VerifyPrivateKey(curve Curve, key []byte) error {
192204
if !bytes.Equal(pub, c.publicKey) {
193205
return ErrPublicPrivateKeyMismatch
194206
}
207+
case Curve_PQ:
208+
// CA certs use ML-DSA-87 signing keys
209+
if len(key) != mldsa87.PrivateKeySize {
210+
return ErrInvalidPrivateKey
211+
}
212+
var sk mldsa87.PrivateKey
213+
var skBuf [mldsa87.PrivateKeySize]byte
214+
copy(skBuf[:], key)
215+
sk.Unpack(&skBuf)
216+
derivedPub := sk.Public().(*mldsa87.PublicKey).Bytes()
217+
if !bytes.Equal(derivedPub, c.publicKey) {
218+
return ErrPublicPrivateKeyMismatch
219+
}
195220
default:
196221
return fmt.Errorf("invalid curve: %s", curve)
197222
}
@@ -212,6 +237,19 @@ func (c *certificateV2) VerifyPrivateKey(curve Curve, key []byte) error {
212237
return ErrInvalidPrivateKey
213238
}
214239
pub = privkey.PublicKey().Bytes()
240+
case Curve_PQ:
241+
// Host certs use ML-KEM-1024 key agreement keys
242+
if len(key) != mlkem1024.PrivateKeySize {
243+
return ErrInvalidPrivateKey
244+
}
245+
_, sk := mlkem1024.NewKeyFromSeed(key[:mlkem1024.KeySeedSize])
246+
pub, _ = sk.MarshalBinary()
247+
// Compare only the public key portion
248+
derivedPub := pub[:mlkem1024.PublicKeySize]
249+
if !bytes.Equal(derivedPub, c.publicKey) {
250+
return ErrPublicPrivateKeyMismatch
251+
}
252+
return nil
215253
default:
216254
return fmt.Errorf("invalid curve: %s", curve)
217255
}

cert/crypto.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ func EncryptAndMarshalSigningPrivateKey(curve Curve, b []byte, passphrase []byte
185185
return pem.EncodeToMemory(&pem.Block{Type: EncryptedEd25519PrivateKeyBanner, Bytes: b}), nil
186186
case Curve_P256:
187187
return pem.EncodeToMemory(&pem.Block{Type: EncryptedECDSAP256PrivateKeyBanner, Bytes: b}), nil
188+
case Curve_PQ:
189+
return pem.EncodeToMemory(&pem.Block{Type: EncryptedMLDSA87PrivateKeyBanner, Bytes: b}), nil
188190
default:
189191
return nil, fmt.Errorf("invalid curve: %v", curve)
190192
}
@@ -265,8 +267,10 @@ func DecryptAndUnmarshalSigningPrivateKey(passphrase, b []byte) (Curve, []byte,
265267
curve = Curve_CURVE25519
266268
case EncryptedECDSAP256PrivateKeyBanner:
267269
curve = Curve_P256
270+
case EncryptedMLDSA87PrivateKeyBanner:
271+
curve = Curve_PQ
268272
default:
269-
return curve, nil, r, fmt.Errorf("bytes did not contain a proper nebula encrypted Ed25519/ECDSA private key banner")
273+
return curve, nil, r, fmt.Errorf("bytes did not contain a proper nebula encrypted Ed25519/ECDSA/MLDSA87 private key banner")
270274
}
271275

272276
ned, err := UnmarshalNebulaEncryptedData(k.Bytes)

cert/crypto_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ qrlJ69wer3ZUHFXA
7575

7676
// Fail due to invalid banner
7777
curve, k, rest, err = DecryptAndUnmarshalSigningPrivateKey(passphrase, rest)
78-
require.EqualError(t, err, "bytes did not contain a proper nebula encrypted Ed25519/ECDSA private key banner")
78+
require.EqualError(t, err, "bytes did not contain a proper nebula encrypted Ed25519/ECDSA/MLDSA87 private key banner")
7979
assert.Nil(t, k)
8080
assert.Equal(t, rest, invalidPem)
8181

cert/helper_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"net/netip"
1010
"time"
1111

12+
"github.com/cloudflare/circl/kem/mlkem/mlkem1024"
13+
"github.com/cloudflare/circl/sign/mldsa/mldsa87"
1214
"golang.org/x/crypto/curve25519"
1315
"golang.org/x/crypto/ed25519"
1416
)
@@ -29,6 +31,14 @@ func NewTestCaCert(version Version, curve Curve, before, after time.Time, networ
2931

3032
pub = elliptic.Marshal(elliptic.P256(), privk.PublicKey.X, privk.PublicKey.Y)
3133
priv = privk.D.FillBytes(make([]byte, 32))
34+
case Curve_PQ:
35+
// CA certs use ML-DSA-87 signing keys
36+
pk, sk, err := mldsa87.GenerateKey(rand.Reader)
37+
if err != nil {
38+
panic(err)
39+
}
40+
pub = pk.Bytes()
41+
priv = sk.Bytes()
3242
default:
3343
// There is no default to allow the underlying lib to respond with an error
3444
}
@@ -87,6 +97,8 @@ func NewTestCert(v Version, curve Curve, ca Certificate, key []byte, name string
8797
pub, priv = X25519Keypair()
8898
case Curve_P256:
8999
pub, priv = P256Keypair()
100+
case Curve_PQ:
101+
pub, priv = MLKEM1024Keypair()
90102
default:
91103
panic("unknown curve")
92104
}
@@ -139,3 +151,15 @@ func P256Keypair() ([]byte, []byte) {
139151
pubkey := privkey.PublicKey()
140152
return pubkey.Bytes(), privkey.Bytes()
141153
}
154+
155+
// MLKEM1024Keypair generates an ML-KEM-1024 key pair for PQ host certificates.
156+
// Returns (publicKey, privateKey) as raw byte slices.
157+
func MLKEM1024Keypair() ([]byte, []byte) {
158+
pk, sk, err := mlkem1024.GenerateKeyPair(rand.Reader)
159+
if err != nil {
160+
panic(err)
161+
}
162+
pubBytes, _ := pk.MarshalBinary()
163+
privBytes, _ := sk.MarshalBinary()
164+
return pubBytes, privBytes
165+
}

cert/pem.go

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"encoding/pem"
55
"fmt"
66

7+
"github.com/cloudflare/circl/kem/mlkem/mlkem1024"
8+
"github.com/cloudflare/circl/sign/mldsa/mldsa87"
79
"golang.org/x/crypto/ed25519"
810
)
911

@@ -13,20 +15,25 @@ const ( //cert banners
1315
)
1416

1517
const ( //key-agreement-key banners
16-
X25519PrivateKeyBanner = "NEBULA X25519 PRIVATE KEY"
17-
X25519PublicKeyBanner = "NEBULA X25519 PUBLIC KEY"
18-
P256PrivateKeyBanner = "NEBULA P256 PRIVATE KEY"
19-
P256PublicKeyBanner = "NEBULA P256 PUBLIC KEY"
18+
X25519PrivateKeyBanner = "NEBULA X25519 PRIVATE KEY"
19+
X25519PublicKeyBanner = "NEBULA X25519 PUBLIC KEY"
20+
P256PrivateKeyBanner = "NEBULA P256 PRIVATE KEY"
21+
P256PublicKeyBanner = "NEBULA P256 PUBLIC KEY"
22+
MLKEM1024PrivateKeyBanner = "NEBULA MLKEM1024 PRIVATE KEY"
23+
MLKEM1024PublicKeyBanner = "NEBULA MLKEM1024 PUBLIC KEY"
2024
)
2125

2226
/* including "ECDSA" in the P256 banners is a clue that these keys should be used only for signing */
2327
const ( //signing key banners
24-
EncryptedECDSAP256PrivateKeyBanner = "NEBULA ECDSA P256 ENCRYPTED PRIVATE KEY"
25-
ECDSAP256PrivateKeyBanner = "NEBULA ECDSA P256 PRIVATE KEY"
26-
ECDSAP256PublicKeyBanner = "NEBULA ECDSA P256 PUBLIC KEY"
27-
EncryptedEd25519PrivateKeyBanner = "NEBULA ED25519 ENCRYPTED PRIVATE KEY"
28-
Ed25519PrivateKeyBanner = "NEBULA ED25519 PRIVATE KEY"
29-
Ed25519PublicKeyBanner = "NEBULA ED25519 PUBLIC KEY"
28+
EncryptedECDSAP256PrivateKeyBanner = "NEBULA ECDSA P256 ENCRYPTED PRIVATE KEY"
29+
ECDSAP256PrivateKeyBanner = "NEBULA ECDSA P256 PRIVATE KEY"
30+
ECDSAP256PublicKeyBanner = "NEBULA ECDSA P256 PUBLIC KEY"
31+
EncryptedEd25519PrivateKeyBanner = "NEBULA ED25519 ENCRYPTED PRIVATE KEY"
32+
Ed25519PrivateKeyBanner = "NEBULA ED25519 PRIVATE KEY"
33+
Ed25519PublicKeyBanner = "NEBULA ED25519 PUBLIC KEY"
34+
EncryptedMLDSA87PrivateKeyBanner = "NEBULA MLDSA87 ENCRYPTED PRIVATE KEY"
35+
MLDSA87PrivateKeyBanner = "NEBULA MLDSA87 PRIVATE KEY"
36+
MLDSA87PublicKeyBanner = "NEBULA MLDSA87 PUBLIC KEY"
3037
)
3138

3239
// UnmarshalCertificateFromPEM will try to unmarshal the first pem block in a byte array, returning any non consumed
@@ -74,6 +81,8 @@ func MarshalPublicKeyToPEM(curve Curve, b []byte) []byte {
7481
return pem.EncodeToMemory(&pem.Block{Type: X25519PublicKeyBanner, Bytes: b})
7582
case Curve_P256:
7683
return pem.EncodeToMemory(&pem.Block{Type: P256PublicKeyBanner, Bytes: b})
84+
case Curve_PQ:
85+
return pem.EncodeToMemory(&pem.Block{Type: MLKEM1024PublicKeyBanner, Bytes: b})
7786
default:
7887
return nil
7988
}
@@ -87,6 +96,8 @@ func MarshalSigningPublicKeyToPEM(curve Curve, b []byte) []byte {
8796
return pem.EncodeToMemory(&pem.Block{Type: Ed25519PublicKeyBanner, Bytes: b})
8897
case Curve_P256:
8998
return pem.EncodeToMemory(&pem.Block{Type: ECDSAP256PublicKeyBanner, Bytes: b})
99+
case Curve_PQ:
100+
return pem.EncodeToMemory(&pem.Block{Type: MLDSA87PublicKeyBanner, Bytes: b})
90101
default:
91102
return nil
92103
}
@@ -107,6 +118,12 @@ func UnmarshalPublicKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
107118
// Uncompressed
108119
expectedLen = 65
109120
curve = Curve_P256
121+
case MLKEM1024PublicKeyBanner:
122+
expectedLen = mlkem1024.PublicKeySize
123+
curve = Curve_PQ
124+
case MLDSA87PublicKeyBanner:
125+
expectedLen = mldsa87.PublicKeySize
126+
curve = Curve_PQ
110127
default:
111128
return nil, r, 0, fmt.Errorf("bytes did not contain a proper public key banner")
112129
}
@@ -122,6 +139,8 @@ func MarshalPrivateKeyToPEM(curve Curve, b []byte) []byte {
122139
return pem.EncodeToMemory(&pem.Block{Type: X25519PrivateKeyBanner, Bytes: b})
123140
case Curve_P256:
124141
return pem.EncodeToMemory(&pem.Block{Type: P256PrivateKeyBanner, Bytes: b})
142+
case Curve_PQ:
143+
return pem.EncodeToMemory(&pem.Block{Type: MLKEM1024PrivateKeyBanner, Bytes: b})
125144
default:
126145
return nil
127146
}
@@ -133,6 +152,8 @@ func MarshalSigningPrivateKeyToPEM(curve Curve, b []byte) []byte {
133152
return pem.EncodeToMemory(&pem.Block{Type: Ed25519PrivateKeyBanner, Bytes: b})
134153
case Curve_P256:
135154
return pem.EncodeToMemory(&pem.Block{Type: ECDSAP256PrivateKeyBanner, Bytes: b})
155+
case Curve_PQ:
156+
return pem.EncodeToMemory(&pem.Block{Type: MLDSA87PrivateKeyBanner, Bytes: b})
136157
default:
137158
return nil
138159
}
@@ -154,6 +175,9 @@ func UnmarshalPrivateKeyFromPEM(b []byte) ([]byte, []byte, Curve, error) {
154175
case P256PrivateKeyBanner:
155176
expectedLen = 32
156177
curve = Curve_P256
178+
case MLKEM1024PrivateKeyBanner:
179+
expectedLen = mlkem1024.PrivateKeySize
180+
curve = Curve_PQ
157181
default:
158182
return nil, r, 0, fmt.Errorf("bytes did not contain a proper private key banner")
159183
}
@@ -184,8 +208,15 @@ func UnmarshalSigningPrivateKeyFromPEM(b []byte) ([]byte, []byte, Curve, error)
184208
if len(k.Bytes) != 32 {
185209
return nil, r, 0, fmt.Errorf("key was not 32 bytes, is invalid ECDSA P256 private key")
186210
}
211+
case EncryptedMLDSA87PrivateKeyBanner:
212+
return nil, nil, Curve_PQ, ErrPrivateKeyEncrypted
213+
case MLDSA87PrivateKeyBanner:
214+
curve = Curve_PQ
215+
if len(k.Bytes) != mldsa87.PrivateKeySize {
216+
return nil, r, 0, fmt.Errorf("key was not %d bytes, is invalid ML-DSA-87 private key", mldsa87.PrivateKeySize)
217+
}
187218
default:
188-
return nil, r, 0, fmt.Errorf("bytes did not contain a proper Ed25519/ECDSA private key banner")
219+
return nil, r, 0, fmt.Errorf("bytes did not contain a proper Ed25519/ECDSA/MLDSA87 private key banner")
189220
}
190221
return k.Bytes, r, curve, nil
191222
}

cert/pem_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
104104
k, rest, curve, err = UnmarshalSigningPrivateKeyFromPEM(rest)
105105
assert.Nil(t, k)
106106
assert.Equal(t, rest, invalidPem)
107-
require.EqualError(t, err, "bytes did not contain a proper Ed25519/ECDSA private key banner")
107+
require.EqualError(t, err, "bytes did not contain a proper Ed25519/ECDSA/MLDSA87 private key banner")
108108

109109
// Fail due to invalid PEM format, because
110110
// it's missing the requisite pre-encapsulation boundary.

cert/sign.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"net/netip"
1111
"time"
1212

13+
"github.com/cloudflare/circl/sign/mldsa/mldsa87"
1314
"github.com/slackhq/nebula/cert/p256"
1415
)
1516

@@ -67,6 +68,24 @@ func (t *TBSCertificate) Sign(signer Certificate, curve Curve, key []byte) (Cert
6768
return ecdsa.SignASN1(rand.Reader, pk, hashed[:])
6869
}
6970
return t.SignWith(signer, curve, sp)
71+
case Curve_PQ:
72+
// ML-DSA-87 (FIPS 204) signing via cloudflare/circl
73+
if len(key) != mldsa87.PrivateKeySize {
74+
return nil, fmt.Errorf("invalid ML-DSA-87 private key size: got %d, want %d", len(key), mldsa87.PrivateKeySize)
75+
}
76+
var sk mldsa87.PrivateKey
77+
var skBuf [mldsa87.PrivateKeySize]byte
78+
copy(skBuf[:], key)
79+
sk.Unpack(&skBuf)
80+
sp := func(certBytes []byte) ([]byte, error) {
81+
sig := make([]byte, mldsa87.SignatureSize)
82+
err := mldsa87.SignTo(&sk, certBytes, nil, false, sig)
83+
if err != nil {
84+
return nil, fmt.Errorf("ML-DSA-87 signing failed: %w", err)
85+
}
86+
return sig, nil
87+
}
88+
return t.SignWith(signer, curve, sp)
7089
default:
7190
return nil, fmt.Errorf("invalid curve: %s", t.Curve)
7291
}

0 commit comments

Comments
 (0)