00869c6f5b
---ci--- project: orca phase: 2 milestone: v0.8 status: execute ---/ci---
339 lines
11 KiB
Go
339 lines
11 KiB
Go
package security
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// CAValidity is how long a CA cert is valid. Per D-013, the CA is long-lived
|
|
// (10 years) because manual rotation is expensive.
|
|
const CAValidity = 10 * 365 * 24 * time.Hour
|
|
|
|
// ServerCertValidity is the default validity window for server certs. D-013
|
|
// says server certs are short-lived (90 days) to limit the compromise window.
|
|
const ServerCertValidity = 90 * 24 * time.Hour
|
|
|
|
// CAKeySize is the RSA key size used for both CA and server certs. 3072 is
|
|
// the minimum we accept for v0.2 — matches REQ-033 spirit and Go's stdlib
|
|
// defaults for new RSA keys are typically 2048 or 4096. 3072 is the
|
|
// sweet spot for balance of safety and key-gen latency.
|
|
const CAKeySize = 3072
|
|
|
|
// CAMode is the file mode used when persisting the CA private key. REQ-033
|
|
// requires 0600.
|
|
const CAMode os.FileMode = 0o600
|
|
|
|
// CACPEMMode is the file mode used when persisting the CA public cert.
|
|
// REQ-033 requires 0644 (public, but still mode-pinned).
|
|
const CACPEMMode os.FileMode = 0o644
|
|
|
|
// File names used inside the CA directory.
|
|
const (
|
|
CACertFile = "ca.crt"
|
|
CAKeyFile = "ca.key"
|
|
)
|
|
|
|
// CA wraps a loaded CA. Use CAInit to mint a new one, LoadCA to read an
|
|
// existing one from disk.
|
|
type CA struct {
|
|
Cert *x509.Certificate
|
|
Key *rsa.PrivateKey
|
|
CertPEM []byte
|
|
Dir string
|
|
NotBefore time.Time
|
|
NotAfter time.Time
|
|
}
|
|
|
|
// CAInit creates a fresh self-signed CA and persists it to dir/ca.crt and
|
|
// dir/ca.key with the required file modes (REQ-033). If the CA files already
|
|
// exist with valid content, the existing CA is returned — idempotent.
|
|
//
|
|
// commonName is the CA's CommonName (typically an org/cluster identifier).
|
|
// Returns a *CA wrapping the loaded cert + key. The CA is valid for
|
|
// CAValidity from now.
|
|
func CAInit(dir, commonName string) (*CA, error) {
|
|
if dir == "" {
|
|
return nil, errors.New("CAInit: dir is required")
|
|
}
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("CAInit: mkdir: %w", err)
|
|
}
|
|
certPath := filepath.Join(dir, CACertFile)
|
|
keyPath := filepath.Join(dir, CAKeyFile)
|
|
|
|
// Fast path: existing CA — load and return.
|
|
if ok, err := bothExist(certPath, keyPath); err != nil {
|
|
return nil, err
|
|
} else if ok {
|
|
// Verify file modes on the existing CA (REQ-033).
|
|
if err := EnforceFileModes(dir); err != nil {
|
|
return nil, err
|
|
}
|
|
return LoadCA(dir)
|
|
}
|
|
|
|
// Generate key.
|
|
key, err := rsa.GenerateKey(rand.Reader, CAKeySize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CAInit: generate key: %w", err)
|
|
}
|
|
|
|
// Self-signed cert. We use x509.Certificate directly to set the CA
|
|
// extensions. Serial number is random 128 bits.
|
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CAInit: serial: %w", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: pkix.Name{
|
|
CommonName: commonName,
|
|
Organization: []string{"orca-internal-ca"},
|
|
},
|
|
NotBefore: now.Add(-1 * time.Hour),
|
|
NotAfter: now.Add(CAValidity),
|
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
|
BasicConstraintsValid: true,
|
|
IsCA: true,
|
|
MaxPathLen: 1,
|
|
MaxPathLenZero: false,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CAInit: create cert: %w", err)
|
|
}
|
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
|
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CAInit: marshal key: %w", err)
|
|
}
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
|
|
|
// Atomic write: temp file + rename. This avoids leaving a half-written
|
|
// ca.key on disk if the process crashes mid-write.
|
|
if err := WriteAtomic(certPath, CACPEMMode, certPEM); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := WriteAtomic(keyPath, CAMode, keyPEM); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return LoadCA(dir)
|
|
}
|
|
|
|
// LoadCA reads a previously-initialized CA from disk. Returns a *CA or an
|
|
// error. Verifies file modes (REQ-033).
|
|
func LoadCA(dir string) (*CA, error) {
|
|
if dir == "" {
|
|
return nil, errors.New("LoadCA: dir is required")
|
|
}
|
|
certPath := filepath.Join(dir, CACertFile)
|
|
keyPath := filepath.Join(dir, CAKeyFile)
|
|
|
|
if err := EnforceFileModes(dir); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
certPEM, err := os.ReadFile(certPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("LoadCA: read cert: %w", err)
|
|
}
|
|
keyPEM, err := os.ReadFile(keyPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("LoadCA: read key: %w", err)
|
|
}
|
|
|
|
certBlock, _ := pem.Decode(certPEM)
|
|
if certBlock == nil {
|
|
return nil, fmt.Errorf("LoadCA: cert PEM decode failed")
|
|
}
|
|
cert, err := x509.ParseCertificate(certBlock.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("LoadCA: parse cert: %w", err)
|
|
}
|
|
keyBlock, _ := pem.Decode(keyPEM)
|
|
if keyBlock == nil {
|
|
return nil, fmt.Errorf("LoadCA: key PEM decode failed")
|
|
}
|
|
keyAny, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("LoadCA: parse key: %w", err)
|
|
}
|
|
key, ok := keyAny.(*rsa.PrivateKey)
|
|
if !ok {
|
|
return nil, fmt.Errorf("LoadCA: key is %T, not *rsa.PrivateKey", keyAny)
|
|
}
|
|
|
|
return &CA{
|
|
Cert: cert,
|
|
Key: key,
|
|
CertPEM: certPEM,
|
|
Dir: dir,
|
|
NotBefore: cert.NotBefore,
|
|
NotAfter: cert.NotAfter,
|
|
}, nil
|
|
}
|
|
|
|
// EnforceFileModes refuses to operate if ca.crt / ca.key do not have the
|
|
// required modes (REQ-033). Returns nil on success. Callers (daemon start,
|
|
// CA loaders) MUST call this and abort on error.
|
|
func EnforceFileModes(dir string) error {
|
|
certPath := filepath.Join(dir, CACertFile)
|
|
keyPath := filepath.Join(dir, CAKeyFile)
|
|
certInfo, err := os.Stat(certPath)
|
|
if err != nil {
|
|
return fmt.Errorf("EnforceFileModes: stat %s: %w", certPath, err)
|
|
}
|
|
keyInfo, err := os.Stat(keyPath)
|
|
if err != nil {
|
|
return fmt.Errorf("EnforceFileModes: stat %s: %w", keyPath, err)
|
|
}
|
|
if certInfo.Mode().Perm() != CACPEMMode {
|
|
return fmt.Errorf(
|
|
"REQ-033 violation: %s has mode %04o, want %04o — fix with `chmod %04o %s`",
|
|
certPath, certInfo.Mode().Perm(), CACPEMMode, CACPEMMode, certPath,
|
|
)
|
|
}
|
|
if keyInfo.Mode().Perm() != CAMode {
|
|
return fmt.Errorf(
|
|
"REQ-033 violation: %s has mode %04o, want %04o — fix with `chmod %04o %s`",
|
|
keyPath, keyInfo.Mode().Perm(), CAMode, CAMode, keyPath,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SignCSR signs a PEM-encoded CSR with the CA and returns the issued cert
|
|
// in PEM form. The resulting cert is valid for ServerCertValidity and
|
|
// inherits the SANs from the CSR (DNS, IP). If the CSR has no SANs, the
|
|
// call fails — REQ-036 requires server certs to have identifying SANs.
|
|
func (c *CA) SignCSR(csrPEM []byte) ([]byte, error) {
|
|
if c == nil || c.Cert == nil || c.Key == nil {
|
|
return nil, errors.New("SignCSR: nil CA")
|
|
}
|
|
block, _ := pem.Decode(csrPEM)
|
|
if block == nil {
|
|
return nil, errors.New("SignCSR: CSR PEM decode failed")
|
|
}
|
|
if block.Type != "CERTIFICATE REQUEST" && block.Type != "NEW CERTIFICATE REQUEST" {
|
|
return nil, fmt.Errorf("SignCSR: unexpected PEM type %q", block.Type)
|
|
}
|
|
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("SignCSR: parse CSR: %w", err)
|
|
}
|
|
if err := csr.CheckSignature(); err != nil {
|
|
return nil, fmt.Errorf("SignCSR: CSR signature invalid: %w", err)
|
|
}
|
|
// REQ-036: refuse CSRs without SANs. A server cert needs at least
|
|
// one DNS or IP SAN so the peer can verify it against a pinned identity.
|
|
if len(csr.DNSNames) == 0 && len(csr.IPAddresses) == 0 {
|
|
return nil, errors.New("SignCSR: CSR has no DNS or IP SANs (REQ-036) — must include at least one")
|
|
}
|
|
|
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("SignCSR: serial: %w", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: csr.Subject,
|
|
NotBefore: now.Add(-1 * time.Hour),
|
|
NotAfter: now.Add(ServerCertValidity),
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
|
DNSNames: csr.DNSNames,
|
|
IPAddresses: csr.IPAddresses,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, c.Cert, csr.PublicKey, c.Key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("SignCSR: create cert: %w", err)
|
|
}
|
|
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), nil
|
|
}
|
|
|
|
// Fingerprint returns the SHA-256 hex fingerprint of the CA cert. Useful
|
|
// for the operator to communicate to peers out-of-band; peers then pin
|
|
// this value at `orca node join --ca-fingerprint <sha>`.
|
|
func (c *CA) Fingerprint() string {
|
|
return FingerprintOf(c.Cert.Raw)
|
|
}
|
|
|
|
// bothExist returns true if both paths exist (regular files).
|
|
func bothExist(paths ...string) (bool, error) {
|
|
for _, p := range paths {
|
|
info, err := os.Stat(p)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return false, fmt.Errorf("not a regular file: %s", p)
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// WriteCert writes a cert PEM blob to path with mode 0644 atomically.
|
|
// REQ-033 requires cert files to be 0644; this helper enforces that.
|
|
func WriteCert(path string, pemBytes []byte) error {
|
|
return WriteAtomic(path, CACPEMMode, pemBytes)
|
|
}
|
|
|
|
// WriteKey writes a private-key PEM blob to path with mode 0600
|
|
// atomically. REQ-033 requires key files to be 0600; this helper
|
|
// enforces that.
|
|
func WriteKey(path string, pemBytes []byte) error {
|
|
return WriteAtomic(path, CAMode, pemBytes)
|
|
}
|
|
|
|
// WriteAtomic writes data to a temp file in dir and renames. Sets the
|
|
// requested perm before the rename so the file lands at the right mode.
|
|
// Exported (AD-029) so the key-reset / known_hosts atomic rewrite path
|
|
// in proxmox (T02.6/T02.7) can reuse it instead of duplicating the
|
|
// ~20-LOC pattern (RESEARCH §5 pitfall #10).
|
|
func WriteAtomic(path string, mode os.FileMode, data []byte) error {
|
|
dir := filepath.Dir(path)
|
|
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
|
if err != nil {
|
|
return fmt.Errorf("WriteAtomic: create temp: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
// Best-effort cleanup if we fail before rename.
|
|
defer func() {
|
|
_ = os.Remove(tmpName)
|
|
}()
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("WriteAtomic: write: %w", err)
|
|
}
|
|
if err := tmp.Chmod(mode); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("WriteAtomic: chmod: %w", err)
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("WriteAtomic: sync: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("WriteAtomic: close: %w", err)
|
|
}
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
return fmt.Errorf("WriteAtomic: rename: %w", err)
|
|
}
|
|
return nil
|
|
}
|