diff --git a/default.nix b/default.nix index 9821512..20d2007 100644 --- a/default.nix +++ b/default.nix @@ -168,6 +168,7 @@ in rec { export PATH=${pkgs.lib.makeBinPath [ appImage pkgs.busybox + pkgs.yq-go pkgs.jq pkgs.dig ]} diff --git a/go/bootstrap/bootstrap.go b/go/bootstrap/bootstrap.go index 1ce8778..6ebc786 100644 --- a/go/bootstrap/bootstrap.go +++ b/go/bootstrap/bootstrap.go @@ -10,6 +10,7 @@ import ( "isle/admin" "isle/garage" "isle/nebula" + "net" "os" "path/filepath" "sort" @@ -27,31 +28,81 @@ func AppDirPath(appDirPath string) string { return filepath.Join(appDirPath, "share/bootstrap.json") } +// Garage contains parameters needed to connect to and use the garage cluster. +type Garage struct { + // TODO RPCSecret and GlobalBucketS3APICredentials are duplicated here and + // in AdminCreationParams, might as well just use them from there + RPCSecret string + AdminToken string + GlobalBucketS3APICredentials garage.S3APICredentials +} + // Bootstrap is used for accessing all information contained within a // bootstrap.json file. type Bootstrap struct { AdminCreationParams admin.CreationParams + CAPublicCredentials nebula.CAPublicCredentials + Garage Garage - Hosts map[string]Host - HostName string + PrivateCredentials nebula.HostPrivateCredentials + HostAssigned `json:"-"` + SignedHostAssigned nebula.Signed[HostAssigned] // signed by CA - Nebula struct { - CAPublicCredentials nebula.CAPublicCredentials - HostCredentials nebula.HostCredentials - SignedPublicCredentials string + Hosts map[string]Host +} + +// New initializes and returns a new Bootstrap file for a new host. This +// function assigns Hosts an empty map. +func New( + caCreds nebula.CACredentials, + adminCreationParams admin.CreationParams, + garage Garage, + name string, + ip net.IP, +) ( + Bootstrap, error, +) { + hostPubCreds, hostPrivCreds, err := nebula.NewHostCredentials( + caCreds, name, ip, + ) + if err != nil { + return Bootstrap{}, fmt.Errorf("generating host credentials: %w", err) } - Garage struct { - RPCSecret string - AdminToken string - GlobalBucketS3APICredentials garage.S3APICredentials + assigned := HostAssigned{ + Name: name, + PublicCredentials: hostPubCreds, } + + signedAssigned, err := nebula.Sign(assigned, caCreds.SigningPrivateKey) + if err != nil { + return Bootstrap{}, fmt.Errorf("signing assigned fields: %w", err) + } + + return Bootstrap{ + AdminCreationParams: adminCreationParams, + CAPublicCredentials: caCreds.Public, + Garage: garage, + PrivateCredentials: hostPrivCreds, + HostAssigned: assigned, + SignedHostAssigned: signedAssigned, + Hosts: map[string]Host{}, + }, nil } // FromReader reads a bootstrap file from the given io.Reader. func FromReader(r io.Reader) (Bootstrap, error) { var b Bootstrap + err := json.NewDecoder(r).Decode(&b) + if err != nil { + return Bootstrap{}, fmt.Errorf("decoding json: %w", err) + } + + if b.HostAssigned, err = b.SignedHostAssigned.UnwrapUnsafe(); err != nil { + return Bootstrap{}, fmt.Errorf("unwrapping host assigned: %w", err) + } + return b, err } @@ -76,9 +127,9 @@ func (b Bootstrap) WriteTo(into io.Writer) error { // HostName isn't found in the Hosts map. func (b Bootstrap) ThisHost() Host { - host, ok := b.Hosts[b.HostName] + host, ok := b.Hosts[b.Name] if !ok { - panic(fmt.Sprintf("hostname %q not defined in bootstrap's hosts", b.HostName)) + panic(fmt.Sprintf("hostname %q not defined in bootstrap's hosts", b.Name)) } return host diff --git a/go/bootstrap/garage_global_bucket.go b/go/bootstrap/garage_global_bucket.go index caea77e..0248893 100644 --- a/go/bootstrap/garage_global_bucket.go +++ b/go/bootstrap/garage_global_bucket.go @@ -8,7 +8,6 @@ import ( "isle/garage" "isle/nebula" "path/filepath" - "strings" "github.com/mediocregopher/mediocre-go-lib/v2/mctx" "github.com/mediocregopher/mediocre-go-lib/v2/mlog" @@ -24,26 +23,24 @@ const ( // into garage so that other hosts are able to see relevant configuration for // it. func (b Bootstrap) PutGarageBoostrapHost(ctx context.Context) error { + var ( + host = b.ThisHost() + client = b.GlobalBucketS3APIClient() + ) - host := b.ThisHost() - client := b.GlobalBucketS3APIClient() - - // the base Bootstrap has the public credentials signed by the CA, but we - // need this to be presented in the data stored into garage, so other hosts - // can verify that the stored host object is signed by the host public key, - // and that the host public key is signed by the CA. - host.Nebula.SignedPublicCredentials = b.Nebula.SignedPublicCredentials - - hostB, err := json.Marshal(host) + configured, err := nebula.Sign( + host.HostConfigured, b.PrivateCredentials.SigningPrivateKey, + ) if err != nil { - return fmt.Errorf("encoding host data: %w", err) + return fmt.Errorf("signing host configured data: %w", err) } - buf := new(bytes.Buffer) - - err = nebula.SignAndWrap(buf, b.Nebula.HostCredentials.SigningPrivateKeyPEM, hostB) + hostB, err := json.Marshal(AuthenticatedHost{ + Assigned: b.SignedHostAssigned, + Configured: configured, + }) if err != nil { - return fmt.Errorf("signing encoded host data: %w", err) + return fmt.Errorf("encoding host data: %w", err) } filePath := filepath.Join( @@ -52,7 +49,11 @@ func (b Bootstrap) PutGarageBoostrapHost(ctx context.Context) error { ) _, err = client.PutObject( - ctx, garage.GlobalBucket, filePath, buf, int64(buf.Len()), + ctx, + garage.GlobalBucket, + filePath, + bytes.NewReader(hostB), + int64(len(hostB)), minio.PutObjectOptions{}, ) @@ -119,49 +120,19 @@ func (b Bootstrap) GetGarageBootstrapHosts( return nil, fmt.Errorf("retrieving object %q: %w", objInfo.Key, err) } - hostB, hostSig, err := nebula.Unwrap(obj) + var authedHost AuthenticatedHost + + err = json.NewDecoder(obj).Decode(&authedHost) obj.Close() if err != nil { - return nil, fmt.Errorf("unwrapping signature from %q: %w", objInfo.Key, err) + logger.Warn(ctx, "object contains invalid json", err) + continue } - var host Host - if err = json.Unmarshal(hostB, &host); err != nil { - return nil, fmt.Errorf("decoding object %q: %w", objInfo.Key, err) - } - - hostPublicCredsB, hostPublicCredsSig, err := nebula.Unwrap( - strings.NewReader(host.Nebula.SignedPublicCredentials), - ) - + host, err := authedHost.Unwrap(b.CAPublicCredentials) if err != nil { - logger.Warn(ctx, "unwrapping signed public creds", err) - continue - } - - err = nebula.ValidateSignature( - b.Nebula.CAPublicCredentials.SigningKeyPEM, - hostPublicCredsB, - hostPublicCredsSig, - ) - - if err != nil { - logger.Warn(ctx, "invalid signed public creds", err) - continue - } - - var hostPublicCreds nebula.HostPublicCredentials - if err := json.Unmarshal(hostPublicCredsB, &hostPublicCreds); err != nil { - logger.Warn(ctx, "unmarshaling signed public creds", err) - continue - } - - err = nebula.ValidateSignature(hostPublicCreds.SigningKeyPEM, hostB, hostSig) - - if err != nil { - logger.Warn(ctx, "invalid host data", err) - continue + logger.Warn(ctx, "host could not be authenticated", err) } hosts[host.Name] = host diff --git a/go/bootstrap/hosts.go b/go/bootstrap/hosts.go index 37dbcbb..862216a 100644 --- a/go/bootstrap/hosts.go +++ b/go/bootstrap/hosts.go @@ -1,44 +1,15 @@ package bootstrap import ( - "bytes" - "encoding/json" "fmt" "isle/nebula" "net" - "strings" ) // NebulaHost describes the nebula configuration of a Host which is relevant for // other hosts to know. type NebulaHost struct { - SignedPublicCredentials string - PublicAddr string -} - -// NewNebulaHostSignedPublicCredentials constructs the SignedPublicCredentials -// field of the NebulaHost struct, using the CACredentials to sign the -// HostPublicCredentials. -func NewNebulaHostSignedPublicCredentials( - caCreds nebula.CACredentials, - hostPublicCreds nebula.HostPublicCredentials, -) ( - string, error, -) { - - hostPublicCredsB, err := json.Marshal(hostPublicCreds) - if err != nil { - return "", fmt.Errorf("marshaling host's public credentials: %w", err) - } - - buf := new(bytes.Buffer) - - err = nebula.SignAndWrap(buf, caCreds.SigningPrivateKeyPEM, hostPublicCredsB) - if err != nil { - return "", fmt.Errorf("signing host's public credentials: %w", err) - } - - return buf.String(), nil + PublicAddr string } // GarageHost describes a single garage instance in the GarageHost. @@ -54,12 +25,51 @@ type GarageHost struct { Instances []GarageHostInstance } -// Host consolidates all information about a single host from the bootstrap -// file. +// HostAssigned are all fields related to a host which were assigned to it by an +// admin. +type HostAssigned struct { + Name string + PublicCredentials nebula.HostPublicCredentials +} + +// HostConfigured are all the fields a host can configure for itself. +type HostConfigured struct { + Nebula NebulaHost `json:",omitempty"` + Garage GarageHost `json:",omitempty"` +} + +// AuthenticatedHost wraps all the data about a host which other hosts may know +// about it, such that those hosts can authenticate that the data is valid and +// approved by an admin. +type AuthenticatedHost struct { + Assigned nebula.Signed[HostAssigned] // signed by CA + Configured nebula.Signed[HostConfigured] // signed by host +} + +// Unwrap attempts to authenticate and unwrap the Host embedded in this +// instance. nebula.ErrInvalidSignature is returned if any signatures are +// invalid. +func (ah AuthenticatedHost) Unwrap(caCreds nebula.CAPublicCredentials) (Host, error) { + assigned, err := ah.Assigned.Unwrap(caCreds.SigningKey) + if err != nil { + return Host{}, fmt.Errorf("unwrapping assigned fields using CA public key: %w", err) + } + + configured, err := ah.Configured.Unwrap(assigned.PublicCredentials.SigningKey) + if err != nil { + return Host{}, fmt.Errorf("unwrapping configured fields using host public key: %w", err) + } + + return Host{assigned, configured}, nil +} + +// Host contains all data bout a host which other hosts may know about it. +// +// A Host should only be obtained over the network as an AuthenticatedHost, and +// subsequently Unwrapped. type Host struct { - Name string - Nebula NebulaHost - Garage GarageHost + HostAssigned + HostConfigured } // IP returns the IP address encoded in the Host's nebula certificate, or panics @@ -68,21 +78,7 @@ type Host struct { // This assumes that the Host and its data has already been verified against the // CA signing key. func (h Host) IP() net.IP { - - hostPublicCredsB, _, err := nebula.Unwrap( - strings.NewReader(h.Nebula.SignedPublicCredentials), - ) - - if err != nil { - panic(fmt.Errorf("unwrapping host's signed public credentials: %w", err)) - } - - var hostPublicCreds nebula.HostPublicCredentials - if err := json.Unmarshal(hostPublicCredsB, &hostPublicCreds); err != nil { - panic(fmt.Errorf("unmarshaling host's public credentials: %w", err)) - } - - ip, err := nebula.IPFromHostCertPEM(hostPublicCreds.CertPEM) + ip, err := nebula.IPFromHostCertPEM(h.PublicCredentials.CertPEM) if err != nil { panic(fmt.Errorf("could not parse IP out of cert for host %q: %w", h.Name, err)) } diff --git a/go/cmd/entrypoint/admin.go b/go/cmd/entrypoint/admin.go index 25d64eb..8e31753 100644 --- a/go/cmd/entrypoint/admin.go +++ b/go/cmd/entrypoint/admin.go @@ -142,46 +142,28 @@ var subCmdAdminCreateNetwork = subCmd{ return fmt.Errorf("creating nebula CA cert: %w", err) } - nebulaHostCreds, err := nebula.NewHostCredentials(nebulaCACreds, *hostName, ip) - if err != nil { - return fmt.Errorf("creating nebula cert for host: %w", err) - } - - nebulaHostSignedPublicCreds, err := bootstrap.NewNebulaHostSignedPublicCredentials( - nebulaCACreds, - nebulaHostCreds.Public, - ) - - if err != nil { - return fmt.Errorf("creating signed public credentials for host: %w", err) - } - adminCreationParams := admin.CreationParams{ ID: randStr(32), Name: *name, Domain: *domain, } - hostBootstrap := bootstrap.Bootstrap{ - AdminCreationParams: adminCreationParams, - Hosts: map[string]bootstrap.Host{ - *hostName: bootstrap.Host{ - Name: *hostName, - Nebula: bootstrap.NebulaHost{ - SignedPublicCredentials: nebulaHostSignedPublicCreds, - }, - }, - }, - HostName: *hostName, + garageBootstrap := bootstrap.Garage{ + RPCSecret: randStr(32), + AdminToken: randStr(32), + GlobalBucketS3APICredentials: garage.NewS3APICredentials(), } - hostBootstrap.Nebula.CAPublicCredentials = nebulaCACreds.Public - hostBootstrap.Nebula.HostCredentials = nebulaHostCreds - hostBootstrap.Nebula.SignedPublicCredentials = nebulaHostSignedPublicCreds - - hostBootstrap.Garage.RPCSecret = randStr(32) - hostBootstrap.Garage.AdminToken = randStr(32) - hostBootstrap.Garage.GlobalBucketS3APICredentials = garage.NewS3APICredentials() + hostBootstrap, err := bootstrap.New( + nebulaCACreds, + adminCreationParams, + garageBootstrap, + *hostName, + ip, + ) + if err != nil { + return fmt.Errorf("initializing bootstrap data: %w", err) + } if hostBootstrap, daemonConfig, err = coalesceDaemonConfigAndBootstrap(hostBootstrap, daemonConfig); err != nil { return fmt.Errorf("merging daemon config into bootstrap data: %w", err) @@ -263,7 +245,7 @@ var subCmdAdminCreateNetwork = subCmd{ var subCmdAdminCreateBootstrap = subCmd{ name: "create-bootstrap", descr: "Creates a new bootstrap.json file for a particular host and writes it to stdout", - checkLock: true, + checkLock: false, do: func(subCmdCtx subCmdCtx) error { flags := subCmdCtx.flagSet(false) @@ -306,39 +288,29 @@ var subCmdAdminCreateBootstrap = subCmd{ return fmt.Errorf("reading admin.json with --admin-path of %q: %w", *adminPath, err) } + garageBootstrap := bootstrap.Garage{ + RPCSecret: adm.Garage.RPCSecret, + AdminToken: randStr(32), + GlobalBucketS3APICredentials: adm.Garage.GlobalBucketS3APICredentials, + } + + newHostBootstrap, err := bootstrap.New( + adm.Nebula.CACredentials, + adm.CreationParams, + garageBootstrap, + *hostName, + ip, + ) + if err != nil { + return fmt.Errorf("initializing bootstrap data: %w", err) + } + hostBootstrap, err := loadHostBootstrap() if err != nil { return fmt.Errorf("loading host bootstrap: %w", err) } - nebulaHostCreds, err := nebula.NewHostCredentials(adm.Nebula.CACredentials, *hostName, ip) - if err != nil { - return fmt.Errorf("creating new nebula host key/cert: %w", err) - } - - nebulaHostSignedPublicCreds, err := bootstrap.NewNebulaHostSignedPublicCredentials( - adm.Nebula.CACredentials, - nebulaHostCreds.Public, - ) - - if err != nil { - return fmt.Errorf("creating signed public credentials for host: %w", err) - } - - newHostBootstrap := bootstrap.Bootstrap{ - AdminCreationParams: adm.CreationParams, - - Hosts: hostBootstrap.Hosts, - HostName: *hostName, - } - - newHostBootstrap.Nebula.CAPublicCredentials = adm.Nebula.CACredentials.Public - newHostBootstrap.Nebula.HostCredentials = nebulaHostCreds - newHostBootstrap.Nebula.SignedPublicCredentials = nebulaHostSignedPublicCreds - - newHostBootstrap.Garage.RPCSecret = adm.Garage.RPCSecret - newHostBootstrap.Garage.AdminToken = randStr(32) - newHostBootstrap.Garage.GlobalBucketS3APICredentials = adm.Garage.GlobalBucketS3APICredentials + newHostBootstrap.Hosts = hostBootstrap.Hosts return newHostBootstrap.WriteTo(os.Stdout) }, diff --git a/go/cmd/entrypoint/daemon_util.go b/go/cmd/entrypoint/daemon_util.go index 71356ea..c953f00 100644 --- a/go/cmd/entrypoint/daemon_util.go +++ b/go/cmd/entrypoint/daemon_util.go @@ -2,10 +2,10 @@ package main import ( "context" + "fmt" "isle/bootstrap" "isle/daemon" "isle/garage" - "fmt" "time" ) @@ -17,10 +17,11 @@ func coalesceDaemonConfigAndBootstrap( ) { host := bootstrap.Host{ - Name: hostBootstrap.HostName, - Nebula: bootstrap.NebulaHost{ - SignedPublicCredentials: hostBootstrap.Nebula.SignedPublicCredentials, - PublicAddr: daemonConfig.VPN.PublicAddr, + HostAssigned: hostBootstrap.HostAssigned, + HostConfigured: bootstrap.HostConfigured{ + Nebula: bootstrap.NebulaHost{ + PublicAddr: daemonConfig.VPN.PublicAddr, + }, }, } diff --git a/go/cmd/entrypoint/nebula.go b/go/cmd/entrypoint/nebula.go index 8f30e81..710495f 100644 --- a/go/cmd/entrypoint/nebula.go +++ b/go/cmd/entrypoint/nebula.go @@ -23,7 +23,7 @@ var subCmdNebulaShow = subCmd{ return fmt.Errorf("loading host bootstrap: %w", err) } - caPublicCreds := hostBootstrap.Nebula.CAPublicCredentials + caPublicCreds := hostBootstrap.CAPublicCredentials caCert, _, err := cert.UnmarshalNebulaCertificateFromPEM([]byte(caPublicCreds.CertPEM)) if err != nil { return fmt.Errorf("unmarshaling ca.crt: %w", err) diff --git a/go/cmd/entrypoint/nebula_util.go b/go/cmd/entrypoint/nebula_util.go index a3fb2fa..9128837 100644 --- a/go/cmd/entrypoint/nebula_util.go +++ b/go/cmd/entrypoint/nebula_util.go @@ -58,9 +58,9 @@ func nebulaPmuxProcConfig( config := map[string]interface{}{ "pki": map[string]string{ - "ca": hostBootstrap.Nebula.CAPublicCredentials.CertPEM, - "cert": hostBootstrap.Nebula.HostCredentials.Public.CertPEM, - "key": hostBootstrap.Nebula.HostCredentials.PrivateKeyPEM, + "ca": hostBootstrap.CAPublicCredentials.CertPEM, + "cert": hostBootstrap.PublicCredentials.CertPEM, + "key": hostBootstrap.PrivateCredentials.PrivateKeyPEM, }, "static_host_map": staticHostMap, "punchy": map[string]bool{ diff --git a/go/nebula/keys.go b/go/nebula/keys.go new file mode 100644 index 0000000..d64c0a1 --- /dev/null +++ b/go/nebula/keys.go @@ -0,0 +1,72 @@ +package nebula + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "fmt" + + "github.com/slackhq/nebula/cert" +) + +// SigningPrivateKey wraps an ed25519.PrivateKey to provide convenient JSON +// (un)marshaling methods. +type SigningPrivateKey ed25519.PrivateKey + +// MarshalJSON implements the json.Marshaler interface. +func (k SigningPrivateKey) MarshalJSON() ([]byte, error) { + pemStr := cert.MarshalEd25519PrivateKey(ed25519.PrivateKey(k)) + return json.Marshal(string(pemStr)) +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (k *SigningPrivateKey) UnmarshalJSON(b []byte) error { + var pemStr string + if err := json.Unmarshal(b, &pemStr); err != nil { + return fmt.Errorf("unmarshaling into string: %w", err) + } + + key, _, err := cert.UnmarshalEd25519PrivateKey([]byte(pemStr)) + if err != nil { + return fmt.Errorf("unmarshaling from PEM: %w", err) + } + + *k = SigningPrivateKey(key) + return nil +} + +// SigningPublicKey wraps an ed25519.PublicKey to provide convenient JSON +// (un)marshaling methods. +type SigningPublicKey ed25519.PublicKey + +// MarshalJSON implements the json.Marshaler interface. +func (k SigningPublicKey) MarshalJSON() ([]byte, error) { + pemStr := cert.MarshalEd25519PublicKey(ed25519.PublicKey(k)) + return json.Marshal(string(pemStr)) +} + +// MarshalJSON implements the json.Unmarshaler interface. +func (k *SigningPublicKey) UnmarshalJSON(b []byte) error { + var pemStr string + if err := json.Unmarshal(b, &pemStr); err != nil { + return fmt.Errorf("unmarshaling into string: %w", err) + } + + key, _, err := cert.UnmarshalEd25519PublicKey([]byte(pemStr)) + if err != nil { + return fmt.Errorf("unmarshaling from PEM: %w", err) + } + + *k = SigningPublicKey(key) + return nil +} + +// GenerateSigningPair generates and returns a new key pair which can be used +// for signing arbitrary blobs of bytes. +func GenerateSigningPair() (SigningPublicKey, SigningPrivateKey) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + panic(fmt.Errorf("generating ed25519 key: %w", err)) + } + return SigningPublicKey(pub), SigningPrivateKey(priv) +} diff --git a/go/nebula/keys_test.go b/go/nebula/keys_test.go new file mode 100644 index 0000000..13e140f --- /dev/null +++ b/go/nebula/keys_test.go @@ -0,0 +1,52 @@ +package nebula + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestSigningKeysJSON(t *testing.T) { + pub, priv := GenerateSigningPair() + + t.Run("SigningPublicKey", func(t *testing.T) { + pubJSON, err := json.Marshal(pub) + if err != nil { + t.Fatal(err) + } + + if !strings.HasPrefix(string(pubJSON), `"-----BEGIN `) { + t.Fatalf("pub key didn't marshal to PEM: %q", pubJSON) + } + + var pub2 SigningPublicKey + if err := json.Unmarshal(pubJSON, &pub2); err != nil { + t.Fatal(err) + } + + if !bytes.Equal([]byte(pub), []byte(pub2)) { + t.Fatalf("json unmarshaling got different result: %q", pub2) + } + }) + + t.Run("SigningPrivateKey", func(t *testing.T) { + privJSON, err := json.Marshal(priv) + if err != nil { + t.Fatal(err) + } + + if !strings.HasPrefix(string(privJSON), `"-----BEGIN `) { + t.Fatalf("priv key didn't marshal to PEM: %q", privJSON) + } + + var priv2 SigningPrivateKey + if err := json.Unmarshal(privJSON, &priv2); err != nil { + t.Fatal(err) + } + + if !bytes.Equal([]byte(priv), []byte(priv2)) { + t.Fatalf("json unmarshaling got different result: %q", priv2) + } + }) +} diff --git a/go/nebula/nebula.go b/go/nebula/nebula.go index f1bac81..1346780 100644 --- a/go/nebula/nebula.go +++ b/go/nebula/nebula.go @@ -3,11 +3,7 @@ package nebula import ( - "crypto" - "crypto/ed25519" "crypto/rand" - "encoding/pem" - "errors" "fmt" "io" "net" @@ -17,38 +13,33 @@ import ( "golang.org/x/crypto/curve25519" ) -// ErrInvalidSignature is returned from functions when a signature validation -// fails. -var ErrInvalidSignature = errors.New("invalid signature") - // HostPublicCredentials contains certificate and signing public keys which are // able to be broadcast publicly. type HostPublicCredentials struct { - CertPEM string - SigningKeyPEM string + CertPEM string + SigningKey SigningPublicKey } -// HostCredentials contains the certificate and private key files which will -// need to be present on a particular host. Each file is PEM encoded. -type HostCredentials struct { - Public HostPublicCredentials - PrivateKeyPEM string - SigningPrivateKeyPEM string +// HostPrivateCredentials contains the private key files which will +// need to be present on a particular host. +type HostPrivateCredentials struct { + PrivateKeyPEM string + SigningPrivateKey SigningPrivateKey } // CAPublicCredentials contains certificate and signing public keys which are // able to be broadcast publicly. The signing public key is the same one which // is embedded into the certificate. type CAPublicCredentials struct { - CertPEM string - SigningKeyPEM string // TODO remove redundant field + CertPEM string + SigningKey SigningPublicKey } // CACredentials contains the certificate and private files which can be used to // create and validate HostCredentials. Each file is PEM encoded. type CACredentials struct { - Public CAPublicCredentials - SigningPrivateKeyPEM string + Public CAPublicCredentials + SigningPrivateKey SigningPrivateKey } // NewHostCertPEM generates and signs a new host certificate containing the @@ -63,11 +54,6 @@ func NewHostCertPEM( return "", fmt.Errorf("unmarshaling public key PEM: %w", err) } - caSigningKey, _, err := cert.UnmarshalEd25519PrivateKey([]byte(caCreds.SigningPrivateKeyPEM)) - if err != nil { - return "", fmt.Errorf("unmarshaling ca.key: %w", err) - } - caCert, _, err := cert.UnmarshalNebulaCertificateFromPEM([]byte(caCreds.Public.CertPEM)) if err != nil { return "", fmt.Errorf("unmarshaling ca.crt: %w", err) @@ -104,7 +90,7 @@ func NewHostCertPEM( return "", fmt.Errorf("validating certificate constraints: %w", err) } - if err := hostCert.Sign(caSigningKey); err != nil { + if err := signCert(&hostCert, caCreds.SigningPrivateKey); err != nil { return "", fmt.Errorf("signing host cert with ca.key: %w", err) } @@ -121,46 +107,45 @@ func NewHostCertPEM( func NewHostCredentials( caCreds CACredentials, hostName string, ip net.IP, ) ( - HostCredentials, error, + pub HostPublicCredentials, priv HostPrivateCredentials, err error, ) { // The logic here is largely based on // https://github.com/slackhq/nebula/blob/v1.4.0/cmd/nebula-cert/sign.go - signingPubKey, signingPrivKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - panic(fmt.Errorf("generating ed25519 key: %w", err)) - } - - signingPrivKeyPEM := cert.MarshalEd25519PrivateKey(signingPrivKey) - signingPubKeyPEM := cert.MarshalEd25519PublicKey(signingPubKey) - var hostPub, hostKey []byte { var pubkey, privkey [32]byte - if _, err := io.ReadFull(rand.Reader, privkey[:]); err != nil { - return HostCredentials{}, fmt.Errorf("reading random bytes to form private key: %w", err) + if _, err = io.ReadFull(rand.Reader, privkey[:]); err != nil { + err = fmt.Errorf("reading random bytes to form private key: %w", err) + return } curve25519.ScalarBaseMult(&pubkey, &privkey) hostPub, hostKey = pubkey[:], privkey[:] } + signingPubKey, signingPrivKey := GenerateSigningPair() + hostPubPEM := cert.MarshalX25519PublicKey(hostPub) hostKeyPEM := cert.MarshalX25519PrivateKey(hostKey) hostCertPEM, err := NewHostCertPEM(caCreds, string(hostPubPEM), hostName, ip) if err != nil { - return HostCredentials{}, fmt.Errorf("creating host certificate: %w", err) + err = fmt.Errorf("creating host certificate: %w", err) + return } - return HostCredentials{ - Public: HostPublicCredentials{ - CertPEM: hostCertPEM, - SigningKeyPEM: string(signingPubKeyPEM), - }, - PrivateKeyPEM: string(hostKeyPEM), - SigningPrivateKeyPEM: string(signingPrivKeyPEM), - }, nil + pub = HostPublicCredentials{ + CertPEM: hostCertPEM, + SigningKey: signingPubKey, + } + + priv = HostPrivateCredentials{ + PrivateKeyPEM: string(hostKeyPEM), + SigningPrivateKey: signingPrivKey, + } + + return } // NewCACredentials generates a CACredentials. The domain should be the network's root domain, @@ -170,10 +155,7 @@ func NewCACredentials(domain string, subnet *net.IPNet) (CACredentials, error) { // The logic here is largely based on // https://github.com/slackhq/nebula/blob/v1.4.0/cmd/nebula-cert/ca.go - signingPubKey, signingPrivKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - panic(fmt.Errorf("generating ed25519 key: %w", err)) - } + signingPubKey, signingPrivKey := GenerateSigningPair() now := time.Now() expireAt := now.Add(2 * 365 * 24 * time.Hour) @@ -189,13 +171,10 @@ func NewCACredentials(domain string, subnet *net.IPNet) (CACredentials, error) { }, } - if err := caCert.Sign(signingPrivKey); err != nil { + if err := signCert(&caCert, signingPrivKey); err != nil { return CACredentials{}, fmt.Errorf("signing caCert: %w", err) } - signingPrivKeyPEM := cert.MarshalEd25519PrivateKey(signingPrivKey) - signingPubKeyPEM := cert.MarshalEd25519PublicKey(signingPubKey) - certPEM, err := caCert.MarshalToPEM() if err != nil { return CACredentials{}, fmt.Errorf("marshaling caCert: %w", err) @@ -203,10 +182,10 @@ func NewCACredentials(domain string, subnet *net.IPNet) (CACredentials, error) { return CACredentials{ Public: CAPublicCredentials{ - CertPEM: string(certPEM), - SigningKeyPEM: string(signingPubKeyPEM), + CertPEM: string(certPEM), + SigningKey: signingPubKey, }, - SigningPrivateKeyPEM: string(signingPrivKeyPEM), + SigningPrivateKey: signingPrivKey, }, nil } @@ -226,76 +205,3 @@ func IPFromHostCertPEM(hostCertPEM string) (net.IP, error) { return ips[0].IP, nil } - -// SignAndWrap signs the given bytes using the host key, and writes an -// encoded, versioned structure containing the signature and the given bytes. -func SignAndWrap(into io.Writer, signingKeyPEM string, b []byte) error { - - key, _, err := cert.UnmarshalEd25519PrivateKey([]byte(signingKeyPEM)) - if err != nil { - return fmt.Errorf("unmarshaling private key: %w", err) - } - - sig, err := key.Sign(rand.Reader, b, crypto.Hash(0)) - if err != nil { - return fmt.Errorf("generating signature: %w", err) - } - - if _, err := into.Write([]byte("0")); err != nil { - return fmt.Errorf("writing version byte: %w", err) - } - - err = pem.Encode(into, &pem.Block{ - Type: "NEBULA ED25519 SIGNATURE", - Bytes: sig, - }) - - if err != nil { - return fmt.Errorf("writing PEM encoding of signature: %w", err) - } - - if _, err := into.Write(b); err != nil { - return fmt.Errorf("writing input bytes: %w", err) - } - - return nil -} - -// Unwrap reads a stream of bytes which was produced by SignAndWrap, and returns -// the original input to SignAndWrap as well as the signature which was -// created. ValidateSignature can be used to validate the signature. -func Unwrap(from io.Reader) (b, sig []byte, err error) { - - full, err := io.ReadAll(from) - if err != nil { - return nil, nil, fmt.Errorf("reading full input: %w", err) - } else if len(full) < 3 { - return nil, nil, fmt.Errorf("input too small") - } else if full[0] != '0' { - return nil, nil, fmt.Errorf("unexpected version byte: %d", full[0]) - } - - full = full[1:] - - pemBlock, rest := pem.Decode(full) - if pemBlock == nil { - return nil, nil, fmt.Errorf("PEM-encoded signature could not be decoded") - } - - return rest, pemBlock.Bytes, nil -} - -// ValidateSignature can be used to validate a signature produced by Unwrap. -func ValidateSignature(signingPubKeyPEM string, b, sig []byte) error { - - pubKey, _, err := cert.UnmarshalEd25519PublicKey([]byte(signingPubKeyPEM)) - if err != nil { - return fmt.Errorf("unmarshaling certificate as PEM: %w", err) - } - - if !ed25519.Verify(pubKey, b, sig) { - return ErrInvalidSignature - } - - return nil -} diff --git a/go/nebula/nebula_test.go b/go/nebula/nebula_test.go index 654fedf..c99373c 100644 --- a/go/nebula/nebula_test.go +++ b/go/nebula/nebula_test.go @@ -1,17 +1,15 @@ package nebula import ( - "bytes" - "errors" "net" - "testing" ) var ( - ip net.IP - ipNet *net.IPNet - caCredsA, caCredsB CACredentials - hostCredsA, hostCredsB HostCredentials + ip net.IP + ipNet *net.IPNet + caCredsA, caCredsB CACredentials + hostPubCredsA, hostPubCredsB HostPublicCredentials + hostPrivCredsA, hostPrivCredsB HostPrivateCredentials ) func init() { @@ -32,40 +30,14 @@ func init() { panic(err) } - hostCredsA, err = NewHostCredentials(caCredsA, "foo", ip) + hostPubCredsA, hostPrivCredsA, err = NewHostCredentials(caCredsA, "foo", ip) if err != nil { panic(err) } - hostCredsB, err = NewHostCredentials(caCredsB, "bar", ip) + hostPubCredsB, hostPrivCredsB, err = NewHostCredentials(caCredsB, "bar", ip) if err != nil { panic(err) } } - -func TestSignAndWrap(t *testing.T) { - - b := []byte("foo bar baz") - buf := new(bytes.Buffer) - - if err := SignAndWrap(buf, hostCredsA.SigningPrivateKeyPEM, b); err != nil { - t.Fatal(err) - } - - gotB, gotSig, err := Unwrap(buf) - if err != nil { - t.Fatal(err) - - } else if !bytes.Equal(b, gotB) { - t.Fatalf("got %q but expected %q", gotB, b) - } - - if err := ValidateSignature(hostCredsA.Public.SigningKeyPEM, b, gotSig); err != nil { - t.Fatal(err) - } - - if err := ValidateSignature(hostCredsB.Public.SigningKeyPEM, b, gotSig); !errors.Is(err, ErrInvalidSignature) { - t.Fatalf("expected ErrInvalidSignature but got %v", err) - } -} diff --git a/go/nebula/signed.go b/go/nebula/signed.go new file mode 100644 index 0000000..0170740 --- /dev/null +++ b/go/nebula/signed.go @@ -0,0 +1,103 @@ +package nebula + +import ( + "crypto" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + + "github.com/slackhq/nebula/cert" +) + +// ErrInvalidSignature is returned from functions when a signature validation +// fails. +var ErrInvalidSignature = errors.New("invalid signature") + +func signCert(c *cert.NebulaCertificate, k SigningPrivateKey) error { + return c.Sign(ed25519.PrivateKey(k)) +} + +// Signed wraps an arbitrary value with a signature which was generated using a +// SigningPrivateKey. It can be JSON (un)marshaled while preserving all of its +// properties. +type Signed[T any] json.RawMessage + +type signed[T any] struct { + Signature []byte + Body json.RawMessage +} + +// Sign will generate a Signed of the given value by first JSON marshaling it, +// and then signing the resulting bytes. +func Sign[T any](v T, k SigningPrivateKey) (Signed[T], error) { + var res Signed[T] + + b, err := json.Marshal(v) + if err != nil { + return res, fmt.Errorf("json marshaling: %w", err) + } + + sig, err := ed25519.PrivateKey(k).Sign(rand.Reader, b, crypto.Hash(0)) + if err != nil { + return res, fmt.Errorf("generating signature: %w", err) + } + + return json.Marshal(signed[T]{Signature: sig, Body: json.RawMessage(b)}) +} + +// MarshalJSON implements the json.Marshaler interface. +func (s Signed[T]) MarshalJSON() ([]byte, error) { + return []byte(s), nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (s *Signed[T]) UnmarshalJSON(b []byte) error { + *s = b + return nil +} + +// Unwrap produces the original value which Sign was called on, or returns +// ErrInvalidSignature if the signature is not valid for the value and public +// key. +func (s Signed[T]) Unwrap(pubK SigningPublicKey) (T, error) { + var ( + res T + into signed[T] + ) + + if err := json.Unmarshal(s, &into); err != nil { + return res, fmt.Errorf("json unmarshaling outer Signed: %w", err) + } + + if !ed25519.Verify( + ed25519.PublicKey(pubK), []byte(into.Body), into.Signature, + ) { + return res, ErrInvalidSignature + } + + if err := json.Unmarshal(into.Body, &res); err != nil { + return res, fmt.Errorf("json unmarshaling: %w", err) + } + + return res, nil +} + +// UnwrapUnsafe is like Unwrap, but it will not check the signature. +func (s Signed[T]) UnwrapUnsafe() (T, error) { + var ( + res T + into signed[T] + ) + + if err := json.Unmarshal(s, &into); err != nil { + return res, fmt.Errorf("json unmarshaling outer Signed: %w", err) + } + + if err := json.Unmarshal(into.Body, &res); err != nil { + return res, fmt.Errorf("json unmarshaling: %w", err) + } + + return res, nil +} diff --git a/go/nebula/signed_test.go b/go/nebula/signed_test.go new file mode 100644 index 0000000..afb6ec2 --- /dev/null +++ b/go/nebula/signed_test.go @@ -0,0 +1,48 @@ +package nebula + +import ( + "encoding/json" + "errors" + "testing" +) + +func TestSigned(t *testing.T) { + type msg struct { + A int + B string + C bool + } + + a := msg{1, "FOO", true} + + signedA, err := Sign(a, hostPrivCredsA.SigningPrivateKey) + if err != nil { + t.Fatal(err) + } + + signedJSON, err := json.Marshal(signedA) + if err != nil { + t.Fatal(err) + } + + t.Log(string(signedJSON)) + + var signedB Signed[msg] + if err := json.Unmarshal(signedJSON, &signedB); err != nil { + t.Fatal(err) + } + + _, err = signedB.Unwrap(hostPubCredsB.SigningKey) + if !errors.Is(err, ErrInvalidSignature) { + t.Fatalf("expected ErrInvalidSignature but got %v", err) + } + + b, err := signedB.Unwrap(hostPubCredsA.SigningKey) + if err != nil { + t.Fatal(err) + } + + if a != b { + t.Fatalf("expected:%+v, got:%+v", a, b) + } +} diff --git a/tests/cases/admin/01-create-network.sh b/tests/cases/admin/01-create-network.sh index 4c8535b..0f6c059 100644 --- a/tests/cases/admin/01-create-network.sh +++ b/tests/cases/admin/01-create-network.sh @@ -12,5 +12,5 @@ source "$UTILS"/with-1-data-1-empty-node-cluster.sh bootstrap_file="$XDG_DATA_HOME/isle/bootstrap.json" [ "$(jq -rc <"$bootstrap_file" '.AdminCreationParams')" = "$(jq -rc