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. 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. // // 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 `. // // 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 }