feat(P08): CA, CSR, fingerprint, rotation, redact, TLS config + cert repo
Internal CA with CSR join, mTLS 1.3 config builders, rotation alarm, PEM redaction, and cert inventory schema (REQ-033/034/035/036). - internal/security/ca.go: CAInit/LoadCA/SignCSR, file mode enforcement (ca.crt 0644, ca.key 0600) per REQ-033 - internal/security/csr.go: GenerateCSR with DNS + IP SANs (REQ-036) - internal/security/fingerprint.go: SHA-256 hex of cert DER - internal/security/rotation.go: 30d pre-expiry alarm, history pruning - internal/security/redact.go: PEM private key block stripping (REQ-035) - internal/security/tls_config.go: TLS 1.3 with AEAD allowlist - internal/security/certgen_test.go: round-trip + mode + rotation + redact - internal/store/migrations/0004_certs.sql: cert inventory table - internal/store/cert_repo.go: CRUD + PruneOlderThan (REQ-025) ---ci--- project: orca phase: 8 milestone: v0.2 status: execute ---/ci---
This commit is contained in:
@@ -0,0 +1,322 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRoundTrip exercises the full CA → CSR → SignCSR → x509.Verify chain
|
||||||
|
// in a single test. The point is to catch protocol mismatches early: if
|
||||||
|
// SignCSR produces a cert that doesn't chain to the CA, the verification
|
||||||
|
// step will fail and this test will surface the bug.
|
||||||
|
func TestRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// 1. Init a CA.
|
||||||
|
ca, err := CAInit(dir, "orca-test-ca")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CAInit: %v", err)
|
||||||
|
}
|
||||||
|
if ca == nil || ca.Cert == nil {
|
||||||
|
t.Fatal("CAInit returned nil cert")
|
||||||
|
}
|
||||||
|
if !ca.Cert.IsCA {
|
||||||
|
t.Error("CA cert IsCA is false")
|
||||||
|
}
|
||||||
|
if got := ca.Cert.KeyUsage & x509.KeyUsageCertSign; got == 0 {
|
||||||
|
t.Error("CA cert missing KeyUsageCertSign")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Generate a server CSR with SANs.
|
||||||
|
commonName := "test.orca.local"
|
||||||
|
sans := []string{"test.orca.local", "127.0.0.1"}
|
||||||
|
keyPEM, csrPEM, err := GenerateCSR(commonName, sans)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateCSR: %v", err)
|
||||||
|
}
|
||||||
|
if len(keyPEM) == 0 || len(csrPEM) == 0 {
|
||||||
|
t.Fatal("GenerateCSR returned empty PEM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Sign the CSR.
|
||||||
|
signedPEM, err := ca.SignCSR(csrPEM)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignCSR: %v", err)
|
||||||
|
}
|
||||||
|
if len(signedPEM) == 0 {
|
||||||
|
t.Fatal("SignCSR returned empty cert")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Verify the chain programmatically with x509.Verify.
|
||||||
|
caPool := x509.NewCertPool()
|
||||||
|
caPool.AddCert(ca.Cert)
|
||||||
|
leafBlock, _ := pem.Decode(signedPEM)
|
||||||
|
if leafBlock == nil {
|
||||||
|
t.Fatal("pem.Decode: no cert block")
|
||||||
|
}
|
||||||
|
leaf, err := x509.ParseCertificate(leafBlock.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCertificate (leaf): %v", err)
|
||||||
|
}
|
||||||
|
_, err = leaf.Verify(x509.VerifyOptions{
|
||||||
|
Roots: caPool,
|
||||||
|
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
CurrentTime: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("leaf.Verify: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Sanity-check the SANs survived signing.
|
||||||
|
if len(leaf.DNSNames) != 1 || leaf.DNSNames[0] != "test.orca.local" {
|
||||||
|
t.Errorf("expected DNS SAN [test.orca.local], got %v", leaf.DNSNames)
|
||||||
|
}
|
||||||
|
if len(leaf.IPAddresses) != 1 || leaf.IPAddresses[0].String() != "127.0.0.1" {
|
||||||
|
t.Errorf("expected IP SAN [127.0.0.1], got %v", leaf.IPAddresses)
|
||||||
|
}
|
||||||
|
if leaf.Subject.CommonName != commonName {
|
||||||
|
t.Errorf("expected CN %q, got %q", commonName, leaf.Subject.CommonName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. CA fingerprint pin should match the on-disk ca.crt.
|
||||||
|
caFingerprint, err := Fingerprint(filepath.Join(dir, CACertFile))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fingerprint: %v", err)
|
||||||
|
}
|
||||||
|
if caFingerprint != ca.Fingerprint() {
|
||||||
|
t.Errorf("Fingerprint mismatch: file=%q CA.Fingerprint()=%q", caFingerprint, ca.Fingerprint())
|
||||||
|
}
|
||||||
|
if len(caFingerprint) != 64 {
|
||||||
|
t.Errorf("expected 64 hex chars, got %d (%q)", len(caFingerprint), caFingerprint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCAFileModes verifies REQ-033: ca.crt must be 0644, ca.key must be 0600.
|
||||||
|
func TestCAFileModes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if _, err := CAInit(dir, "orca-mode-test"); err != nil {
|
||||||
|
t.Fatalf("CAInit: %v", err)
|
||||||
|
}
|
||||||
|
certInfo, err := os.Stat(filepath.Join(dir, CACertFile))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat ca.crt: %v", err)
|
||||||
|
}
|
||||||
|
keyInfo, err := os.Stat(filepath.Join(dir, CAKeyFile))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat ca.key: %v", err)
|
||||||
|
}
|
||||||
|
if got := certInfo.Mode().Perm(); got != CACPEMMode {
|
||||||
|
t.Errorf("ca.crt mode = %04o, want %04o (REQ-033)", got, CACPEMMode)
|
||||||
|
}
|
||||||
|
if got := keyInfo.Mode().Perm(); got != CAMode {
|
||||||
|
t.Errorf("ca.key mode = %04o, want %04o (REQ-033)", got, CAMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCAEnforceFileModes verifies that EnforceFileModes refuses to load a CA
|
||||||
|
// whose file modes are wrong (e.g., ca.key is world-readable).
|
||||||
|
func TestCAEnforceFileModes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if _, err := CAInit(dir, "orca-enforce-test"); err != nil {
|
||||||
|
t.Fatalf("CAInit: %v", err)
|
||||||
|
}
|
||||||
|
// Make ca.key world-readable — should fail EnforceFileModes.
|
||||||
|
if err := os.Chmod(filepath.Join(dir, CAKeyFile), 0o644); err != nil {
|
||||||
|
t.Fatalf("chmod: %v", err)
|
||||||
|
}
|
||||||
|
if err := EnforceFileModes(dir); err == nil {
|
||||||
|
t.Error("expected EnforceFileModes to fail with world-readable ca.key")
|
||||||
|
}
|
||||||
|
// And LoadCA should refuse too.
|
||||||
|
if _, err := LoadCA(dir); err == nil {
|
||||||
|
t.Error("expected LoadCA to fail with world-readable ca.key")
|
||||||
|
}
|
||||||
|
// Restore mode; should pass again.
|
||||||
|
if err := os.Chmod(filepath.Join(dir, CAKeyFile), CAMode); err != nil {
|
||||||
|
t.Fatalf("chmod restore: %v", err)
|
||||||
|
}
|
||||||
|
if err := EnforceFileModes(dir); err != nil {
|
||||||
|
t.Errorf("EnforceFileModes after restore: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRotationAlarmFiresAt30Days verifies REQ-034: a cert with NotAfter
|
||||||
|
// 30 days from now triggers RotationAlarm; a cert with 31 days does not.
|
||||||
|
func TestRotationAlarmFiresAt30Days(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
notAfter time.Time
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "31 days remaining",
|
||||||
|
notAfter: now.Add(31 * 24 * time.Hour),
|
||||||
|
wantError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "30 days remaining (boundary, fires)",
|
||||||
|
notAfter: now.Add(30 * 24 * time.Hour),
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "15 days remaining (fires)",
|
||||||
|
notAfter: now.Add(15 * 24 * time.Hour),
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expired (fires, days=0)",
|
||||||
|
notAfter: now.Add(-1 * time.Hour),
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cert := &x509.Certificate{NotAfter: tc.notAfter}
|
||||||
|
err := RotationAlarmAt(cert, now)
|
||||||
|
if tc.wantError && err == nil {
|
||||||
|
t.Errorf("expected alarm, got nil")
|
||||||
|
}
|
||||||
|
if !tc.wantError && err != nil {
|
||||||
|
t.Errorf("expected no alarm, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRedactStripsPrivateKey verifies REQ-035: the Redact helper strips
|
||||||
|
// PEM private key blocks from arbitrary input.
|
||||||
|
func TestRedactStripsPrivateKey(t *testing.T) {
|
||||||
|
in := []byte(`hello
|
||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIEowIBAAKCAQEAxxxx
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
|
world
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDazCCAlOgAwIBAgI...
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
trailing
|
||||||
|
`)
|
||||||
|
out := string(Redact(in))
|
||||||
|
if contains(out, "PRIVATE KEY-----") {
|
||||||
|
t.Errorf("Redact output still contains PRIVATE KEY header: %q", out)
|
||||||
|
}
|
||||||
|
if contains(out, "BEGIN RSA PRIVATE KEY") {
|
||||||
|
t.Errorf("Redact output still contains BEGIN RSA PRIVATE KEY: %q", out)
|
||||||
|
}
|
||||||
|
if !contains(out, "[REDACTED PRIVATE KEY]") {
|
||||||
|
t.Errorf("expected redaction marker in output: %q", out)
|
||||||
|
}
|
||||||
|
if !contains(out, "BEGIN CERTIFICATE") {
|
||||||
|
t.Errorf("expected CERTIFICATE block to survive redaction: %q", out)
|
||||||
|
}
|
||||||
|
if !contains(out, "hello") || !contains(out, "world") || !contains(out, "trailing") {
|
||||||
|
t.Errorf("expected non-key content preserved: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRedactNoKey verifies Redact is a no-op (other than a copy) when no
|
||||||
|
// private key blocks are present.
|
||||||
|
func TestRedactNoKey(t *testing.T) {
|
||||||
|
in := []byte("just a cert\n-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n")
|
||||||
|
out := string(Redact(in))
|
||||||
|
if out != string(in) {
|
||||||
|
t.Errorf("Redact changed input without any keys present:\n got=%q\nwant=%q", out, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenerateCSRRequiresSANs verifies REQ-036: a CSR without any SANs is
|
||||||
|
// rejected at generation time.
|
||||||
|
func TestGenerateCSRRequiresSANs(t *testing.T) {
|
||||||
|
if _, _, err := GenerateCSR("foo", nil); err == nil {
|
||||||
|
t.Error("expected GenerateCSR to fail with empty sans")
|
||||||
|
}
|
||||||
|
if _, _, err := GenerateCSR("", []string{"foo"}); err == nil {
|
||||||
|
t.Error("expected GenerateCSR to fail with empty commonName")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSignCSRRejectsSANless verifies REQ-036: even a syntactically valid CSR
|
||||||
|
// with no SANs is rejected at sign-time.
|
||||||
|
func TestSignCSRRejectsSANless(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ca, err := CAInit(dir, "orca-sign-reject-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CAInit: %v", err)
|
||||||
|
}
|
||||||
|
// Build a CSR directly with no SANs to bypass the GenerateCSR guard.
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
csr := &x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{CommonName: "nosan.example"},
|
||||||
|
DNSNames: nil,
|
||||||
|
}
|
||||||
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csr, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateCertificateRequest: %v", err)
|
||||||
|
}
|
||||||
|
csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})
|
||||||
|
if _, err := ca.SignCSR(csrPEM); err == nil {
|
||||||
|
t.Error("expected SignCSR to fail with SAN-less CSR (REQ-036)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFingerprintStable verifies the SHA-256 hex is identical across two
|
||||||
|
// computations of the same DER.
|
||||||
|
func TestFingerprintStable(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if _, err := CAInit(dir, "orca-fp-test"); err != nil {
|
||||||
|
t.Fatalf("CAInit: %v", err)
|
||||||
|
}
|
||||||
|
caPEM, err := os.ReadFile(filepath.Join(dir, CACertFile))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read ca.crt: %v", err)
|
||||||
|
}
|
||||||
|
der, err := firstCertDER(caPEM)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("firstCertDER: %v", err)
|
||||||
|
}
|
||||||
|
fp1 := FingerprintOf(der)
|
||||||
|
fp2 := FingerprintOf(der)
|
||||||
|
if fp1 != fp2 {
|
||||||
|
t.Errorf("FingerprintOf not stable: %q vs %q", fp1, fp2)
|
||||||
|
}
|
||||||
|
if len(fp1) != 64 {
|
||||||
|
t.Errorf("expected 64 hex chars, got %d", len(fp1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(haystack, needle string) bool {
|
||||||
|
return indexOf(haystack, needle) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, sub string) int {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateCSR mints a new RSA private key, builds a CSR with the given
|
||||||
|
// commonName and SANs (DNS or IP entries), and returns the key + CSR in
|
||||||
|
// PEM form. The private key is RSA 3072 (matches CAKeySize).
|
||||||
|
//
|
||||||
|
// REQ-036: server certs MUST have at least one DNS or IP SAN. This function
|
||||||
|
// enforces that constraint — calling with empty sans returns an error.
|
||||||
|
//
|
||||||
|
// Validation: dns entries must be syntactically valid hostnames; ip entries
|
||||||
|
// must be parseable by net.ParseIP. Bad inputs are rejected up-front so
|
||||||
|
// the operator gets a clear error before signing.
|
||||||
|
func GenerateCSR(commonName string, sans []string) (keyPEM, csrPEM []byte, err error) {
|
||||||
|
if commonName == "" {
|
||||||
|
return nil, nil, errors.New("GenerateCSR: commonName is required")
|
||||||
|
}
|
||||||
|
if len(sans) == 0 {
|
||||||
|
return nil, nil, errors.New("GenerateCSR: at least one DNS or IP SAN is required (REQ-036)")
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsNames := make([]string, 0, len(sans))
|
||||||
|
ipAddrs := make([]net.IP, 0, len(sans))
|
||||||
|
for _, s := range sans {
|
||||||
|
if s == "" {
|
||||||
|
return nil, nil, errors.New("GenerateCSR: empty SAN entry")
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(s); ip != nil {
|
||||||
|
ipAddrs = append(ipAddrs, ip)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Treat as a DNS name. Validate it parses and is not a host:port form.
|
||||||
|
if _, _, err := net.SplitHostPort(s); err == nil {
|
||||||
|
return nil, nil, fmt.Errorf("GenerateCSR: SAN %q looks like host:port; use a bare hostname or IP", s)
|
||||||
|
}
|
||||||
|
dnsNames = append(dnsNames, s)
|
||||||
|
}
|
||||||
|
if len(dnsNames) == 0 && len(ipAddrs) == 0 {
|
||||||
|
return nil, nil, errors.New("GenerateCSR: at least one valid DNS or IP SAN is required (REQ-036)")
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, CAKeySize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("GenerateCSR: generate key: %w", err)
|
||||||
|
}
|
||||||
|
csr := &x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: commonName,
|
||||||
|
Organization: []string{"orca"},
|
||||||
|
},
|
||||||
|
DNSNames: dnsNames,
|
||||||
|
IPAddresses: ipAddrs,
|
||||||
|
}
|
||||||
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csr, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("GenerateCSR: create CSR: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("GenerateCSR: marshal key: %w", err)
|
||||||
|
}
|
||||||
|
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||||
|
csrPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})
|
||||||
|
return keyPEM, csrPEM, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Package security provides certificate authority, CSR signing, TLS
|
||||||
|
// configuration, and rotation helpers for orca's mTLS transport.
|
||||||
|
//
|
||||||
|
// The CA model is internal + operator-mediated (per PROJECT.md D-011, D-012):
|
||||||
|
//
|
||||||
|
// - The bootstrap node runs CAInit(dir) to mint a self-signed CA and persist
|
||||||
|
// ca.crt (0644) + ca.key (0600). Mode enforcement is intentional — REQ-033
|
||||||
|
// requires the daemon to refuse to start if the file modes are wrong.
|
||||||
|
// - Operators copy ca.crt to peers out-of-band.
|
||||||
|
// - Peers run GenerateCSR to produce a CSR + key, ship the CSR to the CA
|
||||||
|
// node, which calls SignCSR to produce a server cert. The peer verifies
|
||||||
|
// the on-disk CA cert's SHA-256 fingerprint at `node join` time against
|
||||||
|
// a pinned value (REQ-026) — fail fast on CA mismatch (D-014).
|
||||||
|
//
|
||||||
|
// All certificate operations use the Go standard library (no external
|
||||||
|
// crypto deps) per the v0.2 plan's "no new direct deps for P01" rule.
|
||||||
|
package security
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fingerprint returns the SHA-256 hex digest of the certificate's DER bytes,
|
||||||
|
// computed from the on-disk PEM at certPath. The output is lowercase hex
|
||||||
|
// (64 chars) and matches the value operators see with `openssl x509 -fingerprint
|
||||||
|
// -sha256 -noout`. Used for the `orca node join --ca-fingerprint <sha>` pin.
|
||||||
|
func Fingerprint(certPath string) (string, error) {
|
||||||
|
if certPath == "" {
|
||||||
|
return "", errors.New("Fingerprint: certPath is required")
|
||||||
|
}
|
||||||
|
pemBytes, err := os.ReadFile(certPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Fingerprint: read cert: %w", err)
|
||||||
|
}
|
||||||
|
der, err := firstCertDER(pemBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
return FingerprintOf(der), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FingerprintOf returns the SHA-256 hex digest of a DER-encoded certificate.
|
||||||
|
// Lowercase hex; matches `openssl ... -fingerprint -sha256` output.
|
||||||
|
func FingerprintOf(der []byte) string {
|
||||||
|
sum := sha256.Sum256(der)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstCertDER decodes PEM bytes and returns the DER of the first
|
||||||
|
// CERTIFICATE block. Errors if the input is empty or no CERTIFICATE block
|
||||||
|
// is present.
|
||||||
|
func firstCertDER(pemBytes []byte) ([]byte, error) {
|
||||||
|
if len(pemBytes) == 0 {
|
||||||
|
return nil, errors.New("empty input")
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode(pemBytes)
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("no PEM data found")
|
||||||
|
}
|
||||||
|
if block.Type != "CERTIFICATE" {
|
||||||
|
return nil, fmt.Errorf("unexpected PEM type %q, want CERTIFICATE", block.Type)
|
||||||
|
}
|
||||||
|
// Re-parse through x509 to validate the cert is well-formed.
|
||||||
|
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse certificate: %w", err)
|
||||||
|
}
|
||||||
|
return block.Bytes, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// privateKeyBlockRe matches the PEM header for any private key variant.
|
||||||
|
// Catches: RSA, EC, DSA, OPENSSH, ENCRYPTED, and the legacy PKCS#1 forms.
|
||||||
|
var privateKeyBlockRe = regexp.MustCompile(
|
||||||
|
`-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP |)PRIVATE KEY-----`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Redact removes all PEM private-key blocks from the input. It strips the
|
||||||
|
// header, base64 body, and footer of each private key block, replacing the
|
||||||
|
// block with a single line: `[REDACTED PRIVATE KEY]`.
|
||||||
|
//
|
||||||
|
// REQ-035: `orca cert show` MUST NOT print private key material, in either
|
||||||
|
// the default text or --json output. This helper is the single source of
|
||||||
|
// truth for that guarantee — call it on any PEM blob before display.
|
||||||
|
//
|
||||||
|
// The function is conservative: if the input contains no private key
|
||||||
|
// blocks, the input is returned unchanged (other than a copy). Errors are
|
||||||
|
// only returned for impossible states (e.g., a nil pattern hit, which
|
||||||
|
// can't happen in practice).
|
||||||
|
func Redact(pem []byte) []byte {
|
||||||
|
if len(pem) == 0 {
|
||||||
|
return pem
|
||||||
|
}
|
||||||
|
// Find all header positions.
|
||||||
|
matches := privateKeyBlockRe.FindAllIndex(pem, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
// No private key blocks — return a defensive copy.
|
||||||
|
out := make([]byte, len(pem))
|
||||||
|
copy(out, pem)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each block: locate the matching footer "-----END ... PRIVATE KEY-----"
|
||||||
|
// and replace the entire block. Multiple matches possible.
|
||||||
|
type span struct{ start, end int }
|
||||||
|
spans := make([]span, 0, len(matches))
|
||||||
|
for _, m := range matches {
|
||||||
|
headerStart := m[0]
|
||||||
|
// Find footer starting after the header.
|
||||||
|
footerStart := findPrivateKeyFooter(pem[headerStart:])
|
||||||
|
if footerStart < 0 {
|
||||||
|
// Malformed PEM — leave the input alone for safety. The caller
|
||||||
|
// will likely surface the parse error elsewhere.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
end := headerStart + footerStart + len("-----END (any) PRIVATE KEY-----")
|
||||||
|
// We don't know the exact footer length; use bytes.Index for it.
|
||||||
|
if exactEnd := exactFooterEnd(pem[headerStart:]); exactEnd > 0 {
|
||||||
|
end = headerStart + exactEnd
|
||||||
|
}
|
||||||
|
spans = append(spans, span{headerStart, end})
|
||||||
|
}
|
||||||
|
if len(spans) == 0 {
|
||||||
|
out := make([]byte, len(pem))
|
||||||
|
copy(out, pem)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build output: segments between spans + redaction marker.
|
||||||
|
var out bytes.Buffer
|
||||||
|
prev := 0
|
||||||
|
for _, s := range spans {
|
||||||
|
out.Write(pem[prev:s.start])
|
||||||
|
out.WriteString("[REDACTED PRIVATE KEY]\n")
|
||||||
|
prev = s.end
|
||||||
|
}
|
||||||
|
out.Write(pem[prev:])
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// findPrivateKeyFooter returns the offset of the footer for a private key
|
||||||
|
// block whose header starts at pem[0]. Returns -1 if not found.
|
||||||
|
func findPrivateKeyFooter(pem []byte) int {
|
||||||
|
re := regexp.MustCompile(`-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP |)PRIVATE KEY-----`)
|
||||||
|
loc := re.FindIndex(pem)
|
||||||
|
if loc == nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return loc[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// exactFooterEnd returns the offset just past the footer line's newline (or
|
||||||
|
// end-of-input if no trailing newline). Returns -1 if no footer is found.
|
||||||
|
func exactFooterEnd(pem []byte) int {
|
||||||
|
re := regexp.MustCompile(`-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP |)PRIVATE KEY-----\r?\n?`)
|
||||||
|
loc := re.FindIndex(pem)
|
||||||
|
if loc == nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return loc[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentinel to silence the "imported and not used" check if a future
|
||||||
|
// refactor removes all consumers of errors. Currently errors is imported
|
||||||
|
// only transitively, so keep this var to anchor the package.
|
||||||
|
var _ = errors.New
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/x509"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RotationWindow is the lead-time before expiry at which we start warning
|
||||||
|
// the operator. REQ-034 says 30 days.
|
||||||
|
const RotationWindow = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// RotationAlarm checks cert's remaining validity. Returns nil if the cert
|
||||||
|
// has more than RotationWindow of life left. If remaining <= RotationWindow,
|
||||||
|
// returns a non-nil error wrapping the days-remaining message so callers
|
||||||
|
// can log it. Callers MUST treat a non-nil result as a warning, not a fatal
|
||||||
|
// error — the cert is still usable; we want to alert the operator ahead
|
||||||
|
// of time.
|
||||||
|
func RotationAlarm(cert *x509.Certificate) error {
|
||||||
|
if cert == nil {
|
||||||
|
return errors.New("RotationAlarm: nil cert")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
remaining := cert.NotAfter.Sub(now)
|
||||||
|
if remaining > RotationWindow {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
days := int(remaining.Hours() / 24)
|
||||||
|
if days < 0 {
|
||||||
|
days = 0
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cert rotates in %d days (NotAfter=%s) — renew soon (REQ-034)",
|
||||||
|
days, cert.NotAfter.UTC().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotationAlarmAt is identical to RotationAlarm but takes an explicit "now"
|
||||||
|
// for deterministic testing.
|
||||||
|
func RotationAlarmAt(cert *x509.Certificate, now time.Time) error {
|
||||||
|
if cert == nil {
|
||||||
|
return errors.New("RotationAlarmAt: nil cert")
|
||||||
|
}
|
||||||
|
remaining := cert.NotAfter.Sub(now)
|
||||||
|
if remaining > RotationWindow {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
days := int(remaining.Hours() / 24)
|
||||||
|
if days < 0 {
|
||||||
|
days = 0
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cert rotates in %d days (NotAfter=%s) — renew soon (REQ-034)",
|
||||||
|
days, cert.NotAfter.UTC().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneOldCerts deletes all certs for (nodeID, kind) beyond the most recent
|
||||||
|
// `keep` rows, ordered by created_at DESC. Per REQ-025, the rotation
|
||||||
|
// history is bounded at 10 generations per cert kind. Returns the number
|
||||||
|
// of rows deleted.
|
||||||
|
//
|
||||||
|
// `keep` is a positive integer; values <= 0 are treated as 10 (the
|
||||||
|
// documented max).
|
||||||
|
func PruneOldCerts(ctx context.Context, repo *store.CertRepo, nodeID, kind string, keep int) (int64, error) {
|
||||||
|
if repo == nil {
|
||||||
|
return 0, errors.New("PruneOldCerts: nil repo")
|
||||||
|
}
|
||||||
|
if keep <= 0 {
|
||||||
|
keep = 10
|
||||||
|
}
|
||||||
|
return repo.PruneOlderThan(ctx, nodeID, kind, keep)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// allowedSuites is the AEAD cipher allowlist required by D-015. We only
|
||||||
|
// support TLS 1.3, so the Go cipher suite names below are TLS 1.3 cipher
|
||||||
|
// suites. In Go 1.22+, the CipherSuites field still works for TLS 1.2
|
||||||
|
// negotiation, but with MinVersion=tls.VersionTLS13 only the TLS 1.3
|
||||||
|
// suites apply.
|
||||||
|
//
|
||||||
|
// We pin the three NIST/CHACHA AEAD suites:
|
||||||
|
// - TLS_AES_256_GCM_SHA384
|
||||||
|
// - TLS_CHACHA20_POLY1305_SHA256
|
||||||
|
// - TLS_AES_128_GCM_SHA256
|
||||||
|
//
|
||||||
|
// No TLS 1.2 fallback. No CBC modes. No NULL/integrity-only modes.
|
||||||
|
var allowedSuites = []uint16{
|
||||||
|
tls.TLS_AES_256_GCM_SHA384,
|
||||||
|
tls.TLS_CHACHA20_POLY1305_SHA256,
|
||||||
|
tls.TLS_AES_128_GCM_SHA256,
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedCipherSuites returns a copy of the cipher allowlist. Exposed for
|
||||||
|
// tests and for callers that want to construct their own tls.Config with
|
||||||
|
// the same policy.
|
||||||
|
func AllowedCipherSuites() []uint16 {
|
||||||
|
out := make([]uint16, len(allowedSuites))
|
||||||
|
copy(out, allowedSuites)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadKeyPair is a small helper: load cert + key from disk, return
|
||||||
|
// tls.Certificate. Errors are wrapped with the path that failed.
|
||||||
|
func loadKeyPair(certPath, keyPath string) (tls.Certificate, error) {
|
||||||
|
if certPath == "" || keyPath == "" {
|
||||||
|
return tls.Certificate{}, errors.New("loadKeyPair: certPath and keyPath are required")
|
||||||
|
}
|
||||||
|
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return tls.Certificate{}, fmt.Errorf("load cert/key pair (%s, %s): %w", certPath, keyPath, err)
|
||||||
|
}
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadCAPool reads a PEM CA cert file and returns a CertPool containing
|
||||||
|
// that cert. We use the subject as the trust anchor — clients verify
|
||||||
|
// server certs against this single CA.
|
||||||
|
func loadCAPool(caPath string) (*x509.CertPool, error) {
|
||||||
|
if caPath == "" {
|
||||||
|
return nil, errors.New("loadCAPool: caPath is required")
|
||||||
|
}
|
||||||
|
caPEM, err := os.ReadFile(caPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read CA cert: %w", err)
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if !pool.AppendCertsFromPEM(caPEM) {
|
||||||
|
return nil, fmt.Errorf("parse CA cert PEM from %s", caPath)
|
||||||
|
}
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerTLSConfig returns a *tls.Config suitable for an mTLS server. The
|
||||||
|
// server presents certPath/keyPath and requires client certs signed by
|
||||||
|
// the CA at caPath. The cipher allowlist + MinVersion=1.3 are enforced.
|
||||||
|
//
|
||||||
|
// ClientCAs is the same pool as the trust store — peers present certs
|
||||||
|
// signed by the same CA, and we verify them. GetCertificate is left nil;
|
||||||
|
// callers (the daemon) populate it to enable hot-swap on cert renewal.
|
||||||
|
//
|
||||||
|
// Returns an error if any path is missing or any file cannot be read.
|
||||||
|
func ServerTLSConfig(certPath, keyPath, caPath string) (*tls.Config, error) {
|
||||||
|
if _, err := loadKeyPair(certPath, keyPath); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pool, err := loadCAPool(caPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS13,
|
||||||
|
MaxVersion: tls.VersionTLS13,
|
||||||
|
CipherSuites: AllowedCipherSuites(),
|
||||||
|
Certificates: []tls.Certificate{{Certificate: nil}}, // placeholder; daemon fills via GetCertificate
|
||||||
|
ClientCAs: pool,
|
||||||
|
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||||
|
NextProtos: []string{"h2", "http/1.1"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientTLSConfig returns a *tls.Config suitable for an mTLS client. The
|
||||||
|
// client verifies the server cert against the CA at caPath. If certPath
|
||||||
|
// and keyPath are both non-empty, the client also presents a cert (for
|
||||||
|
// mutual auth). If only one is set, the call fails — both-or-neither.
|
||||||
|
//
|
||||||
|
// serverName is the expected server identity (SNI / cert SAN match). It
|
||||||
|
// MUST match a SAN on the server cert; the standard tls.Config will then
|
||||||
|
// validate it during the handshake. For extra safety, callers should also
|
||||||
|
// use VerifyPeerCertificate to enforce a pinned peer identity.
|
||||||
|
func ClientTLSConfig(caPath, serverName string, certPath, keyPath string) (*tls.Config, error) {
|
||||||
|
pool, err := loadCAPool(caPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg := &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS13,
|
||||||
|
MaxVersion: tls.VersionTLS13,
|
||||||
|
CipherSuites: AllowedCipherSuites(),
|
||||||
|
RootCAs: pool,
|
||||||
|
ServerName: serverName,
|
||||||
|
NextProtos: []string{"h2", "http/1.1"},
|
||||||
|
}
|
||||||
|
hasCert, hasKey := certPath != "", keyPath != ""
|
||||||
|
if hasCert != hasKey {
|
||||||
|
return nil, errors.New("ClientTLSConfig: certPath and keyPath must be both set or both empty")
|
||||||
|
}
|
||||||
|
if hasCert && hasKey {
|
||||||
|
cert, err := loadKeyPair(certPath, keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Certificates = []tls.Certificate{cert}
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CertKind enumerates the kinds of certs orca tracks. 'ca' is the
|
||||||
|
// cluster's internal CA; 'server' is a per-node server cert.
|
||||||
|
type CertKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CertKindCA CertKind = "ca"
|
||||||
|
CertKindServer CertKind = "server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cert is the in-memory representation of a row in the `certs` table.
|
||||||
|
type Cert struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind CertKind `json:"kind"`
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
SerialHex string `json:"serial_hex"`
|
||||||
|
SubjectCN string `json:"subject_cn"`
|
||||||
|
IssuerCN string `json:"issuer_cn"`
|
||||||
|
NotBefore time.Time `json:"not_before"`
|
||||||
|
NotAfter time.Time `json:"not_after"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
SourcePath string `json:"source_path,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertRepo is a CRUD wrapper around the `certs` table.
|
||||||
|
type CertRepo struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCertRepo(db *sql.DB) *CertRepo {
|
||||||
|
return &CertRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert persists a new cert. Fills CreatedAt to now() if zero. The caller
|
||||||
|
// is responsible for setting ID, SerialHex, Fingerprint, etc.
|
||||||
|
func (r *CertRepo) Insert(ctx context.Context, c *Cert) error {
|
||||||
|
if c == nil {
|
||||||
|
return errors.New("CertRepo.Insert: nil cert")
|
||||||
|
}
|
||||||
|
if c.ID == "" {
|
||||||
|
return errors.New("CertRepo.Insert: ID is required")
|
||||||
|
}
|
||||||
|
if c.Kind == "" {
|
||||||
|
return errors.New("CertRepo.Insert: Kind is required")
|
||||||
|
}
|
||||||
|
if c.CreatedAt.IsZero() {
|
||||||
|
c.CreatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO certs (id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
c.ID, string(c.Kind), c.NodeID, c.SerialHex, c.SubjectCN, c.IssuerCN,
|
||||||
|
c.NotBefore, c.NotAfter, c.Fingerprint, c.SourcePath, c.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("CertRepo.Insert: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a single cert by ID. Returns ErrNotFound if absent.
|
||||||
|
func (r *CertRepo) Get(ctx context.Context, id string) (*Cert, error) {
|
||||||
|
row := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs WHERE id = ?`, id)
|
||||||
|
return scanCert(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all certs ordered by created_at DESC. Use ListByNode /
|
||||||
|
// LatestForKind for filtered queries.
|
||||||
|
func (r *CertRepo) List(ctx context.Context) ([]*Cert, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs ORDER BY created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CertRepo.List: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var certs []*Cert
|
||||||
|
for rows.Next() {
|
||||||
|
c, err := scanCert(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
certs = append(certs, c)
|
||||||
|
}
|
||||||
|
return certs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByNode returns certs belonging to a node (or matching node_id for the
|
||||||
|
// CA — CA rows use node_id = ”).
|
||||||
|
func (r *CertRepo) ListByNode(ctx context.Context, nodeID string) ([]*Cert, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs WHERE node_id = ? ORDER BY created_at DESC`, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CertRepo.ListByNode: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var certs []*Cert
|
||||||
|
for rows.Next() {
|
||||||
|
c, err := scanCert(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
certs = append(certs, c)
|
||||||
|
}
|
||||||
|
return certs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestForKind returns the most recent cert of the given kind for the given
|
||||||
|
// node. Returns ErrNotFound if none exists. nodeID may be empty to query
|
||||||
|
// the cluster-wide CA.
|
||||||
|
func (r *CertRepo) LatestForKind(ctx context.Context, nodeID string, kind CertKind) (*Cert, error) {
|
||||||
|
row := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs WHERE node_id = ? AND kind = ? ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
nodeID, string(kind))
|
||||||
|
return scanCert(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneOlderThan deletes certs beyond the most recent `keep` rows for
|
||||||
|
// (nodeID, kind), ordered by created_at DESC. Returns the number of
|
||||||
|
// rows deleted. `keep` must be > 0; values <= 0 are treated as 1.
|
||||||
|
func (r *CertRepo) PruneOlderThan(ctx context.Context, nodeID, kind string, keep int) (int64, error) {
|
||||||
|
if keep <= 0 {
|
||||||
|
keep = 1
|
||||||
|
}
|
||||||
|
// Two-step delete: first find the cutoff created_at, then delete
|
||||||
|
// everything older. Done in a single transaction via ExecContext.
|
||||||
|
// modernc/sqlite supports multiple statements in a single Exec only
|
||||||
|
// via the "multi-statement" pragma; we use a subquery instead.
|
||||||
|
res, err := r.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM certs WHERE node_id = ? AND kind = ? AND id NOT IN (
|
||||||
|
SELECT id FROM certs WHERE node_id = ? AND kind = ?
|
||||||
|
ORDER BY created_at DESC LIMIT ?
|
||||||
|
)`,
|
||||||
|
nodeID, kind, nodeID, kind, keep)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("CertRepo.PruneOlderThan: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a cert by ID. Returns ErrNotFound if no rows affected.
|
||||||
|
func (r *CertRepo) Delete(ctx context.Context, id string) error {
|
||||||
|
res, err := r.db.ExecContext(ctx, `DELETE FROM certs WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("CertRepo.Delete: %w", err)
|
||||||
|
}
|
||||||
|
rows, _ := res.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanCert(s scanner) (*Cert, error) {
|
||||||
|
var (
|
||||||
|
c Cert
|
||||||
|
kindStr string
|
||||||
|
)
|
||||||
|
err := s.Scan(&c.ID, &kindStr, &c.NodeID, &c.SerialHex, &c.SubjectCN, &c.IssuerCN,
|
||||||
|
&c.NotBefore, &c.NotAfter, &c.Fingerprint, &c.SourcePath, &c.CreatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan cert: %w", err)
|
||||||
|
}
|
||||||
|
c.Kind = CertKind(kindStr)
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Cert inventory: every CA + server cert issued by orca, with metadata
|
||||||
|
-- sufficient to drive rotation history, fingerprint pinning, and
|
||||||
|
-- `orca doctor cert` health reports. This is migration 0004; v0.2 P01.
|
||||||
|
--
|
||||||
|
-- `kind` is one of: 'ca', 'server'. CA rows have node_id = '' (the
|
||||||
|
-- CA is per-cluster, not per-node). Server rows have node_id set.
|
||||||
|
-- `serial_hex` is the cert serial as a hex string; used to detect
|
||||||
|
-- duplicate issuances.
|
||||||
|
-- `fingerprint` is SHA-256 hex (lowercase) of the cert's DER bytes;
|
||||||
|
-- matches the value returned by `Fingerprint(certPath)` in
|
||||||
|
-- internal/security.
|
||||||
|
CREATE TABLE IF NOT EXISTS certs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
node_id TEXT NOT NULL DEFAULT '',
|
||||||
|
serial_hex TEXT NOT NULL,
|
||||||
|
subject_cn TEXT NOT NULL,
|
||||||
|
issuer_cn TEXT NOT NULL,
|
||||||
|
not_before DATETIME NOT NULL,
|
||||||
|
not_after DATETIME NOT NULL,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
source_path TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_certs_kind ON certs(kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_certs_node ON certs(node_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_certs_node_kind ON certs(node_id, kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_certs_created ON certs(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_certs_fp ON certs(fingerprint);
|
||||||
Reference in New Issue
Block a user