Files
orca/internal/security/ca.go
T
Jon Chery 10bcb49514 fix(P15): file-mode audit expansion (REQ-130, F13)
---ci---
project: orca
phase: 15
milestone: v0.12
status: execute
---/ci---

EnforceFileModes now checks SSH key, known_hosts, master.key,
master.key.sealed, server.key (0600) + orca_ssh_key.pub, server.crt
(0644). Missing files skipped (may not exist before init or after
step-ca migration). All security tests pass. Build green.
2026-08-07 11:25:02 +00:00

412 lines
14 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.
//
// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca
// (D-101/REQ-076). This constant is retained for the dual-write window and
// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md.
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.
//
// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca
// (D-101/REQ-076). This constant is retained for the dual-write window and
// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md.
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.
//
// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca
// (D-101/REQ-076). This constant is retained for the dual-write window and
// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md.
const CAKeySize = 3072
// CAMode is the file mode used when persisting the CA private key. REQ-033
// requires 0600.
//
// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca
// (D-101/REQ-076). This constant is retained for the dual-write window and
// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md.
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).
//
// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca
// (D-101/REQ-076). This constant is retained for the dual-write window and
// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md.
const CACPEMMode os.FileMode = 0o644
// File names used inside the CA directory.
//
// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca
// (D-101/REQ-076). These constants are retained for the dual-write window and
// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md.
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.
//
// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca
// (D-101/REQ-076). The CA type is retained for the dual-write window and
// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md.
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.
//
// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca
// (D-101/REQ-076). CAInit is retained for the dual-write window and scheduled
// for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md.
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).
//
// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca
// (D-101/REQ-076). LoadCA is retained for the dual-write window and scheduled
// for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md.
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.
//
// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca
// (D-101/REQ-076). EnforceFileModes is retained for the dual-write window
// and scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md.
// EnforceFileModes checks that all security-sensitive files in dir have
// the correct permissions (REQ-033 + REQ-130, F13). Checks: ca.crt
// (0644), ca.key (0600), orca_ssh_key (0600), orca_ssh_key.pub (0644),
// known_hosts (0600), master.key (0600), master.key.sealed (0600),
// server.crt (0644), server.key (0600). Missing files are skipped (they
// may not exist yet — e.g. before init or after migration to step-ca).
func EnforceFileModes(dir string) error {
// Files that must be 0600 (secrets/keys).
secretFiles := []string{
CAKeyFile,
"orca_ssh_key",
"known_hosts",
"master.key",
"master.key.sealed",
"server.key",
}
// Files that must be 0644 (certs/public keys).
publicFiles := []string{
CACertFile,
"orca_ssh_key.pub",
"server.crt",
}
for _, name := range secretFiles {
path := filepath.Join(dir, name)
info, err := os.Stat(path)
if err != nil {
continue // skip missing
}
if info.Mode().Perm() != 0o600 {
return fmt.Errorf(
"REQ-033/130 violation: %s has mode %04o, want 0600 — fix with `chmod 0600 %s`",
path, info.Mode().Perm(), path,
)
}
}
for _, name := range publicFiles {
path := filepath.Join(dir, name)
info, err := os.Stat(path)
if err != nil {
continue // skip missing
}
if info.Mode().Perm() != 0o644 {
return fmt.Errorf(
"REQ-033/130 violation: %s has mode %04o, want 0644 — fix with `chmod 0644 %s`",
path, info.Mode().Perm(), path,
)
}
}
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.
//
// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca
// (D-101/REQ-076). SignCSR is retained for the dual-write window and
// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md.
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>`.
//
// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca
// (D-101/REQ-076). CA.Fingerprint is retained for the dual-write window and
// scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md.
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
}