From 9c4f11d7c78f77b2a87fdbce878fb3c802add1a7 Mon Sep 17 00:00:00 2001 From: lite Date: Tue, 19 May 2026 16:03:28 -0400 Subject: [PATCH] refactor: accept certificates as base64, remove external dependencies - Replace file path with base64 content in register endpoint - Use native Go pkcs12 for certificate validation and loading - Remove PowerShell script dependency (validator.go) - Remove Python script dependency (client.go) - Remove hardcoded Windows paths - Cross-platform: works on Linux, macOS, Windows without external tools - Update documentation (api.md, seguridad.md, prerequisites.md) --- api/handler.go | 8 ++-- documentacion/PREREQUISITES.md | 28 +------------- documentacion/api.md | 6 +-- documentacion/seguridad.md | 29 ++++++++------- go.mod | 2 + go.sum | 2 + internal/cert/storage.go | 28 +++++--------- internal/cert/validator.go | 67 ++++++++++++++-------------------- verifactu/client.go | 48 ++++++++++++------------ 9 files changed, 90 insertions(+), 128 deletions(-) diff --git a/api/handler.go b/api/handler.go index 7f6d631..a048d88 100644 --- a/api/handler.go +++ b/api/handler.go @@ -17,7 +17,7 @@ import ( type RegisterInput struct { CertName string `json:"cert_name"` - CertPath string `json:"cert_path"` + CertFileBase64 string `json:"cert_file"` PasswordEncrypted string `json:"password_encrypted"` } @@ -72,7 +72,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) { return } - if input.CertName == "" || input.CertPath == "" || input.PasswordEncrypted == "" { + if input.CertName == "" || input.CertFileBase64 == "" || input.PasswordEncrypted == "" { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"success":false,"error":"missing_fields"}`)) return @@ -94,7 +94,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) { plainPass := string(plainPassBytes) - validation := cert.ValidateP12(input.CertPath, plainPass) + validation := cert.ValidateP12(input.CertFileBase64, plainPass) if !validation.Valid { resp, _ := json.Marshal(map[string]interface{}{ "success": false, @@ -106,7 +106,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) { return } - tempPath, err := h.cert.StoreTemp(input.CertName, input.CertPath, plainPass) + tempPath, err := h.cert.StoreFromBase64(input.CertName, input.CertFileBase64) if err != nil { h.cert.DeleteTemp(tempPath) w.Header().Set("Content-Type", "application/json") diff --git a/documentacion/PREREQUISITES.md b/documentacion/PREREQUISITES.md index 9662524..382c82b 100644 --- a/documentacion/PREREQUISITES.md +++ b/documentacion/PREREQUISITES.md @@ -11,29 +11,9 @@ go version ``` -### Python 3 - -Required for test scripts and certificate conversion utilities. - -- **Version:** 3.8 or higher -- **Verify installation:** - ```bash - python --version - ``` - -### OpenSSL (optional but recommended) - -Used as an alternative to the Python conversion script for `.p12` to `.pem` certificates. - -- **Linux:** `sudo apt install openssl` / `sudo pacman -S openssl` -- **macOS:** `brew install openssl` -- **Windows:** https://slproweb.com/products/Win32OpenSSL.html -- **Verify installation:** - ```bash - openssl version - ``` - +No se requieren Python, OpenSSL ni scripts externos. Todo el procesamiento de certificados (.p12/.pfx) es nativo en Go. +--- ## Project Setup @@ -161,7 +141,3 @@ The API falls back to defaults but logs a warning. Create the file as described ### `403 Forbidden` from AEAT The certificate is not authorized in the AEAT testing environment. Contact AEAT or verify your FNMT certificate is enabled for VeriFactu. - -### Hardcoded Windows paths - -`verifactu/client.go` and `internal/cert/validator.go` contain hardcoded Windows paths (`C:\Users\jmest\...`). Update them to your environment or use the `cert_file` config option instead. diff --git a/documentacion/api.md b/documentacion/api.md index bb3fab2..16f9a14 100644 --- a/documentacion/api.md +++ b/documentacion/api.md @@ -32,13 +32,13 @@ Obtiene la clave pública RSA para cifrar contraseñas. ``` POST /api/v1/auth/register ``` -Registra y valida un certificado digital. +Registra y valida un certificado digital. El certificado se envía como base64 en el body (no como ruta de fichero). **Request:** ```json { - "cert_name": "mi_certificado", - "cert_path": "C:/ruta/al/certificado.p12", + "cert_name": "mi-certificado", + "cert_file": "BASE64_CONTENT_OF_P12_FILE", "password_encrypted": "base64_encoded_password" } ``` diff --git a/documentacion/seguridad.md b/documentacion/seguridad.md index 2386607..701396a 100644 --- a/documentacion/seguridad.md +++ b/documentacion/seguridad.md @@ -10,27 +10,28 @@ ### Validación -La API valida: -1. **Existencia del archivo** +La API valida nativamente (sin scripts externos): +1. **Formato PKCS#12 válido** 2. **Contraseña correcta** -3. **Fechas de validez** (no expirado, no futuror) +3. **Fechas de validez** (no expirado, no futuro) 4. **Días hasta expiración** ### Almacenamiento -``` -Flujo temporal: -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Original │───▶│ /tmp/ │───▶│ /certs/ │ -│ (user) │ │ (validado)│ │ (permanente) -└─────────────┘ └─────────────┘ └─────────────┘ +El certificado se envía como base64 en el JSON de registro: + +```json +{ + "cert_name": "mi-cert", + "cert_file": "BASE64_P12_CONTENT", + "password_encrypted": "..." +} ``` -1. El usuario envía el certificado original -2. Se guarda temporalmente en `data/certs/tmp/` -3. Se valida -4. Si es válido, se mueve a `data/certs/` -5. Si falla, se borra el temporal +1. El cliente envía el certificado como base64 +2. Se valida el PKCS#12 nativamente en Go +3. Se guarda en `data/certs/.p12` +4. Se genera un token de sesión ## Cifrado RSA diff --git a/go.mod b/go.mod index 64019be..600084a 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,5 @@ module VerifactuMidAPI go 1.26 require gopkg.in/yaml.v3 v3.0.1 + +require golang.org/x/crypto v0.51.0 // indirect diff --git a/go.sum b/go.sum index 4bc0337..c7b1264 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cert/storage.go b/internal/cert/storage.go index 5fbc2d2..367aa0b 100644 --- a/internal/cert/storage.go +++ b/internal/cert/storage.go @@ -3,6 +3,7 @@ package cert import ( "crypto/rand" "crypto/sha256" + "encoding/base64" "encoding/hex" "fmt" "os" @@ -19,7 +20,6 @@ type Storage struct { type Certificate struct { ID string `json:"id"` - OriginalPath string `json:"original_path"` StoredPath string `json:"stored_path"` Password string `json:"password,omitempty"` Token string `json:"token,omitempty"` @@ -46,8 +46,6 @@ func (s *Storage) Init() error { if err := os.MkdirAll(s.basePath, 0700); err != nil { return fmt.Errorf("creating cert storage directory: %w", err) } - - // Load existing certificates from disk return s.loadFromDisk() } @@ -80,23 +78,19 @@ func (s *Storage) loadFromDisk() error { return nil } -func (s *Storage) StoreTemp(id, origPath, password string) (string, error) { - tmpPath := filepath.Join(s.basePath, "tmp") - if err := os.MkdirAll(tmpPath, 0700); err != nil { - return "", fmt.Errorf("creating tmp directory: %w", err) +func (s *Storage) StoreFromBase64(id, base64Content string) (string, error) { + if err := os.MkdirAll(s.basePath, 0700); err != nil { + return "", fmt.Errorf("creating cert directory: %w", err) } - ext := filepath.Ext(origPath) - storedFilename := fmt.Sprintf("%s%s", id, ext) - storedPath := filepath.Join(tmpPath, storedFilename) - - data, err := os.ReadFile(origPath) + der, err := base64.StdEncoding.DecodeString(base64Content) if err != nil { - return "", fmt.Errorf("reading certificate file: %w", err) + return "", fmt.Errorf("invalid base64: %w", err) } - if err := os.WriteFile(storedPath, data, 0600); err != nil { - return "", fmt.Errorf("storing certificate: %w", err) + storedPath := filepath.Join(s.basePath, id+".p12") + if err := os.WriteFile(storedPath, der, 0600); err != nil { + return "", fmt.Errorf("writing certificate: %w", err) } return storedPath, nil @@ -106,9 +100,7 @@ func (s *Storage) MoveToPerm(id, tempPath, password string) (string, error) { s.mu.Lock() defer s.mu.Unlock() - ext := filepath.Ext(tempPath) - storedFilename := fmt.Sprintf("%s%s", id, ext) - storedPath := filepath.Join(s.basePath, storedFilename) + storedPath := filepath.Join(s.basePath, id+".p12") if _, err := os.Stat(storedPath); err == nil { if err := os.Remove(storedPath); err != nil { diff --git a/internal/cert/validator.go b/internal/cert/validator.go index 910bb05..2544ce7 100644 --- a/internal/cert/validator.go +++ b/internal/cert/validator.go @@ -1,10 +1,10 @@ package cert import ( - "os" - "os/exec" - "strconv" - "strings" + "encoding/base64" + "time" + + "golang.org/x/crypto/pkcs12" ) type ValidationResult struct { @@ -26,70 +26,57 @@ type CertInfo struct { const WarningDaysThreshold = 30 -func ValidateP12(filePath, password string) *ValidationResult { +func ValidateP12(base64Content, password string) *ValidationResult { result := &ValidationResult{Valid: true} - if _, err := os.Stat(filePath); os.IsNotExist(err) { + der, err := base64.StdEncoding.DecodeString(base64Content) + if err != nil { result.Valid = false - result.Error = "file_not_found" + result.Error = "invalid_base64" return result } - scriptPath := "C:\\Users\\jmest\\GolandProjects\\VerifactuMidAPI\\validate_cert.ps1" - cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", scriptPath, "-p12Path", filePath, "-pwd", password) - out, err := cmd.CombinedOutput() - output := strings.TrimSpace(string(out)) - - if err != nil || output == "" { + _, cert, err := pkcs12.Decode(der, password) + if err != nil { result.Valid = false result.Error = "invalid_password_or_format" return result } - if strings.HasPrefix(output, "NOT_FOUND") { + if cert == nil { result.Valid = false - result.Error = "file_not_found" + result.Error = "no_certificate_found" return result } - if strings.HasPrefix(output, "INVALID") { - result.Valid = false - result.Error = "invalid_password_or_format" - return result - } - - if strings.HasPrefix(output, "NOT_YET_VALID") { + now := time.Now() + if now.Before(cert.NotBefore) { result.Valid = false result.Error = "certificate_not_yet_valid" return result } - if strings.HasPrefix(output, "EXPIRED") { + if now.After(cert.NotAfter) { result.Valid = false result.Error = "certificate_expired" result.CertInfo = &CertInfo{Expired: true} return result } - if strings.HasPrefix(output, "OK:") { - daysStr := strings.TrimPrefix(output, "OK:") - days, _ := strconv.Atoi(daysStr) + daysUntilExpiry := int(cert.NotAfter.Sub(now).Hours() / 24) - result.CertInfo = &CertInfo{ - Subject: "Certificate", - Issuer: "Certificate", - DaysUntilExpiry: days, - } - - if days <= WarningDaysThreshold { - result.Warnings = append(result.Warnings, "certificate_expiring_soon") - result.CertInfo.ExpiringSoon = true - } - - return result + result.CertInfo = &CertInfo{ + Subject: cert.Subject.String(), + Issuer: cert.Issuer.String(), + NotBefore: cert.NotBefore.Format("2006-01-02"), + NotAfter: cert.NotAfter.Format("2006-01-02"), + DaysUntilExpiry: daysUntilExpiry, + } + + if daysUntilExpiry <= WarningDaysThreshold { + result.Warnings = append(result.Warnings, "certificate_expiring_soon") + result.CertInfo.ExpiringSoon = true } - result.Valid = false - result.Error = "invalid_password_or_format" return result } diff --git a/verifactu/client.go b/verifactu/client.go index 2b4e638..911e93d 100644 --- a/verifactu/client.go +++ b/verifactu/client.go @@ -9,9 +9,9 @@ import ( "log" "net/http" "os" - "os/exec" - "path/filepath" "time" + + "golang.org/x/crypto/pkcs12" ) type Client struct { @@ -54,35 +54,37 @@ func NewClient(cfg ClientConfig) (*Client, error) { } func LoadCertificate(certPath, password string) (*tls.Certificate, error) { - dir := filepath.Dir(certPath) - keyPath := filepath.Join(dir, "cert_key.pem") - certPath2 := filepath.Join(dir, "cert_cert.pem") - - pyScript := "C:\\Users\\jmest\\GolandProjects\\VerifactuMidAPI\\convert_cert.py" - - cmd := exec.Command("python", pyScript, certPath, password, keyPath, certPath2) - out, err := cmd.CombinedOutput() - log.Printf("cert convert: out=%s err=%v", string(out), err) + der, err := os.ReadFile(certPath) if err != nil { - return nil, fmt.Errorf("converting: %w - %s", err, string(out)) + return nil, fmt.Errorf("reading cert file: %w", err) } - certData, err := os.ReadFile(certPath2) + return parseP12(der, password) +} + +func LoadCertificateFromBytes(der []byte, password string) (*tls.Certificate, error) { + return parseP12(der, password) +} + +func parseP12(der []byte, password string) (*tls.Certificate, error) { + key, cert, err := pkcs12.Decode(der, password) if err != nil { - return nil, fmt.Errorf("reading cert: %w", err) - } - keyData, err := os.ReadFile(keyPath) - if err != nil { - return nil, fmt.Errorf("reading key: %w", err) + return nil, fmt.Errorf("decoding PKCS#12: %w", err) } - cert, err := tls.X509KeyPair(certData, keyData) - if err != nil { - return nil, fmt.Errorf("parsing: %w", err) + if cert == nil { + return nil, fmt.Errorf("no certificate found in PKCS#12") } - log.Printf("cert loaded: has private key=%v", cert.PrivateKey != nil) - return &cert, nil + tlsCert := &tls.Certificate{ + Certificate: [][]byte{cert.Raw}, + PrivateKey: key, + Leaf: cert, + } + + log.Printf("cert loaded: subject=%s has private key=%v", cert.Subject, key != nil) + + return tlsCert, nil } func (c *Client) SetCertificate(certPath, password string) error {