|
@@ -0,0 +1,933 @@
|
|
|
|
|
+package certs
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "context"
|
|
|
|
|
+ "crypto"
|
|
|
|
|
+ "crypto/ecdsa"
|
|
|
|
|
+ "crypto/elliptic"
|
|
|
|
|
+ "crypto/rand"
|
|
|
|
|
+ "crypto/rsa"
|
|
|
|
|
+ "crypto/sha256"
|
|
|
|
|
+ "crypto/tls"
|
|
|
|
|
+ "crypto/x509"
|
|
|
|
|
+ "crypto/x509/pkix"
|
|
|
|
|
+ "encoding/hex"
|
|
|
|
|
+ "encoding/json"
|
|
|
|
|
+ "encoding/pem"
|
|
|
|
|
+ "errors"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "math/big"
|
|
|
|
|
+ "net"
|
|
|
|
|
+ "os"
|
|
|
|
|
+ "path/filepath"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "sync"
|
|
|
|
|
+ "time"
|
|
|
|
|
+
|
|
|
|
|
+ "golang.org/x/crypto/acme"
|
|
|
|
|
+ "proxmox-router/internal/logging"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+const (
|
|
|
|
|
+ DefaultCertsDir = "/etc/proxmox-router/certs"
|
|
|
|
|
+ DefaultMetadataFile = "certs.json"
|
|
|
|
|
+
|
|
|
|
|
+ TypeCA = "ca"
|
|
|
|
|
+ TypeServer = "server"
|
|
|
|
|
+ TypeClient = "client"
|
|
|
|
|
+ TypeACME = "acme"
|
|
|
|
|
+
|
|
|
|
|
+ KeyRSA2048 = "RSA-2048"
|
|
|
|
|
+ KeyRSA4096 = "RSA-4096"
|
|
|
|
|
+ KeyECDSAP256 = "ECDSA-P256"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+type CertificateInfo struct {
|
|
|
|
|
+ ID string `json:"id"`
|
|
|
|
|
+ Name string `json:"name"`
|
|
|
|
|
+ CommonName string `json:"common_name"`
|
|
|
|
|
+ SANs []string `json:"sans"`
|
|
|
|
|
+ IsCA bool `json:"is_ca"`
|
|
|
|
|
+ Issuer string `json:"issuer"`
|
|
|
|
|
+ KeyType string `json:"key_type"`
|
|
|
|
|
+ ValidFrom time.Time `json:"valid_from"`
|
|
|
|
|
+ ValidTo time.Time `json:"valid_to"`
|
|
|
|
|
+ Fingerprint string `json:"fingerprint"`
|
|
|
|
|
+ CertPath string `json:"cert_path"`
|
|
|
|
|
+ KeyPath string `json:"key_path"`
|
|
|
|
|
+ Type string `json:"type"`
|
|
|
|
|
+ Comment string `json:"comment,omitempty"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type CreateCARequest struct {
|
|
|
|
|
+ Name string `json:"name"`
|
|
|
|
|
+ CommonName string `json:"common_name"`
|
|
|
|
|
+ Organization string `json:"organization,omitempty"`
|
|
|
|
|
+ Country string `json:"country,omitempty"`
|
|
|
|
|
+ Days int `json:"days,omitempty"`
|
|
|
|
|
+ KeyType string `json:"key_type,omitempty"`
|
|
|
|
|
+ Comment string `json:"comment,omitempty"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type CreateCertRequest struct {
|
|
|
|
|
+ Name string `json:"name"`
|
|
|
|
|
+ CommonName string `json:"common_name"`
|
|
|
|
|
+ CAName string `json:"ca_name,omitempty"`
|
|
|
|
|
+ SANs []string `json:"sans,omitempty"`
|
|
|
|
|
+ Days int `json:"days,omitempty"`
|
|
|
|
|
+ KeyType string `json:"key_type,omitempty"`
|
|
|
|
|
+ IsServer bool `json:"is_server"`
|
|
|
|
|
+ Organization string `json:"organization,omitempty"`
|
|
|
|
|
+ Country string `json:"country,omitempty"`
|
|
|
|
|
+ Comment string `json:"comment,omitempty"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type ImportCertRequest struct {
|
|
|
|
|
+ Name string `json:"name"`
|
|
|
|
|
+ CertPEM string `json:"cert_pem"`
|
|
|
|
|
+ KeyPEM string `json:"key_pem"`
|
|
|
|
|
+ Comment string `json:"comment,omitempty"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type LetsEncryptRequest struct {
|
|
|
|
|
+ Name string `json:"name"`
|
|
|
|
|
+ Domain string `json:"domain"`
|
|
|
|
|
+ Email string `json:"email"`
|
|
|
|
|
+ Staging bool `json:"staging"`
|
|
|
|
|
+ Comment string `json:"comment,omitempty"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type CertManager struct {
|
|
|
|
|
+ dir string
|
|
|
|
|
+ metaPath string
|
|
|
|
|
+ mu sync.RWMutex
|
|
|
|
|
+ certs map[string]*CertificateInfo
|
|
|
|
|
+ challenges map[string]string // token -> keyAuth
|
|
|
|
|
+ challengeMu sync.RWMutex
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func NewCertManager(customDir string) (*CertManager, error) {
|
|
|
|
|
+ d := customDir
|
|
|
|
|
+ if d == "" {
|
|
|
|
|
+ d = DefaultCertsDir
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := os.MkdirAll(d, 0700); err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed to create certs directory %s: %w", d, err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ cm := &CertManager{
|
|
|
|
|
+ dir: d,
|
|
|
|
|
+ metaPath: filepath.Join(d, DefaultMetadataFile),
|
|
|
|
|
+ certs: make(map[string]*CertificateInfo),
|
|
|
|
|
+ challenges: make(map[string]string),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if err := cm.load(); err != nil {
|
|
|
|
|
+ logging.Log("certs,warning", "Warning loading cert metadata: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return cm, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (cm *CertManager) load() error {
|
|
|
|
|
+ cm.mu.Lock()
|
|
|
|
|
+ defer cm.mu.Unlock()
|
|
|
|
|
+
|
|
|
|
|
+ data, err := os.ReadFile(cm.metaPath)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ if os.IsNotExist(err) {
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var list []*CertificateInfo
|
|
|
|
|
+ if err := json.Unmarshal(data, &list); err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for _, c := range list {
|
|
|
|
|
+ if c.Name != "" {
|
|
|
|
|
+ cm.certs[strings.ToLower(c.Name)] = c
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func (cm *CertManager) saveLocked() error {
|
|
|
|
|
+ var list []*CertificateInfo
|
|
|
|
|
+ for _, c := range cm.certs {
|
|
|
|
|
+ list = append(list, c)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ data, err := json.MarshalIndent(list, "", " ")
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ tmp := fmt.Sprintf("%s.tmp.%d", cm.metaPath, time.Now().UnixNano())
|
|
|
|
|
+ if err := os.WriteFile(tmp, data, 0600); err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ return os.Rename(tmp, cm.metaPath)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// RegisterHTTP01Challenge stores an ACME challenge token and its response for HTTP-01 verification.
|
|
|
|
|
+func (cm *CertManager) RegisterHTTP01Challenge(token, keyAuth string) {
|
|
|
|
|
+ cm.challengeMu.Lock()
|
|
|
|
|
+ defer cm.challengeMu.Unlock()
|
|
|
|
|
+ cm.challenges[token] = keyAuth
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// UnregisterHTTP01Challenge removes an ACME challenge token.
|
|
|
|
|
+func (cm *CertManager) UnregisterHTTP01Challenge(token string) {
|
|
|
|
|
+ cm.challengeMu.Lock()
|
|
|
|
|
+ defer cm.challengeMu.Unlock()
|
|
|
|
|
+ delete(cm.challenges, token)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// GetHTTP01Challenge looks up an ACME challenge token for HTTP-01 verification.
|
|
|
|
|
+func (cm *CertManager) GetHTTP01Challenge(token string) (string, bool) {
|
|
|
|
|
+ cm.challengeMu.RLock()
|
|
|
|
|
+ defer cm.challengeMu.RUnlock()
|
|
|
|
|
+ auth, ok := cm.challenges[token]
|
|
|
|
|
+ return auth, ok
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ListCertificates returns all certificates known to the manager.
|
|
|
|
|
+func (cm *CertManager) ListCertificates() []*CertificateInfo {
|
|
|
|
|
+ cm.mu.RLock()
|
|
|
|
|
+ defer cm.mu.RUnlock()
|
|
|
|
|
+
|
|
|
|
|
+ var result []*CertificateInfo
|
|
|
|
|
+ for _, c := range cm.certs {
|
|
|
|
|
+ copyItem := *c
|
|
|
|
|
+ result = append(result, ©Item)
|
|
|
|
|
+ }
|
|
|
|
|
+ return result
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// GetCertificate returns metadata for a certificate by name.
|
|
|
|
|
+func (cm *CertManager) GetCertificate(name string) (*CertificateInfo, error) {
|
|
|
|
|
+ cm.mu.RLock()
|
|
|
|
|
+ defer cm.mu.RUnlock()
|
|
|
|
|
+
|
|
|
|
|
+ c, ok := cm.certs[strings.ToLower(name)]
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ return nil, fmt.Errorf("certificate %q not found", name)
|
|
|
|
|
+ }
|
|
|
|
|
+ copyItem := *c
|
|
|
|
|
+ return ©Item, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// GetTLSCertificate loads a parsed tls.Certificate ready for TLS listeners.
|
|
|
|
|
+func (cm *CertManager) GetTLSCertificate(name string) (tls.Certificate, error) {
|
|
|
|
|
+ cm.mu.RLock()
|
|
|
|
|
+ c, ok := cm.certs[strings.ToLower(name)]
|
|
|
|
|
+ cm.mu.RUnlock()
|
|
|
|
|
+
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ return tls.Certificate{}, fmt.Errorf("certificate %q not found", name)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if _, err := os.Stat(c.CertPath); err != nil {
|
|
|
|
|
+ return tls.Certificate{}, fmt.Errorf("certificate file missing: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, err := os.Stat(c.KeyPath); err != nil {
|
|
|
|
|
+ return tls.Certificate{}, fmt.Errorf("private key file missing: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return tls.LoadX509KeyPair(c.CertPath, c.KeyPath)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ExportPEM returns the PEM encoded certificate and private key content.
|
|
|
|
|
+func (cm *CertManager) ExportPEM(name string) (certPEM string, keyPEM string, err error) {
|
|
|
|
|
+ cm.mu.RLock()
|
|
|
|
|
+ c, ok := cm.certs[strings.ToLower(name)]
|
|
|
|
|
+ cm.mu.RUnlock()
|
|
|
|
|
+
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ return "", "", fmt.Errorf("certificate %q not found", name)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ certBytes, err := os.ReadFile(c.CertPath)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return "", "", fmt.Errorf("failed to read cert file: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ keyBytes, err := os.ReadFile(c.KeyPath)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return "", "", fmt.Errorf("failed to read key file: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return string(certBytes), string(keyBytes), nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// DeleteCertificate removes a certificate, its files, and metadata.
|
|
|
|
|
+func (cm *CertManager) DeleteCertificate(name string) error {
|
|
|
|
|
+ cm.mu.Lock()
|
|
|
|
|
+ defer cm.mu.Unlock()
|
|
|
|
|
+
|
|
|
|
|
+ key := strings.ToLower(name)
|
|
|
|
|
+ c, ok := cm.certs[key]
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ return fmt.Errorf("certificate %q not found", name)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ _ = os.Remove(c.CertPath)
|
|
|
|
|
+ _ = os.Remove(c.KeyPath)
|
|
|
|
|
+ delete(cm.certs, key)
|
|
|
|
|
+
|
|
|
|
|
+ if err := cm.saveLocked(); err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ logging.Log("certs,info", "Certificate %s deleted", name)
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// CreateCA creates a new Self-Signed Certificate Authority (CA).
|
|
|
|
|
+func (cm *CertManager) CreateCA(req CreateCARequest) (*CertificateInfo, error) {
|
|
|
|
|
+ name := strings.TrimSpace(req.Name)
|
|
|
|
|
+ if name == "" {
|
|
|
|
|
+ return nil, errors.New("certificate name is required")
|
|
|
|
|
+ }
|
|
|
|
|
+ cn := strings.TrimSpace(req.CommonName)
|
|
|
|
|
+ if cn == "" {
|
|
|
|
|
+ cn = name
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ cm.mu.Lock()
|
|
|
|
|
+ defer cm.mu.Unlock()
|
|
|
|
|
+
|
|
|
|
|
+ if _, exists := cm.certs[strings.ToLower(name)]; exists {
|
|
|
|
|
+ return nil, fmt.Errorf("certificate %q already exists", name)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ days := req.Days
|
|
|
|
|
+ if days <= 0 {
|
|
|
|
|
+ days = 3650 // 10 years default for CA
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ keyType := req.KeyType
|
|
|
|
|
+ if keyType == "" {
|
|
|
|
|
+ keyType = KeyRSA2048
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ privKey, pubKey, err := generateKeyPair(keyType)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed generating key: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed generating serial: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ org := req.Organization
|
|
|
|
|
+ if org == "" {
|
|
|
|
|
+ org = "DebTik RouterOS Appliance"
|
|
|
|
|
+ }
|
|
|
|
|
+ country := req.Country
|
|
|
|
|
+ if country == "" {
|
|
|
|
|
+ country = "GR"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ now := time.Now().UTC()
|
|
|
|
|
+ template := x509.Certificate{
|
|
|
|
|
+ SerialNumber: serialNumber,
|
|
|
|
|
+ Subject: pkix.Name{
|
|
|
|
|
+ CommonName: cn,
|
|
|
|
|
+ Organization: []string{org},
|
|
|
|
|
+ Country: []string{country},
|
|
|
|
|
+ },
|
|
|
|
|
+ NotBefore: now.Add(-1 * time.Hour),
|
|
|
|
|
+ NotAfter: now.AddDate(0, 0, days),
|
|
|
|
|
+ IsCA: true,
|
|
|
|
|
+ KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
|
|
|
|
|
+ BasicConstraintsValid: true,
|
|
|
|
|
+ MaxPathLenZero: false,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, pubKey, privKey)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed creating CA certificate: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ certPath := filepath.Join(cm.dir, fmt.Sprintf("%s.crt", name))
|
|
|
|
|
+ keyPath := filepath.Join(cm.dir, fmt.Sprintf("%s.key", name))
|
|
|
|
|
+
|
|
|
|
|
+ if err := savePEM(certPath, "CERTIFICATE", certBytes); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := savePrivateKeyPEM(keyPath, privKey); err != nil {
|
|
|
|
|
+ _ = os.Remove(certPath)
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fp := sha256.Sum256(certBytes)
|
|
|
|
|
+ info := &CertificateInfo{
|
|
|
|
|
+ ID: fmt.Sprintf("cert-%d", time.Now().UnixNano()),
|
|
|
|
|
+ Name: name,
|
|
|
|
|
+ CommonName: cn,
|
|
|
|
|
+ IsCA: true,
|
|
|
|
|
+ Issuer: "Self-Signed CA",
|
|
|
|
|
+ KeyType: keyType,
|
|
|
|
|
+ ValidFrom: template.NotBefore,
|
|
|
|
|
+ ValidTo: template.NotAfter,
|
|
|
|
|
+ Fingerprint: formatFingerprint(fp[:]),
|
|
|
|
|
+ CertPath: certPath,
|
|
|
|
|
+ KeyPath: keyPath,
|
|
|
|
|
+ Type: TypeCA,
|
|
|
|
|
+ Comment: req.Comment,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ cm.certs[strings.ToLower(name)] = info
|
|
|
|
|
+ if err := cm.saveLocked(); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ logging.Log("certs,info", "Created new Certificate Authority: %s (CN: %s)", name, cn)
|
|
|
|
|
+ return info, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// CreateCertificate issues a server or client certificate signed by a CA or self-signed.
|
|
|
|
|
+func (cm *CertManager) CreateCertificate(req CreateCertRequest) (*CertificateInfo, error) {
|
|
|
|
|
+ name := strings.TrimSpace(req.Name)
|
|
|
|
|
+ if name == "" {
|
|
|
|
|
+ return nil, errors.New("certificate name is required")
|
|
|
|
|
+ }
|
|
|
|
|
+ cn := strings.TrimSpace(req.CommonName)
|
|
|
|
|
+ if cn == "" {
|
|
|
|
|
+ return nil, errors.New("common name is required")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ cm.mu.Lock()
|
|
|
|
|
+ defer cm.mu.Unlock()
|
|
|
|
|
+
|
|
|
|
|
+ if _, exists := cm.certs[strings.ToLower(name)]; exists {
|
|
|
|
|
+ return nil, fmt.Errorf("certificate %q already exists", name)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ days := req.Days
|
|
|
|
|
+ if days <= 0 {
|
|
|
|
|
+ days = 365 // 1 year default
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ keyType := req.KeyType
|
|
|
|
|
+ if keyType == "" {
|
|
|
|
|
+ keyType = KeyRSA2048
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ privKey, pubKey, err := generateKeyPair(keyType)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed generating key: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed generating serial: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ org := req.Organization
|
|
|
|
|
+ if org == "" {
|
|
|
|
|
+ org = "DebTik RouterOS Appliance"
|
|
|
|
|
+ }
|
|
|
|
|
+ country := req.Country
|
|
|
|
|
+ if country == "" {
|
|
|
|
|
+ country = "GR"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ now := time.Now().UTC()
|
|
|
|
|
+ template := x509.Certificate{
|
|
|
|
|
+ SerialNumber: serialNumber,
|
|
|
|
|
+ Subject: pkix.Name{
|
|
|
|
|
+ CommonName: cn,
|
|
|
|
|
+ Organization: []string{org},
|
|
|
|
|
+ Country: []string{country},
|
|
|
|
|
+ },
|
|
|
|
|
+ NotBefore: now.Add(-1 * time.Hour),
|
|
|
|
|
+ NotAfter: now.AddDate(0, 0, days),
|
|
|
|
|
+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
|
|
|
+ BasicConstraintsValid: true,
|
|
|
|
|
+ IsCA: false,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if req.IsServer {
|
|
|
|
|
+ template.ExtKeyUsage = append(template.ExtKeyUsage, x509.ExtKeyUsageServerAuth)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ template.ExtKeyUsage = append(template.ExtKeyUsage, x509.ExtKeyUsageClientAuth)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Process SANs (DNS names & IP addresses)
|
|
|
|
|
+ sansMap := make(map[string]bool)
|
|
|
|
|
+ if ip := net.ParseIP(cn); ip != nil {
|
|
|
|
|
+ template.IPAddresses = append(template.IPAddresses, ip)
|
|
|
|
|
+ sansMap[cn] = true
|
|
|
|
|
+ } else if strings.Contains(cn, ".") || cn != "" {
|
|
|
|
|
+ template.DNSNames = append(template.DNSNames, cn)
|
|
|
|
|
+ sansMap[cn] = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for _, s := range req.SANs {
|
|
|
|
|
+ s = strings.TrimSpace(s)
|
|
|
|
|
+ if s == "" || sansMap[s] {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ sansMap[s] = true
|
|
|
|
|
+ if ip := net.ParseIP(s); ip != nil {
|
|
|
|
|
+ template.IPAddresses = append(template.IPAddresses, ip)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ template.DNSNames = append(template.DNSNames, s)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var sansList []string
|
|
|
|
|
+ for s := range sansMap {
|
|
|
|
|
+ sansList = append(sansList, s)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var signerCert *x509.Certificate
|
|
|
|
|
+ var signerKey crypto.PrivateKey
|
|
|
|
|
+ issuerName := "Self-Signed"
|
|
|
|
|
+ var caPEMBytes []byte
|
|
|
|
|
+
|
|
|
|
|
+ if req.CAName != "" {
|
|
|
|
|
+ caInfo, ok := cm.certs[strings.ToLower(req.CAName)]
|
|
|
|
|
+ if !ok || !caInfo.IsCA {
|
|
|
|
|
+ return nil, fmt.Errorf("signing CA %q not found or is not a CA", req.CAName)
|
|
|
|
|
+ }
|
|
|
|
|
+ caCert, caKey, err := loadCertAndKey(caInfo.CertPath, caInfo.KeyPath)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed loading CA cert/key: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ signerCert = caCert
|
|
|
|
|
+ signerKey = caKey
|
|
|
|
|
+ issuerName = caInfo.Name
|
|
|
|
|
+ caPEMBytes, _ = os.ReadFile(caInfo.CertPath)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ signerCert = &template
|
|
|
|
|
+ signerKey = privKey
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ certBytes, err := x509.CreateCertificate(rand.Reader, &template, signerCert, pubKey, signerKey)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed creating certificate: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ certPath := filepath.Join(cm.dir, fmt.Sprintf("%s.crt", name))
|
|
|
|
|
+ keyPath := filepath.Join(cm.dir, fmt.Sprintf("%s.key", name))
|
|
|
|
|
+
|
|
|
|
|
+ // If signed by CA, build chain: [Cert] + [CA Cert]
|
|
|
|
|
+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes})
|
|
|
|
|
+ if len(caPEMBytes) > 0 {
|
|
|
|
|
+ certPEM = append(certPEM, caPEMBytes...)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if err := savePrivateKeyPEM(keyPath, privKey); err != nil {
|
|
|
|
|
+ _ = os.Remove(certPath)
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fp := sha256.Sum256(certBytes)
|
|
|
|
|
+ info := &CertificateInfo{
|
|
|
|
|
+ ID: fmt.Sprintf("cert-%d", time.Now().UnixNano()),
|
|
|
|
|
+ Name: name,
|
|
|
|
|
+ CommonName: cn,
|
|
|
|
|
+ SANs: sansList,
|
|
|
|
|
+ IsCA: false,
|
|
|
|
|
+ Issuer: issuerName,
|
|
|
|
|
+ KeyType: keyType,
|
|
|
|
|
+ ValidFrom: template.NotBefore,
|
|
|
|
|
+ ValidTo: template.NotAfter,
|
|
|
|
|
+ Fingerprint: formatFingerprint(fp[:]),
|
|
|
|
|
+ CertPath: certPath,
|
|
|
|
|
+ KeyPath: keyPath,
|
|
|
|
|
+ Type: TypeServer,
|
|
|
|
|
+ Comment: req.Comment,
|
|
|
|
|
+ }
|
|
|
|
|
+ if !req.IsServer {
|
|
|
|
|
+ info.Type = TypeClient
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ cm.certs[strings.ToLower(name)] = info
|
|
|
|
|
+ if err := cm.saveLocked(); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ logging.Log("certs,info", "Created new certificate %s (Issuer: %s, CN: %s)", name, issuerName, cn)
|
|
|
|
|
+ return info, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ImportCertificate imports user-provided PEM certificate and private key.
|
|
|
|
|
+func (cm *CertManager) ImportCertificate(req ImportCertRequest) (*CertificateInfo, error) {
|
|
|
|
|
+ name := strings.TrimSpace(req.Name)
|
|
|
|
|
+ if name == "" {
|
|
|
|
|
+ return nil, errors.New("certificate name is required")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ certBlock, _ := pem.Decode([]byte(req.CertPEM))
|
|
|
|
|
+ if certBlock == nil || certBlock.Type != "CERTIFICATE" {
|
|
|
|
|
+ return nil, errors.New("invalid or empty PEM certificate")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ parsedCert, err := x509.ParseCertificate(certBlock.Bytes)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Verify key matches certificate
|
|
|
|
|
+ _, err = tls.X509KeyPair([]byte(req.CertPEM), []byte(req.KeyPEM))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("private key does not match certificate: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ cm.mu.Lock()
|
|
|
|
|
+ defer cm.mu.Unlock()
|
|
|
|
|
+
|
|
|
|
|
+ if _, exists := cm.certs[strings.ToLower(name)]; exists {
|
|
|
|
|
+ return nil, fmt.Errorf("certificate %q already exists", name)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ certPath := filepath.Join(cm.dir, fmt.Sprintf("%s.crt", name))
|
|
|
|
|
+ keyPath := filepath.Join(cm.dir, fmt.Sprintf("%s.key", name))
|
|
|
|
|
+
|
|
|
|
|
+ if err := os.WriteFile(certPath, []byte(strings.TrimSpace(req.CertPEM)+"\n"), 0644); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := os.WriteFile(keyPath, []byte(strings.TrimSpace(req.KeyPEM)+"\n"), 0600); err != nil {
|
|
|
|
|
+ _ = os.Remove(certPath)
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var sans []string
|
|
|
|
|
+ for _, dns := range parsedCert.DNSNames {
|
|
|
|
|
+ sans = append(sans, dns)
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, ip := range parsedCert.IPAddresses {
|
|
|
|
|
+ sans = append(sans, ip.String())
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fp := sha256.Sum256(parsedCert.Raw)
|
|
|
|
|
+ cType := TypeServer
|
|
|
|
|
+ if parsedCert.IsCA {
|
|
|
|
|
+ cType = TypeCA
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ keyType := "Unknown"
|
|
|
|
|
+ switch parsedCert.PublicKeyAlgorithm {
|
|
|
|
|
+ case x509.RSA:
|
|
|
|
|
+ keyType = "RSA"
|
|
|
|
|
+ case x509.ECDSA:
|
|
|
|
|
+ keyType = "ECDSA"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ info := &CertificateInfo{
|
|
|
|
|
+ ID: fmt.Sprintf("cert-%d", time.Now().UnixNano()),
|
|
|
|
|
+ Name: name,
|
|
|
|
|
+ CommonName: parsedCert.Subject.CommonName,
|
|
|
|
|
+ SANs: sans,
|
|
|
|
|
+ IsCA: parsedCert.IsCA,
|
|
|
|
|
+ Issuer: parsedCert.Issuer.CommonName,
|
|
|
|
|
+ KeyType: keyType,
|
|
|
|
|
+ ValidFrom: parsedCert.NotBefore,
|
|
|
|
|
+ ValidTo: parsedCert.NotAfter,
|
|
|
|
|
+ Fingerprint: formatFingerprint(fp[:]),
|
|
|
|
|
+ CertPath: certPath,
|
|
|
|
|
+ KeyPath: keyPath,
|
|
|
|
|
+ Type: cType,
|
|
|
|
|
+ Comment: req.Comment,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ cm.certs[strings.ToLower(name)] = info
|
|
|
|
|
+ if err := cm.saveLocked(); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ logging.Log("certs,info", "Imported certificate: %s (CN: %s)", name, info.CommonName)
|
|
|
|
|
+ return info, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// IssueLetsEncrypt requests and issues a certificate from Let's Encrypt via ACME HTTP-01 challenge.
|
|
|
|
|
+func (cm *CertManager) IssueLetsEncrypt(ctx context.Context, req LetsEncryptRequest) (*CertificateInfo, error) {
|
|
|
|
|
+ name := strings.TrimSpace(req.Name)
|
|
|
|
|
+ domain := strings.TrimSpace(req.Domain)
|
|
|
|
|
+ email := strings.TrimSpace(req.Email)
|
|
|
|
|
+
|
|
|
|
|
+ if domain == "" {
|
|
|
|
|
+ return nil, errors.New("domain is required for Let's Encrypt")
|
|
|
|
|
+ }
|
|
|
|
|
+ if name == "" {
|
|
|
|
|
+ name = domain
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ cm.mu.Lock()
|
|
|
|
|
+ if _, exists := cm.certs[strings.ToLower(name)]; exists {
|
|
|
|
|
+ cm.mu.Unlock()
|
|
|
|
|
+ return nil, fmt.Errorf("certificate %q already exists", name)
|
|
|
|
|
+ }
|
|
|
|
|
+ cm.mu.Unlock()
|
|
|
|
|
+
|
|
|
|
|
+ logging.Log("certs,info", "Starting Let's Encrypt order for domain %s (Staging=%t)", domain, req.Staging)
|
|
|
|
|
+
|
|
|
|
|
+ // Generate ACME account key
|
|
|
|
|
+ accountKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed generating ACME account key: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ client := &acme.Client{
|
|
|
|
|
+ Key: accountKey,
|
|
|
|
|
+ }
|
|
|
|
|
+ if req.Staging {
|
|
|
|
|
+ client.DirectoryURL = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
|
|
|
|
+ } else {
|
|
|
|
|
+ client.DirectoryURL = acme.LetsEncryptURL
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Register account
|
|
|
|
|
+ account := &acme.Account{}
|
|
|
|
|
+ if email != "" {
|
|
|
|
|
+ account.Contact = []string{"mailto:" + email}
|
|
|
|
|
+ }
|
|
|
|
|
+ _, err = client.Register(ctx, account, acme.AcceptTOS)
|
|
|
|
|
+ if err != nil && !strings.Contains(err.Error(), "already exists") {
|
|
|
|
|
+ return nil, fmt.Errorf("failed registering ACME account: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Create order for domain
|
|
|
|
|
+ order, err := client.AuthorizeOrder(ctx, acme.DomainIDs(domain))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed requesting order for %s: %w", domain, err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Complete authorization for each authorization URL
|
|
|
|
|
+ for _, authzURL := range order.AuthzURLs {
|
|
|
|
|
+ authz, err := client.GetAuthorization(ctx, authzURL)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed getting authorization %s: %w", authzURL, err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if authz.Status == acme.StatusValid {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var challenge *acme.Challenge
|
|
|
|
|
+ for _, ch := range authz.Challenges {
|
|
|
|
|
+ if ch.Type == "http-01" {
|
|
|
|
|
+ challenge = ch
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if challenge == nil {
|
|
|
|
|
+ return nil, fmt.Errorf("no http-01 challenge available for domain %s", domain)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ keyAuth, err := client.HTTP01ChallengeResponse(challenge.Token)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed generating challenge response: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Register in our web server challenge map
|
|
|
|
|
+ cm.RegisterHTTP01Challenge(challenge.Token, keyAuth)
|
|
|
|
|
+ defer cm.UnregisterHTTP01Challenge(challenge.Token)
|
|
|
|
|
+
|
|
|
|
|
+ // Inform ACME server challenge is ready
|
|
|
|
|
+ if _, err := client.Accept(ctx, challenge); err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed accepting challenge: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Wait for authorization to succeed
|
|
|
|
|
+ if _, err := client.WaitAuthorization(ctx, challenge.URI); err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("ACME authorization failed for %s: %w", domain, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Generate certificate private key
|
|
|
|
|
+ certPrivKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed generating certificate key: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ csrTemplate := x509.CertificateRequest{
|
|
|
|
|
+ Subject: pkix.Name{CommonName: domain},
|
|
|
|
|
+ DNSNames: []string{domain},
|
|
|
|
|
+ }
|
|
|
|
|
+ csr, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, certPrivKey)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed creating CSR: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Finalize order
|
|
|
|
|
+ certDerList, certURL, err := client.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed finalizing ACME order: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(certDerList) == 0 && certURL != "" {
|
|
|
|
|
+ certDerList, err = client.FetchCert(ctx, certURL, true)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("failed fetching issued cert: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Build certificate chain in PEM format
|
|
|
|
|
+ var certPEM []byte
|
|
|
|
|
+ var firstCert *x509.Certificate
|
|
|
|
|
+ for idx, der := range certDerList {
|
|
|
|
|
+ certPEM = append(certPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...)
|
|
|
|
|
+ if idx == 0 {
|
|
|
|
|
+ firstCert, _ = x509.ParseCertificate(der)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ certPath := filepath.Join(cm.dir, fmt.Sprintf("%s.crt", name))
|
|
|
|
|
+ keyPath := filepath.Join(cm.dir, fmt.Sprintf("%s.key", name))
|
|
|
|
|
+
|
|
|
|
|
+ if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := savePrivateKeyPEM(keyPath, certPrivKey); err != nil {
|
|
|
|
|
+ _ = os.Remove(certPath)
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ cm.mu.Lock()
|
|
|
|
|
+ defer cm.mu.Unlock()
|
|
|
|
|
+
|
|
|
|
|
+ var fp [32]byte
|
|
|
|
|
+ validFrom := time.Now()
|
|
|
|
|
+ validTo := time.Now().AddDate(0, 0, 90)
|
|
|
|
|
+ if firstCert != nil {
|
|
|
|
|
+ fp = sha256.Sum256(firstCert.Raw)
|
|
|
|
|
+ validFrom = firstCert.NotBefore
|
|
|
|
|
+ validTo = firstCert.NotAfter
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ issuer := "Let's Encrypt"
|
|
|
|
|
+ if req.Staging {
|
|
|
|
|
+ issuer = "Let's Encrypt (Staging)"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ info := &CertificateInfo{
|
|
|
|
|
+ ID: fmt.Sprintf("cert-%d", time.Now().UnixNano()),
|
|
|
|
|
+ Name: name,
|
|
|
|
|
+ CommonName: domain,
|
|
|
|
|
+ SANs: []string{domain},
|
|
|
|
|
+ IsCA: false,
|
|
|
|
|
+ Issuer: issuer,
|
|
|
|
|
+ KeyType: KeyRSA2048,
|
|
|
|
|
+ ValidFrom: validFrom,
|
|
|
|
|
+ ValidTo: validTo,
|
|
|
|
|
+ Fingerprint: formatFingerprint(fp[:]),
|
|
|
|
|
+ CertPath: certPath,
|
|
|
|
|
+ KeyPath: keyPath,
|
|
|
|
|
+ Type: TypeACME,
|
|
|
|
|
+ Comment: req.Comment,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ cm.certs[strings.ToLower(name)] = info
|
|
|
|
|
+ if err := cm.saveLocked(); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ logging.Log("certs,info", "Successfully issued Let's Encrypt certificate for %s", domain)
|
|
|
|
|
+ return info, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Helpers
|
|
|
|
|
+func generateKeyPair(keyType string) (crypto.PrivateKey, crypto.PublicKey, error) {
|
|
|
|
|
+ switch strings.ToUpper(keyType) {
|
|
|
|
|
+ case KeyRSA4096:
|
|
|
|
|
+ priv, err := rsa.GenerateKey(rand.Reader, 4096)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ return priv, &priv.PublicKey, nil
|
|
|
|
|
+ case KeyECDSAP256:
|
|
|
|
|
+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ return priv, &priv.PublicKey, nil
|
|
|
|
|
+ default: // KeyRSA2048
|
|
|
|
|
+ priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ return priv, &priv.PublicKey, nil
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func savePEM(path, blockType string, data []byte) error {
|
|
|
|
|
+ block := &pem.Block{Type: blockType, Bytes: data}
|
|
|
|
|
+ return os.WriteFile(path, pem.EncodeToMemory(block), 0644)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func savePrivateKeyPEM(path string, key crypto.PrivateKey) error {
|
|
|
|
|
+ var block *pem.Block
|
|
|
|
|
+ switch k := key.(type) {
|
|
|
|
|
+ case *rsa.PrivateKey:
|
|
|
|
|
+ block = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)}
|
|
|
|
|
+ case *ecdsa.PrivateKey:
|
|
|
|
|
+ b, err := x509.MarshalECPrivateKey(k)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ block = &pem.Block{Type: "EC PRIVATE KEY", Bytes: b}
|
|
|
|
|
+ default:
|
|
|
|
|
+ b, err := x509.MarshalPKCS8PrivateKey(key)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ block = &pem.Block{Type: "PRIVATE KEY", Bytes: b}
|
|
|
|
|
+ }
|
|
|
|
|
+ return os.WriteFile(path, pem.EncodeToMemory(block), 0600)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func loadCertAndKey(certPath, keyPath string) (*x509.Certificate, crypto.PrivateKey, error) {
|
|
|
|
|
+ certPEM, err := os.ReadFile(certPath)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ block, _ := pem.Decode(certPEM)
|
|
|
|
|
+ if block == nil {
|
|
|
|
|
+ return nil, nil, errors.New("failed decoding certificate PEM")
|
|
|
|
|
+ }
|
|
|
|
|
+ cert, err := x509.ParseCertificate(block.Bytes)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ keyPEM, err := os.ReadFile(keyPath)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ keyBlock, _ := pem.Decode(keyPEM)
|
|
|
|
|
+ if keyBlock == nil {
|
|
|
|
|
+ return nil, nil, errors.New("failed decoding private key PEM")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if k, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes); err == nil {
|
|
|
|
|
+ return cert, k, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ if k, err := x509.ParseECPrivateKey(keyBlock.Bytes); err == nil {
|
|
|
|
|
+ return cert, k, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ if k, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes); err == nil {
|
|
|
|
|
+ return cert, k, nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return nil, nil, errors.New("unrecognized private key format")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func formatFingerprint(b []byte) string {
|
|
|
|
|
+ hexStr := hex.EncodeToString(b)
|
|
|
|
|
+ var parts []string
|
|
|
|
|
+ for i := 0; i < len(hexStr); i += 2 {
|
|
|
|
|
+ parts = append(parts, strings.ToUpper(hexStr[i:i+2]))
|
|
|
|
|
+ }
|
|
|
|
|
+ return strings.Join(parts, ":")
|
|
|
|
|
+}
|