Store full nebula cert for each host in garage, rather than just the IP

This allows each host to verify the cert against the CA cert. We also
now have each host sign the yaml file that it posts to garage, to ensure
that a host can't arbitrarily overwrite another host's file.
This commit is contained in:
Brian Picciano 2022-10-29 21:11:40 +02:00
parent 711d568036
commit 7dceb659ef
14 changed files with 390 additions and 230 deletions

View File

@ -35,7 +35,7 @@ type CreationParams struct {
type Admin struct {
CreationParams CreationParams
NebulaCACert nebula.CACert
NebulaCACredentials nebula.CACredentials
GarageRPCSecret string
GarageGlobalBucketS3APICredentials garage.S3APICredentials
@ -67,8 +67,8 @@ func FromFS(adminFS fs.FS) (Admin, error) {
into *string
path string
}{
{&a.NebulaCACert.CACert, nebulaCertsCACertPath},
{&a.NebulaCACert.CAKey, nebulaCertsCAKeyPath},
{&a.NebulaCACredentials.CACertPEM, nebulaCertsCACertPath},
{&a.NebulaCACredentials.CAKeyPEM, nebulaCertsCAKeyPath},
{&a.GarageRPCSecret, garageRPCSecretPath},
}
@ -122,8 +122,8 @@ func (a Admin) WriteTo(into io.Writer) error {
value string
path string
}{
{a.NebulaCACert.CACert, nebulaCertsCACertPath},
{a.NebulaCACert.CAKey, nebulaCertsCAKeyPath},
{a.NebulaCACredentials.CACertPEM, nebulaCertsCACertPath},
{a.NebulaCACredentials.CAKeyPEM, nebulaCertsCAKeyPath},
{a.GarageRPCSecret, garageRPCSecretPath},
}

View File

@ -45,7 +45,7 @@ type Bootstrap struct {
Hosts map[string]Host
HostName string
NebulaHostCert nebula.HostCert
NebulaHostCredentials nebula.HostCredentials
GarageRPCSecret string
GarageAdminToken string
@ -84,9 +84,9 @@ func FromFS(bootstrapFS fs.FS) (Bootstrap, error) {
path string
}{
{&b.HostName, hostNamePath},
{&b.NebulaHostCert.CACert, nebulaCertsCACertPath},
{&b.NebulaHostCert.HostCert, nebulaCertsHostCertPath},
{&b.NebulaHostCert.HostKey, nebulaCertsHostKeyPath},
{&b.NebulaHostCredentials.CACertPEM, nebulaCertsCACertPath},
{&b.NebulaHostCredentials.HostCertPEM, nebulaCertsHostCertPath},
{&b.NebulaHostCredentials.HostKeyPEM, nebulaCertsHostKeyPath},
{&b.GarageRPCSecret, garageRPCSecretPath},
{&b.GarageAdminToken, garageAdminTokenPath},
}
@ -165,9 +165,9 @@ func (b Bootstrap) WriteTo(into io.Writer) error {
path string
}{
{b.HostName, hostNamePath},
{b.NebulaHostCert.CACert, nebulaCertsCACertPath},
{b.NebulaHostCert.HostCert, nebulaCertsHostCertPath},
{b.NebulaHostCert.HostKey, nebulaCertsHostKeyPath},
{b.NebulaHostCredentials.CACertPEM, nebulaCertsCACertPath},
{b.NebulaHostCredentials.HostCertPEM, nebulaCertsHostCertPath},
{b.NebulaHostCredentials.HostKeyPEM, nebulaCertsHostKeyPath},
{b.GarageRPCSecret, garageRPCSecretPath},
{b.GarageAdminToken, garageAdminTokenPath},
}
@ -209,19 +209,3 @@ func HostsHash(hostsMap map[string]Host) ([]byte, error) {
return h.Sum(nil), nil
}
// WithHosts returns a copy of the Bootstrap with the given set of Hosts applied
// to it. It will _not_ overwrite the Host for _this_ host, however.
func (b Bootstrap) WithHosts(hosts map[string]Host) Bootstrap {
hostsCopy := make(map[string]Host, len(hosts))
for name, host := range hosts {
hostsCopy[name] = host
}
hostsCopy[b.HostName] = b.ThisHost()
b.Hosts = hostsCopy
return b
}

View File

@ -26,7 +26,7 @@ func (b Bootstrap) GaragePeers() []garage.RemotePeer {
peer := garage.RemotePeer{
ID: instance.ID,
IP: host.Nebula.IP,
IP: host.IP().String(),
RPCPort: instance.RPCPort,
S3APIPort: instance.S3APIPort,
}
@ -56,10 +56,11 @@ func (b Bootstrap) ChooseGaragePeer() garage.RemotePeer {
thisHost := b.ThisHost()
if thisHost.Garage != nil && len(thisHost.Garage.Instances) > 0 {
inst := thisHost.Garage.Instances[0]
return garage.RemotePeer{
ID: inst.ID,
IP: thisHost.Nebula.IP,
IP: thisHost.IP().String(),
RPCPort: inst.RPCPort,
S3APIPort: inst.S3APIPort,
}

View File

@ -4,8 +4,9 @@ import (
"bytes"
"context"
"cryptic-net/garage"
"cryptic-net/nebula"
"fmt"
"log"
"os"
"path/filepath"
"github.com/minio/minio-go/v7"
@ -17,23 +18,32 @@ const (
garageGlobalBucketBootstrapHostsDirPath = "bootstrap/hosts"
)
// PutGarageBoostrapHost places the <hostname>.yml file for the given host into
// garage so that other hosts are able to see relevant configuration for it.
//
// The given client should be for the global bucket.
func PutGarageBoostrapHost(
ctx context.Context, client garage.S3APIClient, host Host,
) error {
// PutGarageBoostrapHost places the <hostname>.yml.signed file for this host
// into garage so that other hosts are able to see relevant configuration for
// it.
func (b Bootstrap) PutGarageBoostrapHost(ctx context.Context) error {
host := b.ThisHost()
client := b.GlobalBucketS3APIClient()
hostB, err := yaml.Marshal(host)
if err != nil {
return fmt.Errorf("yaml encoding host data: %w", err)
}
buf := new(bytes.Buffer)
if err := yaml.NewEncoder(buf).Encode(host); err != nil {
log.Fatalf("yaml encoding host data: %v", err)
err = nebula.SignAndWrap(buf, b.NebulaHostCredentials.HostKeyPEM, hostB)
if err != nil {
return fmt.Errorf("signing encoded host data: %w", err)
}
filePath := filepath.Join(garageGlobalBucketBootstrapHostsDirPath, host.Name+".yml")
filePath := filepath.Join(
garageGlobalBucketBootstrapHostsDirPath,
host.Name+".yml.signed",
)
_, err := client.PutObject(
_, err = client.PutObject(
ctx, garage.GlobalBucket, filePath, buf, int64(buf.Len()),
minio.PutObjectOptions{},
)
@ -45,15 +55,18 @@ func PutGarageBoostrapHost(
return nil
}
// RemoveGarageBootstrapHost removes the <hostname>.yml for the given host from
// garage.
// RemoveGarageBootstrapHost removes the <hostname>.yml.signed for the given
// host from garage.
//
// The given client should be for the global bucket.
func RemoveGarageBootstrapHost(
ctx context.Context, client garage.S3APIClient, hostName string,
) error {
filePath := filepath.Join(garageGlobalBucketBootstrapHostsDirPath, hostName+".yml")
filePath := filepath.Join(
garageGlobalBucketBootstrapHostsDirPath,
hostName+".yml.signed",
)
return client.RemoveObject(
ctx, garage.GlobalBucket, filePath,
@ -61,16 +74,17 @@ func RemoveGarageBootstrapHost(
)
}
// GetGarageBootstrapHosts loads the <hostname>.yml file for all hosts stored in
// garage.
//
// The given client should be for the global bucket.
func GetGarageBootstrapHosts(
ctx context.Context, client garage.S3APIClient,
// GetGarageBootstrapHosts loads the <hostname>.yml.signed file for all hosts
// stored in garage.
func (b Bootstrap) GetGarageBootstrapHosts(
ctx context.Context,
) (
map[string]Host, error,
) {
caCertPEM := b.NebulaHostCredentials.CACertPEM
client := b.GlobalBucketS3APIClient()
hosts := map[string]Host{}
objInfoCh := client.ListObjects(
@ -95,15 +109,30 @@ func GetGarageBootstrapHosts(
return nil, fmt.Errorf("retrieving object %q: %w", objInfo.Key, err)
}
var host Host
err = yaml.NewDecoder(obj).Decode(&host)
hostB, sig, err := nebula.Unwrap(obj)
obj.Close()
if err != nil {
return nil, fmt.Errorf("unwrapping signature from %q: %w", objInfo.Key, err)
}
var host Host
if err = yaml.Unmarshal(hostB, &host); err != nil {
return nil, fmt.Errorf("yaml decoding object %q: %w", objInfo.Key, err)
}
hostCertPEM := host.Nebula.CertPEM
if err := nebula.ValidateSignature(hostCertPEM, hostB, sig); err != nil {
fmt.Fprintf(os.Stderr, "invalid host data for %q: %w\n", objInfo.Key, err)
continue
}
if err := nebula.ValidateHostCertPEM(caCertPEM, hostCertPEM); err != nil {
fmt.Fprintf(os.Stderr, "invalid nebula cert for %q: %w\n", objInfo.Key, err)
continue
}
hosts[host.Name] = host
}

View File

@ -1,8 +1,10 @@
package bootstrap
import (
"cryptic-net/nebula"
"fmt"
"io/fs"
"net"
"path/filepath"
"strings"
@ -16,7 +18,7 @@ const (
// NebulaHost describes the nebula configuration of a Host which is relevant for
// other hosts to know.
type NebulaHost struct {
IP string `yaml:"ip"`
CertPEM string `yaml:"crt"`
PublicAddr string `yaml:"public_addr,omitempty"`
}
@ -41,6 +43,18 @@ type Host struct {
Garage *GarageHost `yaml:"garage,omitempty"`
}
// IP returns the IP address encoded in the Host's nebula certificate, or panics
// if there is an error.
func (h Host) IP() net.IP {
ip, err := nebula.IPFromHostCertPEM(h.Nebula.CertPEM)
if err != nil {
panic(fmt.Errorf("could not parse IP out of cert for host %q: %w", h.Name, err))
}
return ip
}
func loadHosts(bootstrapFS fs.FS) (map[string]Host, error) {
hosts := map[string]Host{}

View File

@ -117,12 +117,12 @@ var subCmdAdminCreateNetwork = subCmd{
return fmt.Errorf("daemon config with at least 3 allocations was not provided")
}
nebulaCACert, err := nebula.NewCACert(*domain, subnet)
nebulaCACreds, err := nebula.NewCACredentials(*domain, subnet)
if err != nil {
return fmt.Errorf("creating nebula CA cert: %w", err)
}
nebulaHostCert, err := nebula.NewHostCert(nebulaCACert, *hostName, ip)
nebulaHostCreds, err := nebula.NewHostCredentials(nebulaCACreds, *hostName, ip)
if err != nil {
return fmt.Errorf("creating nebula cert for host: %w", err)
}
@ -138,12 +138,12 @@ var subCmdAdminCreateNetwork = subCmd{
*hostName: bootstrap.Host{
Name: *hostName,
Nebula: bootstrap.NebulaHost{
IP: ip.String(),
CertPEM: nebulaHostCreds.HostCertPEM,
},
},
},
HostName: *hostName,
NebulaHostCert: nebulaHostCert,
NebulaHostCredentials: nebulaHostCreds,
GarageRPCSecret: randStr(32),
GarageAdminToken: randStr(32),
GarageGlobalBucketS3APICredentials: garage.NewS3APICredentials(),
@ -213,7 +213,7 @@ var subCmdAdminCreateNetwork = subCmd{
err = admin.Admin{
CreationParams: adminCreationParams,
NebulaCACert: nebulaCACert,
NebulaCACredentials: nebulaCACreds,
GarageRPCSecret: hostBootstrap.GarageRPCSecret,
GarageGlobalBucketS3APICredentials: hostBootstrap.GarageGlobalBucketS3APICredentials,
GarageAdminBucketS3APICredentials: garage.NewS3APICredentials(),
@ -240,6 +240,11 @@ var subCmdAdminMakeBootstrap = subCmd{
"Name of the host to generate bootstrap.tgz for",
)
ipStr := flags.StringP(
"ip", "i", "",
"IP of the new host",
)
adminPath := flags.StringP(
"admin-path", "a", "",
`Path to admin.tgz file. If the given path is "-" then stdin is used.`,
@ -249,13 +254,18 @@ var subCmdAdminMakeBootstrap = subCmd{
return fmt.Errorf("parsing flags: %w", err)
}
if *name == "" || *adminPath == "" {
return errors.New("--name and --admin-path are required")
if *name == "" || *ipStr == "" || *adminPath == "" {
return errors.New("--name, --ip, and --admin-path are required")
}
hostBootstrap, err := loadHostBootstrap()
if err != nil {
return fmt.Errorf("loading host bootstrap: %w", err)
if err := validateHostName(*name); err != nil {
return fmt.Errorf("invalid hostname %q: %w", *name, err)
}
ip := net.ParseIP(*ipStr)
if ip == nil {
return fmt.Errorf("invalid ip %q", *ipStr)
}
adm, err := readAdmin(*adminPath)
@ -263,28 +273,12 @@ var subCmdAdminMakeBootstrap = subCmd{
return fmt.Errorf("reading admin.tgz with --admin-path of %q: %w", *adminPath, err)
}
client := hostBootstrap.GlobalBucketS3APIClient()
// NOTE this isn't _technically_ required, but if the `hosts add`
// command for this host has been run recently then it might not have
// made it into the bootstrap file yet, and so won't be in
// `hostBootstrap`.
hosts, err := bootstrap.GetGarageBootstrapHosts(subCmdCtx.ctx, client)
hostBootstrap, err := loadHostBootstrap()
if err != nil {
return fmt.Errorf("retrieving host info from garage: %w", err)
return fmt.Errorf("loading host bootstrap: %w", err)
}
host, ok := hosts[*name]
if !ok {
return fmt.Errorf("couldn't find host into for %q in garage, has `cryptic-net hosts add` been run yet?", *name)
}
ip := net.ParseIP(host.Nebula.IP)
if ip == nil {
return fmt.Errorf("invalid IP stored with host %q: %q", *name, host.Nebula.IP)
}
nebulaHostCert, err := nebula.NewHostCert(adm.NebulaCACert, host.Name, ip)
nebulaHostCreds, err := nebula.NewHostCredentials(adm.NebulaCACredentials, *name, ip)
if err != nil {
return fmt.Errorf("creating new nebula host key/cert: %w", err)
}
@ -292,10 +286,10 @@ var subCmdAdminMakeBootstrap = subCmd{
newHostBootstrap := bootstrap.Bootstrap{
AdminCreationParams: adm.CreationParams,
Hosts: hosts,
Hosts: hostBootstrap.Hosts,
HostName: *name,
NebulaHostCert: nebulaHostCert,
NebulaHostCredentials: nebulaHostCreds,
GarageRPCSecret: adm.GarageRPCSecret,
GarageAdminToken: randStr(32),

View File

@ -12,7 +12,6 @@ import (
"cryptic-net/bootstrap"
"cryptic-net/daemon"
"cryptic-net/garage"
"code.betamike.com/cryptic-io/pmux/pmuxlib"
)
@ -42,16 +41,21 @@ import (
func reloadBootstrap(
ctx context.Context,
hostBootstrap bootstrap.Bootstrap,
s3Client garage.S3APIClient,
) (
bootstrap.Bootstrap, bool, error,
) {
newHosts, err := bootstrap.GetGarageBootstrapHosts(ctx, s3Client)
thisHost := hostBootstrap.ThisHost()
newHosts, err := hostBootstrap.GetGarageBootstrapHosts(ctx)
if err != nil {
return bootstrap.Bootstrap{}, false, fmt.Errorf("getting hosts from garage: %w", err)
}
// the daemon's view of this host's bootstrap info takes precedence over
// whatever is in garage
newHosts[thisHost.Name] = thisHost
newHostsHash, err := bootstrap.HostsHash(newHosts)
if err != nil {
return bootstrap.Bootstrap{}, false, fmt.Errorf("calculating hash of new hosts: %w", err)
@ -66,13 +70,8 @@ func reloadBootstrap(
return hostBootstrap, false, nil
}
newHostBootstrap := hostBootstrap.WithHosts(newHosts)
if err := writeBootstrapToDataDir(newHostBootstrap); err != nil {
return bootstrap.Bootstrap{}, false, fmt.Errorf("writing new bootstrap.tgz to data dir: %w", err)
}
return newHostBootstrap, true, nil
hostBootstrap.Hosts = newHosts
return hostBootstrap, true, nil
}
// runs a single pmux process of daemon, returning only once the env.Context has
@ -87,14 +86,6 @@ func runDaemonPmuxOnce(
bootstrap.Bootstrap, error,
) {
thisHost := hostBootstrap.ThisHost()
fmt.Fprintf(os.Stderr, "host name is %q, ip is %q\n", thisHost.Name, thisHost.Nebula.IP)
// create s3Client anew on every loop, in case the topology has
// changed and we should be connecting to a different garage
// endpoint.
s3Client := hostBootstrap.GlobalBucketS3APIClient()
nebulaPmuxProcConfig, err := nebulaPmuxProcConfig(hostBootstrap, daemonConfig)
if err != nil {
return bootstrap.Bootstrap{}, fmt.Errorf("generating nebula config: %w", err)
@ -143,11 +134,9 @@ func runDaemonPmuxOnce(
return
}
thisHost := hostBootstrap.ThisHost()
err := doOnce(ctx, func(ctx context.Context) error {
fmt.Fprintln(os.Stderr, "updating host info in garage")
return bootstrap.PutGarageBoostrapHost(ctx, s3Client, thisHost)
return hostBootstrap.PutGarageBoostrapHost(ctx)
})
if err != nil {
@ -194,7 +183,7 @@ func runDaemonPmuxOnce(
err error
)
if hostBootstrap, changed, err = reloadBootstrap(ctx, hostBootstrap, s3Client); err != nil {
if hostBootstrap, changed, err = reloadBootstrap(ctx, hostBootstrap); err != nil {
return bootstrap.Bootstrap{}, fmt.Errorf("reloading bootstrap: %w", err)
} else if changed {

View File

@ -20,19 +20,22 @@ func dnsmasqPmuxProcConfig(
confPath := filepath.Join(envRuntimeDirPath, "dnsmasq.conf")
hostsSlice := make([]bootstrap.Host, 0, len(hostBootstrap.Hosts))
hostsSlice := make([]dnsmasq.ConfDataHost, 0, len(hostBootstrap.Hosts))
for _, host := range hostBootstrap.Hosts {
hostsSlice = append(hostsSlice, host)
hostsSlice = append(hostsSlice, dnsmasq.ConfDataHost{
Name: host.Name,
IP: host.IP().String(),
})
}
sort.Slice(hostsSlice, func(i, j int) bool {
return hostsSlice[i].Nebula.IP < hostsSlice[j].Nebula.IP
return hostsSlice[i].IP < hostsSlice[j].IP
})
confData := dnsmasq.ConfData{
Resolvers: daemonConfig.DNS.Resolvers,
Domain: hostBootstrap.AdminCreationParams.Domain,
IP: hostBootstrap.ThisHost().Nebula.IP,
IP: hostBootstrap.ThisHost().IP().String(),
Hosts: hostsSlice,
}

View File

@ -23,7 +23,7 @@ func newGarageAdminClient(
return garage.NewAdminClient(
net.JoinHostPort(
thisHost.Nebula.IP,
thisHost.IP().String(),
strconv.Itoa(daemonConfig.Storage.Allocations[0].AdminPort),
),
hostBootstrap.GarageAdminToken,
@ -47,7 +47,7 @@ func waitForGarageAndNebula(
for _, alloc := range allocs {
adminAddr := net.JoinHostPort(
hostBootstrap.ThisHost().Nebula.IP,
hostBootstrap.ThisHost().IP().String(),
strconv.Itoa(alloc.AdminPort),
)
@ -97,7 +97,7 @@ func garageWriteChildConfig(
peer := garage.LocalPeer{
RemotePeer: garage.RemotePeer{
ID: id,
IP: thisHost.Nebula.IP,
IP: thisHost.IP().String(),
RPCPort: alloc.RPCPort,
S3APIPort: alloc.S3APIPort,
},

View File

@ -4,7 +4,6 @@ import (
"cryptic-net/bootstrap"
"errors"
"fmt"
"net"
"os"
"regexp"
"sort"
@ -23,60 +22,6 @@ func validateHostName(name string) error {
return nil
}
var subCmdHostsAdd = subCmd{
name: "add",
descr: "Adds a host to the network",
checkLock: true,
do: func(subCmdCtx subCmdCtx) error {
flags := subCmdCtx.flagSet(false)
name := flags.StringP(
"name", "n", "",
"Name of the new host",
)
ip := flags.StringP(
"ip", "i", "",
"IP of the new host",
)
if err := flags.Parse(subCmdCtx.args); err != nil {
return fmt.Errorf("parsing flags: %w", err)
}
if *name == "" || *ip == "" {
return errors.New("--name and --ip are required")
}
if err := validateHostName(*name); err != nil {
return fmt.Errorf("invalid hostname %q: %w", *name, err)
}
if net.ParseIP(*ip) == nil {
return fmt.Errorf("invalid ip %q", *ip)
}
// TODO validate that the IP is in the correct CIDR
hostBootstrap, err := loadHostBootstrap()
if err != nil {
return fmt.Errorf("loading host bootstrap: %w", err)
}
client := hostBootstrap.GlobalBucketS3APIClient()
host := bootstrap.Host{
Name: *name,
Nebula: bootstrap.NebulaHost{
IP: *ip,
},
}
return bootstrap.PutGarageBoostrapHost(subCmdCtx.ctx, client, host)
},
}
var subCmdHostsList = subCmd{
name: "list",
descr: "Lists all hosts in the network, and their IPs",
@ -88,9 +33,7 @@ var subCmdHostsList = subCmd{
return fmt.Errorf("loading host bootstrap: %w", err)
}
client := hostBootstrap.GlobalBucketS3APIClient()
hostsMap, err := bootstrap.GetGarageBootstrapHosts(subCmdCtx.ctx, client)
hostsMap, err := hostBootstrap.GetGarageBootstrapHosts(subCmdCtx.ctx)
if err != nil {
return fmt.Errorf("retrieving hosts from garage: %w", err)
}
@ -143,7 +86,6 @@ var subCmdHosts = subCmd{
descr: "Sub-commands having to do with configuration of hosts in the network",
do: func(subCmdCtx subCmdCtx) error {
return subCmdCtx.doSubCmd(
subCmdHostsAdd,
subCmdHostsDelete,
subCmdHostsList,
)

View File

@ -18,8 +18,7 @@ import (
// interface has been initialized.
func waitForNebula(ctx context.Context, hostBootstrap bootstrap.Bootstrap) error {
ipStr := hostBootstrap.ThisHost().Nebula.IP
ip := net.ParseIP(ipStr)
ip := hostBootstrap.ThisHost().IP()
lUdpAddr := &net.UDPAddr{IP: ip, Port: 0}
rUdpAddr := &net.UDPAddr{IP: ip, Port: 45535}
@ -52,15 +51,16 @@ func nebulaPmuxProcConfig(
continue
}
lighthouseHostIPs = append(lighthouseHostIPs, host.Nebula.IP)
staticHostMap[host.Nebula.IP] = []string{host.Nebula.PublicAddr}
ip := host.IP().String()
lighthouseHostIPs = append(lighthouseHostIPs, ip)
staticHostMap[ip] = []string{host.Nebula.PublicAddr}
}
config := map[string]interface{}{
"pki": map[string]string{
"ca": hostBootstrap.NebulaHostCert.CACert,
"cert": hostBootstrap.NebulaHostCert.HostCert,
"key": hostBootstrap.NebulaHostCert.HostKey,
"ca": hostBootstrap.NebulaHostCredentials.CACertPEM,
"cert": hostBootstrap.NebulaHostCredentials.HostCertPEM,
"key": hostBootstrap.NebulaHostCredentials.HostKeyPEM,
},
"static_host_map": staticHostMap,
"punchy": map[string]bool{

View File

@ -1,18 +1,23 @@
package dnsmasq
import (
"cryptic-net/bootstrap"
"fmt"
"os"
"text/template"
)
// ConfDataHost describes a host which can be resolved by dnsmasq.
type ConfDataHost struct {
Name string
IP string
}
// ConfData describes all the data needed to populate a dnsmasq.conf file.
type ConfData struct {
Resolvers []string
Domain string
IP string
Hosts []bootstrap.Host
Hosts []ConfDataHost
}
var confTpl = template.Must(template.New("").Parse(`

View File

@ -3,8 +3,11 @@
package nebula
import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
@ -14,65 +17,69 @@ import (
"golang.org/x/crypto/curve25519"
)
// HostCert contains the certificate and private key files which will need to
// be present on a particular host. Each file is PEM encoded.
type HostCert struct {
CACert string
HostKey string
HostCert string
// ErrInvalidSignature is returned from functions when a signature validation
// fails.
var ErrInvalidSignature = errors.New("invalid signature")
// 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 {
CACertPEM string
HostKeyPEM string
HostCertPEM string
}
// CACert contains the certificate and private files which can be used to create
// HostCerts. Each file is PEM encoded.
type CACert struct {
CACert string
CAKey string
// CACredentials contains the certificate and private files which can be used to
// create and validate HostCredentials. Each file is PEM encoded.
type CACredentials struct {
CACertPEM string
CAKeyPEM string
}
// NewHostCert generates a new key/cert for a nebula host using the CA key
// which will be found in the adminFS.
func NewHostCert(
caCert CACert, hostName string, ip net.IP,
// NewHostCredentials generates a new key/cert for a nebula host using the CA
// key which will be found in the adminFS.
func NewHostCredentials(
caCreds CACredentials, hostName string, ip net.IP,
) (
HostCert, error,
HostCredentials, error,
) {
// The logic here is largely based on
// https://github.com/slackhq/nebula/blob/v1.4.0/cmd/nebula-cert/sign.go
caKey, _, err := cert.UnmarshalEd25519PrivateKey([]byte(caCert.CAKey))
caKey, _, err := cert.UnmarshalEd25519PrivateKey([]byte(caCreds.CAKeyPEM))
if err != nil {
return HostCert{}, fmt.Errorf("unmarshaling ca.key: %w", err)
return HostCredentials{}, fmt.Errorf("unmarshaling ca.key: %w", err)
}
caCrt, _, err := cert.UnmarshalNebulaCertificateFromPEM([]byte(caCert.CACert))
caCert, _, err := cert.UnmarshalNebulaCertificateFromPEM([]byte(caCreds.CACertPEM))
if err != nil {
return HostCert{}, fmt.Errorf("unmarshaling ca.crt: %w", err)
return HostCredentials{}, fmt.Errorf("unmarshaling ca.crt: %w", err)
}
issuer, err := caCrt.Sha256Sum()
issuer, err := caCert.Sha256Sum()
if err != nil {
return HostCert{}, fmt.Errorf("getting ca.crt issuer: %w", err)
return HostCredentials{}, fmt.Errorf("getting ca.crt issuer: %w", err)
}
expireAt := caCrt.Details.NotAfter.Add(-1 * time.Second)
expireAt := caCert.Details.NotAfter.Add(-1 * time.Second)
subnet := caCrt.Details.Subnets[0]
subnet := caCert.Details.Subnets[0]
if !subnet.Contains(ip) {
return HostCert{}, fmt.Errorf("invalid ip %q, not contained by network subnet %q", ip, subnet)
return HostCredentials{}, fmt.Errorf("invalid ip %q, not contained by network subnet %q", ip, subnet)
}
var hostPub, hostKey []byte
{
var pubkey, privkey [32]byte
if _, err := io.ReadFull(rand.Reader, privkey[:]); err != nil {
return HostCert{}, fmt.Errorf("reading random bytes to form private key: %w", err)
return HostCredentials{}, fmt.Errorf("reading random bytes to form private key: %w", err)
}
curve25519.ScalarBaseMult(&pubkey, &privkey)
hostPub, hostKey = pubkey[:], privkey[:]
}
hostCrt := cert.NebulaCertificate{
hostCert := cert.NebulaCertificate{
Details: cert.NebulaCertificateDetails{
Name: hostName,
Ips: []*net.IPNet{{
@ -87,31 +94,31 @@ func NewHostCert(
},
}
if err := hostCrt.CheckRootConstrains(caCrt); err != nil {
return HostCert{}, fmt.Errorf("validating certificate constraints: %w", err)
if err := hostCert.CheckRootConstrains(caCert); err != nil {
return HostCredentials{}, fmt.Errorf("validating certificate constraints: %w", err)
}
if err := hostCrt.Sign(caKey); err != nil {
return HostCert{}, fmt.Errorf("signing host cert with ca.key: %w", err)
if err := hostCert.Sign(caKey); err != nil {
return HostCredentials{}, fmt.Errorf("signing host cert with ca.key: %w", err)
}
hostKeyPEM := cert.MarshalX25519PrivateKey(hostKey)
hostCrtPEM, err := hostCrt.MarshalToPEM()
hostCertPEM, err := hostCert.MarshalToPEM()
if err != nil {
return HostCert{}, fmt.Errorf("marshalling host.crt: %w", err)
return HostCredentials{}, fmt.Errorf("marshalling host.crt: %w", err)
}
return HostCert{
CACert: caCert.CACert,
HostKey: string(hostKeyPEM),
HostCert: string(hostCrtPEM),
return HostCredentials{
CACertPEM: caCreds.CACertPEM,
HostKeyPEM: string(hostKeyPEM),
HostCertPEM: string(hostCertPEM),
}, nil
}
// NewCACert generates a CACert. The domain should be the network's root domain,
// NewCACredentials generates a CACredentials. The domain should be the network's root domain,
// and is included in the signing certificate's Name field.
func NewCACert(domain string, subnet *net.IPNet) (CACert, error) {
func NewCACredentials(domain string, subnet *net.IPNet) (CACredentials, error) {
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
@ -121,7 +128,7 @@ func NewCACert(domain string, subnet *net.IPNet) (CACert, error) {
now := time.Now()
expireAt := now.Add(2 * 365 * 24 * time.Hour)
caCrt := cert.NebulaCertificate{
caCert := cert.NebulaCertificate{
Details: cert.NebulaCertificateDetails{
Name: fmt.Sprintf("%s cryptic-net root cert", domain),
Subnets: []*net.IPNet{subnet},
@ -132,19 +139,134 @@ func NewCACert(domain string, subnet *net.IPNet) (CACert, error) {
},
}
if err := caCrt.Sign(privKey); err != nil {
return CACert{}, fmt.Errorf("signing caCrt: %w", err)
if err := caCert.Sign(privKey); err != nil {
return CACredentials{}, fmt.Errorf("signing caCert: %w", err)
}
caKeyPEM := cert.MarshalEd25519PrivateKey(privKey)
caCrtPem, err := caCrt.MarshalToPEM()
caCertPEM, err := caCert.MarshalToPEM()
if err != nil {
return CACert{}, fmt.Errorf("marshaling caCrt: %w", err)
return CACredentials{}, fmt.Errorf("marshaling caCert: %w", err)
}
return CACert{
CACert: string(caCrtPem),
CAKey: string(caKeyPEM),
return CACredentials{
CACertPEM: string(caCertPEM),
CAKeyPEM: string(caKeyPEM),
}, nil
}
// ValidateHostCertPEM checks if the given host certificate was signed by the
// given CA certificate, and returns ErrInvalidSignature if validation fails.
func ValidateHostCertPEM(caCertPEM, hostCertPEM string) error {
caCert, _, err := cert.UnmarshalNebulaCertificateFromPEM([]byte(caCertPEM))
if err != nil {
return fmt.Errorf("unmarshaling CA certificate as PEM: %w", err)
}
hostCert, _, err := cert.UnmarshalNebulaCertificateFromPEM([]byte(hostCertPEM))
if err != nil {
return fmt.Errorf("unmarshaling host certificate as PEM: %w", err)
}
caPubKey := ed25519.PublicKey(caCert.Details.PublicKey)
if !hostCert.CheckSignature(caPubKey) {
return ErrInvalidSignature
}
return nil
}
// IPFromHostCertPEM is a convenience function for parsing the IP of a host out
// of its nebula cert.
func IPFromHostCertPEM(hostCertPEM string) (net.IP, error) {
hostCert, _, err := cert.UnmarshalNebulaCertificateFromPEM([]byte(hostCertPEM))
if err != nil {
return nil, fmt.Errorf("unmarshaling host certificate as PEM: %w", err)
}
ips := hostCert.Details.Ips
if len(ips) == 0 {
return nil, fmt.Errorf("malformed nebula host cert: no IPs")
}
return ips[0].IP, nil
}
// SignAndWrap signs the given bytes using the keyPEM, and writes an
// encoded, versioned structure containing the signature and the given bytes.
func SignAndWrap(into io.Writer, keyPEM string, b []byte) error {
key, _, err := cert.UnmarshalEd25519PrivateKey([]byte(keyPEM))
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: "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 inpute 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(certPEM string, b, sig []byte) error {
cert, _, err := cert.UnmarshalNebulaCertificateFromPEM([]byte(certPEM))
if err != nil {
return fmt.Errorf("unmarshaling certificate as PEM: %w", err)
}
pubKey := ed25519.PublicKey(cert.Details.PublicKey)
if !ed25519.Verify(pubKey, b, sig) {
return ErrInvalidSignature
}
return nil
}

View File

@ -0,0 +1,77 @@
package nebula
import (
"bytes"
"errors"
"net"
"testing"
)
var (
ip net.IP
ipNet *net.IPNet
caCredsA, caCredsB CACredentials
)
func init() {
var err error
ip, ipNet, err = net.ParseCIDR("192.168.0.1/24")
if err != nil {
panic(err)
}
caCredsA, err = NewCACredentials("a.example.com", ipNet)
if err != nil {
panic(err)
}
caCredsB, err = NewCACredentials("b.example.com", ipNet)
if err != nil {
panic(err)
}
}
func TestValidateHostCredentials(t *testing.T) {
hostCreds, err := NewHostCredentials(caCredsA, "foo", ip)
if err != nil {
t.Fatal(err)
}
err = ValidateHostCertPEM(hostCreds.CACertPEM, hostCreds.HostCertPEM)
if err != nil {
t.Fatal(err)
}
err = ValidateHostCertPEM(caCredsB.CACertPEM, hostCreds.HostCertPEM)
if !errors.Is(err, ErrInvalidSignature) {
t.Fatalf("expected ErrInvalidSignature, got %v", err)
}
}
func TestSignAndWrap(t *testing.T) {
b := []byte("foo bar baz")
buf := new(bytes.Buffer)
if err := SignAndWrap(buf, caCredsA.CAKeyPEM, 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(caCredsA.CACertPEM, b, gotSig); err != nil {
t.Fatal(err)
}
if err := ValidateSignature(caCredsB.CACertPEM, b, gotSig); !errors.Is(err, ErrInvalidSignature) {
t.Fatalf("expected ErrInvalidSignature but got %v", err)
}
}