blob: 7cd2c818aa5be9fc6691ef1fb2af5fcb62796732 [file] [log] [blame]
package certs
import (
"bytes"
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log"
"math/big"
"net"
"os"
"path/filepath"
"time"
)
// Certificate is a higher-level descriptor of an intent to generate a
// certificate and corresponding Ed25519 keypair on disk.
type Certificate struct {
// uniquer name for this cert, used to calculate filesystem paths.
name string
// root directory where all certs are stored.
root string
// duration used to determine TimeAfter. If not set, the certificate will
// never expire.
duration time.Duration
kind certificateKind
// cn is the subject common name that's going to be produced in the X.509
// certificate.
cn string
// o is the subject organziation that's going to be produced in the X.509
// certificate.
o string
// san are the DNS alternate names that are going to be produced in the
// X.509 certificate.
san []string
// ips are the IP alternate names that are going to be produced in the
// X.509 certificate.
ips []net.IP
// issuer, if set, is the certificate that will sign this certificate. If
// not set, the certificate will be self-signed.
issuer *Certificate
}
// Paths returns local filesystem paths to the CA certificate, certificate and
// key respectively. If the certificate is self signed, the CA path returned
// will be empty. These files might or might not live on the file system - you
// should first call Ensure to make sure they do.
func (c *Certificate) Paths() (caPath, certPath, keyPath string) {
if c.issuer != nil {
caPath = c.issuer.path(fileKindCert)
}
certPath = c.path(fileKindCert)
keyPath = c.path(fileKindKey)
return
}
type certificateKind string
const (
kindServer certificateKind = "server"
kindClient certificateKind = "client"
kindClientServer certificateKind = "client-server"
kindCA certificateKind = "ca"
kindProdvider certificateKind = "prodvider"
)
type fileKind string
const (
fileKindKey fileKind = "key"
fileKindKeyEncrypted fileKind = "key-encrypted"
fileKindCert fileKind = "cert"
)
// path returns the path to the generated fileKind for this Certificate.
func (c *Certificate) path(k fileKind) string {
switch k {
case fileKindKeyEncrypted:
return filepath.Join(c.root, "secrets", "cipher", c.name+".key")
case fileKindKey:
return filepath.Join(c.root, "secrets", "plain", c.name+".key")
case fileKindCert:
// clustercfg.py compat: CA certs end in .crt, non-CA certs end in .cert.
// We're keeping this accidental convention to avoid spurious nix rebuilds
// when migrating.
//
// Feel free to fix it if it annoys you.
extension := ".cert"
if c.kind == kindCA {
extension = ".crt"
}
return filepath.Join(c.root, "certs", c.name+extension)
default:
panic("unexpected file kind type " + k)
}
}
// ensureKey loads or generates-then-saves the private key for this
// Certificate.
func (c *Certificate) ensureKey() (crypto.Signer, error) {
path := c.path(fileKindKey)
_, err := os.Stat(path)
switch {
case err == nil:
return c.loadKey()
case errors.Is(err, os.ErrNotExist):
epath := c.path(fileKindKeyEncrypted)
if _, err = os.Stat(epath); err == nil {
return nil, fmt.Errorf("plaintext key at %q not found, but exists encrypted at %q - please decrypt using secretstore", path, epath)
}
return c.generateKey()
default:
return nil, fmt.Errorf("could not read key: %w", err)
}
}
func (c *Certificate) loadKey() (crypto.Signer, error) {
path := c.path(fileKindKey)
bytes, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(bytes)
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}
if block.Type != "PRIVATE KEY" {
return nil, fmt.Errorf("unexpected PEM block: %q", block.Type)
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
if k, ok := key.(ed25519.PrivateKey); ok {
return k, nil
}
return nil, fmt.Errorf("not an ED25519 key")
}
func (c *Certificate) generateKey() (crypto.Signer, error) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
pkcs8, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
block := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
path := c.path(fileKindKey)
os.MkdirAll(filepath.Dir(path), 0700)
log.Printf("Saving %s key to %s ...", c.name, path)
if err := os.WriteFile(path, block, 0600); err != nil {
return nil, err
}
return priv, nil
}
// ensureCert loads or generates-then-saves the X.509 certificate for the
// Certificate.
func (c *Certificate) ensureCert() (*x509.Certificate, error) {
path := c.path(fileKindCert)
_, err := os.Stat(path)
switch {
case err == nil:
cert, err := c.loadCert()
switch err {
case nil:
return cert, nil
case errExpired:
return c.generateCert()
default:
return nil, err
}
case errors.Is(err, os.ErrNotExist):
return c.generateCert()
default:
return nil, fmt.Errorf("could not read cert: %w", err)
}
}
func (c *Certificate) generateCert() (*x509.Certificate, error) {
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 127)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, err
}
notAfter := unknownNotAfter
if c.duration != 0 {
notAfter = time.Now().Add(c.duration)
}
template := c.template()
template.SerialNumber = serialNumber
template.NotBefore = time.Now()
template.NotAfter = notAfter
parent := template
skey, err := c.ensureKey()
if err != nil {
return nil, fmt.Errorf("when ensuring key: %w", err)
}
pkey := skey.Public()
caskey := skey
if c.issuer != nil {
caskey, err = c.issuer.ensureKey()
if err != nil {
return nil, fmt.Errorf("when ensuring CA key: %w", err)
}
cacert, err := c.issuer.ensureCert()
if err != nil {
return nil, fmt.Errorf("when ensuring CA cert: %w", err)
}
parent = cacert
}
bytes, err := x509.CreateCertificate(rand.Reader, template, parent, pkey, caskey)
if err != nil {
return nil, fmt.Errorf("issuing certificate failed: %w", err)
}
block := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: bytes})
path := c.path(fileKindCert)
os.MkdirAll(filepath.Dir(path), 0700)
log.Printf("Saving %s cert to %s ...", c.name, path)
if err := os.WriteFile(path, block, 0600); err != nil {
return nil, err
}
return x509.ParseCertificate(bytes)
}
// errExpired is returned if the cert exists on disk but has (nearly) expired.
var errExpired = errors.New("certificate expired")
func (c *Certificate) loadCert() (*x509.Certificate, error) {
path := c.path(fileKindCert)
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(b)
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}
if block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("unexpected PEM block: %q", block.Type)
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, err
}
if time.Now().Add(time.Hour).After(cert.NotAfter) {
return nil, errExpired
}
pkey, ok := cert.PublicKey.(ed25519.PublicKey)
if !ok {
return nil, fmt.Errorf("not a ED25519 cert")
}
skey, err := c.ensureKey()
if err != nil {
return nil, fmt.Errorf("when ensuring key: %w", err)
}
if !bytes.Equal(pkey, skey.Public().(ed25519.PublicKey)) {
return nil, fmt.Errorf("issued for different key")
}
template := c.template()
if err := compareCertData(template, cert); err != nil {
return nil, err
}
return cert, nil
}
// Ensure makes sure the given Certificate (and all of its' issuers) have
// corresponding private keys and X.509 certificates on disk, generating things
// as necessary.
func (c *Certificate) Ensure() error {
cert, err := c.ensureCert()
if err != nil {
return fmt.Errorf("when ensuring cert %s: %w", c.name, err)
}
_ = cert
return nil
}