Repository: smallstep/cli
Pull request #1085 (2023-12-12 15:14:48)

Fix Nebula key + cert verification
Nebula added support for using ECDSA with the P-256 curve: https://github.com/slackhq/nebula/pull/769.

This takes #1082, but fixes the breaking change.

I expect they might need some additional parameters in the future to be able to use other ECDSA curves too.


Patch:
diff --git a/go.mod b/go.mod
index 6d6f6244..19700fd2 100644
--- a/go.mod
+++ b/go.mod
@@ -14,7 +14,7 @@ require (
 	github.com/manifoldco/promptui v0.9.0
 	github.com/pkg/errors v0.9.1
 	github.com/pquerna/otp v1.4.0
-	github.com/slackhq/nebula v1.6.1
+	github.com/slackhq/nebula v1.8.0
 	github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262
 	github.com/smallstep/certificates v0.25.3-0.20231213004719-62e09de2c6a8
 	github.com/smallstep/certinfo v1.12.0
@@ -108,6 +108,7 @@ require (
 	github.com/peterbourgon/diskv/v3 v3.0.1 // indirect
 	github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/rogpeppe/go-internal v1.10.0 // indirect
 	github.com/rs/xid v1.5.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/schollz/jsonstore v1.1.0 // indirect
@@ -122,20 +123,21 @@ require (
 	github.com/x448/float16 v0.8.4 // indirect
 	go.etcd.io/bbolt v1.3.7 // indirect
 	go.opencensus.io v0.24.0 // indirect
-	golang.org/x/exp v0.0.0-20230310171629-522b1b587ee0 // indirect
-	golang.org/x/mod v0.10.0 // indirect
+	golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect
+	golang.org/x/mod v0.12.0 // indirect
 	golang.org/x/net v0.19.0 // indirect
 	golang.org/x/oauth2 v0.14.0 // indirect
 	golang.org/x/sync v0.5.0 // indirect
 	golang.org/x/text v0.14.0 // indirect
 	golang.org/x/time v0.5.0 // indirect
-	golang.org/x/tools v0.8.0 // indirect
+	golang.org/x/tools v0.13.0 // indirect
 	google.golang.org/api v0.153.0 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect
 	google.golang.org/grpc v1.59.0 // indirect
+	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 	howett.net/plist v1.0.0 // indirect
 	k8s.io/klog/v2 v2.100.1 // indirect
diff --git a/go.sum b/go.sum
index 06ab2a62..da1322bb 100644
--- a/go.sum
+++ b/go.sum
@@ -264,6 +264,7 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
 github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI=
@@ -317,7 +318,8 @@ github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
 github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
-github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
 github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
 github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
 github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
@@ -339,8 +341,8 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB
 github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
 github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
 github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
-github.com/slackhq/nebula v1.6.1 h1:/OCTR3abj0Sbf2nGoLUrdDXImrCv0ZVFpVPP5qa0DsM=
-github.com/slackhq/nebula v1.6.1/go.mod h1:UmkqnXe4O53QwToSl/gG7sM4BroQwAB7dd4hUaT6MlI=
+github.com/slackhq/nebula v1.8.0 h1:WCOmmg1JMyy4HcFwA/hEfAq7IJZB9MyaL12itu88Umk=
+github.com/slackhq/nebula v1.8.0/go.mod h1:3z8wvb4Qy+T9gNqyaVsxIYXQDLBBm8a6cmvUXA89RXc=
 github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY=
 github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc=
 github.com/smallstep/certificates v0.25.3-0.20231213004719-62e09de2c6a8 h1:zL0GLHpIcJIZba4pVZ4j+NAnCGBNzUFz0sXyEWDtTTE=
@@ -441,8 +443,8 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58
 golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
 golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20230310171629-522b1b587ee0 h1:LGJsf5LRplCck6jUCH3dBL2dmycNruWNF5xugkSlfXw=
-golang.org/x/exp v0.0.0-20230310171629-522b1b587ee0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
+golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o=
+golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
 golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -450,8 +452,8 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHl
 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
 golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
-golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -537,8 +539,8 @@ golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtn
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y=
-golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
+golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
 golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -582,8 +584,9 @@ google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs
 google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
 gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
diff --git a/token/options.go b/token/options.go
index 767f3f96..3643dffd 100644
--- a/token/options.go
+++ b/token/options.go
@@ -2,10 +2,14 @@ package token
 
 import (
 	"bytes"
+	"crypto/ecdh"
+	"crypto/ecdsa"
+	"crypto/ed25519"
 	"crypto/sha256"
 	"crypto/x509"
 	"encoding/hex"
 	"encoding/pem"
+	"fmt"
 	"os"
 	"time"
 
@@ -13,6 +17,7 @@ import (
 	nebula "github.com/slackhq/nebula/cert"
 	"go.step.sm/crypto/jose"
 	"go.step.sm/crypto/pemutil"
+	"go.step.sm/crypto/x25519"
 )
 
 // Options is a function that set claims.
@@ -194,7 +199,7 @@ func WithX5CCerts(certs []*x509.Certificate, key interface{}) Options {
 var pemCertPrefix = []byte("-----BEGIN")
 
 // WithNebulaCert returns a Options that sets the nebula header.
-func WithNebulaCert(certFile string, key []byte) Options {
+func WithNebulaCert(certFile string, anyKey any) Options {
 	return func(c *Claims) error {
 		b, err := os.ReadFile(certFile)
 		if err != nil {
@@ -211,9 +216,37 @@ func WithNebulaCert(certFile string, key []byte) Options {
 		if err != nil {
 			return errors.Wrapf(err, "error reading %s", certFile)
 		}
-		if err := crt.VerifyPrivateKey(key); err != nil {
+
+		var key []byte
+		var curve nebula.Curve
+		switch k := anyKey.(type) {
+		case x25519.PrivateKey:
+			key = []byte(k)
+			curve = nebula.Curve_CURVE25519
+		case ed25519.PrivateKey:
+			key = []byte(k)
+			curve = nebula.Curve_CURVE25519
+		case []byte:
+			key = k
+			curve = nebula.Curve_CURVE25519
+		case *ecdsa.PrivateKey:
+			pk, err := k.ECDH()
+			if err != nil {
+				return fmt.Errorf("failed transforming to ECDH key: %w", err)
+			}
+			key = pk.Bytes()
+			curve = nebula.Curve_P256
+		case *ecdh.PrivateKey:
+			key = k.Bytes()
+			curve = nebula.Curve_P256
+		default:
+			return errors.Errorf("key content is not a valid nebula key; got type %T", anyKey)
+		}
+
+		if err := crt.VerifyPrivateKey(curve, key); err != nil {
 			return errors.Wrapf(err, "error validating %s", certFile)
 		}
+
 		c.SetHeader("nebula", b)
 		return nil
 	}
diff --git a/utils/cautils/token_generator.go b/utils/cautils/token_generator.go
index 38090b85..465dbcc1 100644
--- a/utils/cautils/token_generator.go
+++ b/utils/cautils/token_generator.go
@@ -26,7 +26,6 @@ import (
 	"go.step.sm/crypto/jose"
 	"go.step.sm/crypto/pemutil"
 	"go.step.sm/crypto/randutil"
-	"go.step.sm/crypto/x25519"
 )
 
 // TokenGenerator is a helper used to generate different types of tokens used in
@@ -259,17 +258,7 @@ func generateNebulaToken(ctx *cli.Context, p *provisioner.Nebula, tokType int, t
 		return "", err
 	}
 
-	var key []byte
-	switch k := jwk.Key.(type) {
-	case x25519.PrivateKey:
-		key = []byte(k)
-	case ed25519.PrivateKey:
-		key = []byte(k)
-	case []byte:
-		key = k
-	default:
-		return "", errors.Errorf("error reading %s: content is not a valid nebula key", keyFile)
-	}
+	key := jwk.Key
 
 	tokenGen := NewTokenGenerator(jwk.KeyID, p.Name,
 		fmt.Sprintf("%s#%s", tokAttrs.audience, p.GetIDForToken()), tokAttrs.root,


Test patch:
diff --git a/token/options_test.go b/token/options_test.go
index 90ada94f..8c3303d1 100644
--- a/token/options_test.go
+++ b/token/options_test.go
@@ -1,16 +1,46 @@
 package token
 
 import (
-	"reflect"
+	"crypto/ecdh"
+	"crypto/ecdsa"
+	"crypto/ed25519"
+	"crypto/elliptic"
+	"crypto/rand"
+	"net"
+	"os"
+	"path/filepath"
 	"testing"
 	"time"
 
+	nebula "github.com/slackhq/nebula/cert"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 	"go.step.sm/crypto/jose"
+	"go.step.sm/crypto/x25519"
 )
 
 func TestOptions(t *testing.T) {
 	empty := new(Claims)
 	now := time.Now()
+	c25519CACert, c25519CAKey := mustNebulaCurve25519CA(t)
+	p256CACert, p256CAKey := mustNebulaP256CA(t)
+	c25519Cert, c25519Signer := mustNebulaCurve25519Cert(t, "test.lan", mustNebulaIPNet(t, "10.1.0.1/16"), []string{"test"}, c25519CACert, c25519CAKey)
+	p256Cert, p256Signer := mustNebulaP256Cert(t, "test.lan", mustNebulaIPNet(t, "10.1.0.1/16"), []string{"test"}, p256CACert, p256CAKey)
+
+	tempDir := t.TempDir()
+	c25519CACertFilename, c25519CACertData := serializeAndWriteNebulaCert(t, tempDir, c25519CACert)
+	c25519CertFilename, c25519CertData := serializeAndWriteNebulaCert(t, tempDir, c25519Cert)
+	p256CertFilename, p256CertData := serializeAndWriteNebulaCert(t, tempDir, p256Cert)
+
+	p256ECDHSigner, err := p256Signer.ECDH()
+	require.NoError(t, err)
+
+	wrongNebulaContentsFilename := "testdata/ca.crt"
+
+	emptyFile, err := os.CreateTemp(tempDir, "empty-file")
+	require.NoError(t, err)
+	emptyFile.Close()
+
 	tests := []struct {
 		name    string
 		option  Options
@@ -39,19 +69,186 @@ func TestOptions(t *testing.T) {
 		{"WithKid ok", WithKid("value"), &Claims{ExtraHeaders: map[string]interface{}{"kid": "value"}}, false},
 		{"WithKid fail", WithKid(""), empty, true},
 		{"WithSHA ok", WithSHA("6908751f68290d4573ae0be39a98c8b9b7b7d4e8b2a6694b7509946626adfe98"), &Claims{ExtraClaims: map[string]interface{}{"sha": "6908751f68290d4573ae0be39a98c8b9b7b7d4e8b2a6694b7509946626adfe98"}}, false},
+		{"WithNebulaCurve25519Cert ok", WithNebulaCert(c25519CertFilename, c25519Signer), &Claims{ExtraHeaders: map[string]interface{}{"nebula": c25519CertData}}, false},
+		{"WithNebulaCurve25519CACert ok", WithNebulaCert(c25519CACertFilename, c25519CAKey), &Claims{ExtraHeaders: map[string]interface{}{"nebula": c25519CACertData}}, false},
+		{"WithNebulaCurve25519Cert and key as bytes ok", WithNebulaCert(c25519CertFilename, []byte(c25519Signer)), &Claims{ExtraHeaders: map[string]interface{}{"nebula": c25519CertData}}, false},
+		{"WithNebulaP256Cert ok", WithNebulaCert(p256CertFilename, p256Signer), &Claims{ExtraHeaders: map[string]interface{}{"nebula": p256CertData}}, false},
+		{"WithNebulaP256Cert as ECDH signer ok", WithNebulaCert(p256CertFilename, p256ECDHSigner), &Claims{ExtraHeaders: map[string]interface{}{"nebula": p256CertData}}, false},
+		{"WithNebulaCurve25519Cert non existing file fail", WithNebulaCert(filepath.Join(tempDir, "does-not-exist"), nil), empty, true},
+		{"WithNebulaCurve25519Cert wrong contents fail", WithNebulaCert(wrongNebulaContentsFilename, nil), empty, true},
+		{"WithNebulaCurve25519Cert empty file fail", WithNebulaCert(emptyFile.Name(), nil), empty, true},
+		{"WithNebulaCurve25519Cert invalid content fail", WithNebulaCert(c25519CertFilename, nil), empty, true},
+		{"WithNebulaCurve25519Cert mismatching key fail", WithNebulaCert(c25519CertFilename, p256Signer), empty, true},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			claim := new(Claims)
 			err := tt.option(claim)
-			if (err != nil) != tt.wantErr {
-				t.Errorf("Options error = %v, wantErr %v", err, tt.wantErr)
+			if tt.wantErr {
+				assert.Error(t, err)
 				return
 			}
-			if !reflect.DeepEqual(claim, tt.want) {
-				t.Errorf("Options claims = %v, want %v", claim, tt.want)
-			}
+
+			assert.NoError(t, err)
+			assert.Equal(t, tt.want, claim)
 		})
 	}
 }
+
+func serializeAndWriteNebulaCert(t *testing.T, tempDir string, cert *nebula.NebulaCertificate) (string, []byte) {
+	file, err := os.CreateTemp(tempDir, "nebula-test-cert-*")
+	require.NoError(t, err)
+	defer file.Close()
+	pem, err := cert.MarshalToPEM()
+	require.NoError(t, err)
+	data, err := cert.Marshal()
+	require.NoError(t, err)
+	_, err = file.Write(pem)
+	require.NoError(t, err)
+	return file.Name(), data
+}
+
+func mustNebulaIPNet(t *testing.T, s string) *net.IPNet {
+	t.Helper()
+	ip, ipNet, err := net.ParseCIDR(s)
+	require.NoError(t, err)
+
+	if ip = ip.To4(); ip == nil {
+		require.Failf(t, "nebula only supports ipv4, have %s", s)
+	}
+
+	ipNet.IP = ip
+	return ipNet
+}
+
+func mustNebulaCurve25519CA(t *testing.T) (*nebula.NebulaCertificate, ed25519.PrivateKey) {
+	t.Helper()
+	pub, priv, err := ed25519.GenerateKey(rand.Reader)
+	require.NoError(t, err)
+
+	nc := &nebula.NebulaCertificate{
+		Details: nebula.NebulaCertificateDetails{
+			Name:   "TestCA",
+			Groups: []string{"test"},
+			Ips: []*net.IPNet{
+				mustNebulaIPNet(t, "10.1.0.0/16"),
+			},
+			Subnets:   []*net.IPNet{},
+			NotBefore: time.Now(),
+			NotAfter:  time.Now().Add(10 * time.Minute),
+			PublicKey: pub,
+			IsCA:      true,
+			Curve:     nebula.Curve_CURVE25519,
+		},
+	}
+
+	require.NoError(t, nc.Sign(nebula.Curve_CURVE25519, priv))
+
+	return nc, priv
+}
+
+func mustNebulaP256CA(t *testing.T) (*nebula.NebulaCertificate, *ecdh.PrivateKey) {
+	t.Helper()
+	priv, err := ecdh.P256().GenerateKey(rand.Reader)
+	require.NoError(t, err)
+
+	nc := &nebula.NebulaCertificate{
+		Details: nebula.NebulaCertificateDetails{
+			Name:   "TestCA",
+			Groups: []string{"test"},
+			Ips: []*net.IPNet{
+				mustNebulaIPNet(t, "10.1.0.0/16"),
+			},
+			Subnets:   []*net.IPNet{},
+			NotBefore: time.Now(),
+			NotAfter:  time.Now().Add(10 * time.Minute),
+			PublicKey: priv.PublicKey().Bytes(),
+			IsCA:      true,
+			Curve:     nebula.Curve_P256,
+		},
+	}
+
+	require.NoError(t, nc.Sign(nebula.Curve_P256, priv.Bytes()))
+
+	return nc, priv
+}
+
+func mustNebulaCurve25519Cert(t *testing.T, name string, ipNet *net.IPNet, groups []string, ca *nebula.NebulaCertificate, signer ed25519.PrivateKey) (*nebula.NebulaCertificate, x25519.PrivateKey) {
+	t.Helper()
+
+	pub, priv, err := x25519.GenerateKey(rand.Reader)
+	require.NoError(t, err)
+	issuer, err := ca.Sha256Sum()
+	require.NoError(t, err)
+
+	invertedGroups := make(map[string]struct{}, len(groups))
+	for _, name := range groups {
+		invertedGroups[name] = struct{}{}
+	}
+
+	key := []byte(signer)
+	curve := nebula.Curve_CURVE25519
+
+	t1 := time.Now().Truncate(time.Second)
+	nc := &nebula.NebulaCertificate{
+		Details: nebula.NebulaCertificateDetails{
+			Name:           name,
+			Ips:            []*net.IPNet{ipNet},
+			Subnets:        []*net.IPNet{},
+			Groups:         groups,
+			NotBefore:      t1,
+			NotAfter:       t1.Add(5 * time.Minute),
+			PublicKey:      pub,
+			IsCA:           false,
+			Issuer:         issuer,
+			InvertedGroups: invertedGroups,
+			Curve:          curve,
+		},
+	}
+
+	require.NoError(t, nc.Sign(curve, key))
+
+	return nc, priv
+}
+
+func mustNebulaP256Cert(t *testing.T, name string, ipNet *net.IPNet, groups []string, ca *nebula.NebulaCertificate, signer *ecdh.PrivateKey) (*nebula.NebulaCertificate, *ecdsa.PrivateKey) {
+	t.Helper()
+
+	priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	require.NoError(t, err)
+	issuer, err := ca.Sha256Sum()
+	require.NoError(t, err)
+
+	invertedGroups := make(map[string]struct{}, len(groups))
+	for _, name := range groups {
+		invertedGroups[name] = struct{}{}
+	}
+
+	key := signer.Bytes()
+	curve := nebula.Curve_P256
+
+	pk, err := priv.ECDH()
+	require.NoError(t, err)
+
+	t1 := time.Now().Truncate(time.Second)
+	nc := &nebula.NebulaCertificate{
+		Details: nebula.NebulaCertificateDetails{
+			Name:           name,
+			Ips:            []*net.IPNet{ipNet},
+			Subnets:        []*net.IPNet{},
+			Groups:         groups,
+			NotBefore:      t1,
+			NotAfter:       t1.Add(5 * time.Minute),
+			PublicKey:      pk.PublicKey().Bytes(),
+			IsCA:           false,
+			Issuer:         issuer,
+			InvertedGroups: invertedGroups,
+			Curve:          curve,
+		},
+	}
+
+	require.NoError(t, nc.Sign(curve, key))
+
+	return nc, priv
+}