Refactor how host data is signed, now it's simpler and probably more secure

This commit is contained in:
Brian Picciano 2024-06-10 22:31:29 +02:00
parent f13a08abfb
commit 78890d1f77
15 changed files with 500 additions and 356 deletions

View File

@ -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
PrivateCredentials nebula.HostPrivateCredentials
HostAssigned `json:"-"`
SignedHostAssigned nebula.Signed[HostAssigned] // signed by CA
Hosts map[string]Host
HostName string
}
Nebula struct {
CAPublicCredentials nebula.CAPublicCredentials
HostCredentials nebula.HostCredentials
SignedPublicCredentials string
// 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

View File

@ -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

View File

@ -1,46 +1,17 @@
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
}
// GarageHost describes a single garage instance in the GarageHost.
type GarageHostInstance struct {
ID string
@ -54,12 +25,51 @@ type GarageHost struct {
Instances []GarageHostInstance
}
// Host consolidates all information about a single host from the bootstrap
// file.
type Host struct {
// HostAssigned are all fields related to a host which were assigned to it by an
// admin.
type HostAssigned struct {
Name string
Nebula NebulaHost
Garage GarageHost
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 {
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))
}

View File

@ -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)
},

View File

@ -2,10 +2,10 @@ package main
import (
"context"
"fmt"
"isle/bootstrap"
"isle/daemon"
"isle/garage"
"fmt"
"time"
)
@ -17,11 +17,12 @@ func coalesceDaemonConfigAndBootstrap(
) {
host := bootstrap.Host{
Name: hostBootstrap.HostName,
HostAssigned: hostBootstrap.HostAssigned,
HostConfigured: bootstrap.HostConfigured{
Nebula: bootstrap.NebulaHost{
SignedPublicCredentials: hostBootstrap.Nebula.SignedPublicCredentials,
PublicAddr: daemonConfig.VPN.PublicAddr,
},
},
}
if allocs := daemonConfig.Storage.Allocations; len(allocs) > 0 {

View File

@ -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)

View File

@ -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{

72
go/nebula/keys.go Normal file
View File

@ -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)
}

52
go/nebula/keys_test.go Normal file
View File

@ -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)
}
})
}

View File

@ -3,11 +3,7 @@
package nebula
import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
@ -17,23 +13,18 @@ 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
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
// HostPrivateCredentials contains the private key files which will
// need to be present on a particular host.
type HostPrivateCredentials struct {
PrivateKeyPEM string
SigningPrivateKeyPEM string
SigningPrivateKey SigningPrivateKey
}
// CAPublicCredentials contains certificate and signing public keys which are
@ -41,14 +32,14 @@ type HostCredentials struct {
// is embedded into the certificate.
type CAPublicCredentials struct {
CertPEM string
SigningKeyPEM string // TODO remove redundant field
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
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{
pub = HostPublicCredentials{
CertPEM: hostCertPEM,
SigningKeyPEM: string(signingPubKeyPEM),
},
SigningKey: signingPubKey,
}
priv = HostPrivateCredentials{
PrivateKeyPEM: string(hostKeyPEM),
SigningPrivateKeyPEM: string(signingPrivKeyPEM),
}, nil
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)
@ -204,9 +183,9 @@ func NewCACredentials(domain string, subnet *net.IPNet) (CACredentials, error) {
return CACredentials{
Public: CAPublicCredentials{
CertPEM: string(certPEM),
SigningKeyPEM: string(signingPubKeyPEM),
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
}

View File

@ -1,17 +1,15 @@
package nebula
import (
"bytes"
"errors"
"net"
"testing"
)
var (
ip net.IP
ipNet *net.IPNet
caCredsA, caCredsB CACredentials
hostCredsA, hostCredsB HostCredentials
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)
}
}

103
go/nebula/signed.go Normal file
View File

@ -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
}

48
go/nebula/signed_test.go Normal file
View File

@ -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)
}
}

View File

@ -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 <admin.json '.CreationParams')" ]
[ "$(jq -rc <"$bootstrap_file" '.Nebula.CAPublicCredentials')" = "$(jq -rc <admin.json '.Nebula.CACredentials.Public')" ]
[ "$(jq -r <"$bootstrap_file" '.HostName')" = "primus" ]
[ "$(jq -rc <"$bootstrap_file" '.CAPublicCredentials')" = "$(jq -rc <admin.json '.Nebula.CACredentials.Public')" ]
[ "$(jq -r <"$bootstrap_file" '.SignedHostAssigned.Body.Name')" = "primus" ]

View File

@ -5,10 +5,10 @@ adminBS="$XDG_DATA_HOME"/isle/bootstrap.json
bs="$secondus_bootstrap" # set in with-1-data-1-empty-node-cluster.sh
[ "$(jq -r <"$bs" '.AdminCreationParams')" = "$(jq -r <admin.json '.CreationParams')" ]
[ "$(jq -r <"$bs" '.HostName')" = "secondus" ]
[ "$(jq -r <"$bs" '.SignedHostAssigned.Body.Name')" = "secondus" ]
[ "$(jq -r <"$bs" '.Hosts.primus.Nebula.SignedPublicCredentials')" \
= "$(jq -r <"$adminBS" '.Nebula.SignedPublicCredentials')" ]
[ "$(jq -r <"$bs" '.Hosts.primus.PublicCredentials')" \
= "$(jq -r <"$adminBS" '.SignedHostAssigned.Body.PublicCredentials')" ]
[ "$(jq <"$bs" '.Hosts.primus.Garage.Instances|length')" = "3" ]