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)
This commit is contained in:
lite 2026-05-19 16:03:28 -04:00
parent b99d09789b
commit 9c4f11d7c7
9 changed files with 90 additions and 128 deletions

View File

@ -17,7 +17,7 @@ import (
type RegisterInput struct { type RegisterInput struct {
CertName string `json:"cert_name"` CertName string `json:"cert_name"`
CertPath string `json:"cert_path"` CertFileBase64 string `json:"cert_file"`
PasswordEncrypted string `json:"password_encrypted"` PasswordEncrypted string `json:"password_encrypted"`
} }
@ -72,7 +72,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
return return
} }
if input.CertName == "" || input.CertPath == "" || input.PasswordEncrypted == "" { if input.CertName == "" || input.CertFileBase64 == "" || input.PasswordEncrypted == "" {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"missing_fields"}`)) w.Write([]byte(`{"success":false,"error":"missing_fields"}`))
return return
@ -94,7 +94,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
plainPass := string(plainPassBytes) plainPass := string(plainPassBytes)
validation := cert.ValidateP12(input.CertPath, plainPass) validation := cert.ValidateP12(input.CertFileBase64, plainPass)
if !validation.Valid { if !validation.Valid {
resp, _ := json.Marshal(map[string]interface{}{ resp, _ := json.Marshal(map[string]interface{}{
"success": false, "success": false,
@ -106,7 +106,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
return return
} }
tempPath, err := h.cert.StoreTemp(input.CertName, input.CertPath, plainPass) tempPath, err := h.cert.StoreFromBase64(input.CertName, input.CertFileBase64)
if err != nil { if err != nil {
h.cert.DeleteTemp(tempPath) h.cert.DeleteTemp(tempPath)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

View File

@ -11,29 +11,9 @@
go version go version
``` ```
### Python 3 No se requieren Python, OpenSSL ni scripts externos. Todo el procesamiento de certificados (.p12/.pfx) es nativo en Go.
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
```
---
## Project Setup ## 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 ### `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. 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.

View File

@ -32,13 +32,13 @@ Obtiene la clave pública RSA para cifrar contraseñas.
``` ```
POST /api/v1/auth/register 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:** **Request:**
```json ```json
{ {
"cert_name": "mi_certificado", "cert_name": "mi-certificado",
"cert_path": "C:/ruta/al/certificado.p12", "cert_file": "BASE64_CONTENT_OF_P12_FILE",
"password_encrypted": "base64_encoded_password" "password_encrypted": "base64_encoded_password"
} }
``` ```

View File

@ -10,27 +10,28 @@
### Validación ### Validación
La API valida: La API valida nativamente (sin scripts externos):
1. **Existencia del archivo** 1. **Formato PKCS#12 válido**
2. **Contraseña correcta** 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** 4. **Días hasta expiración**
### Almacenamiento ### Almacenamiento
``` El certificado se envía como base64 en el JSON de registro:
Flujo temporal:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ```json
│ Original │───▶│ /tmp/ │───▶│ /certs/ │ {
│ (user) │ │ (validado)│ │ (permanente) "cert_name": "mi-cert",
└─────────────┘ └─────────────┘ └─────────────┘ "cert_file": "BASE64_P12_CONTENT",
"password_encrypted": "..."
}
``` ```
1. El usuario envía el certificado original 1. El cliente envía el certificado como base64
2. Se guarda temporalmente en `data/certs/tmp/` 2. Se valida el PKCS#12 nativamente en Go
3. Se valida 3. Se guarda en `data/certs/<cert_name>.p12`
4. Si es válido, se mueve a `data/certs/` 4. Se genera un token de sesión
5. Si falla, se borra el temporal
## Cifrado RSA ## Cifrado RSA

2
go.mod
View File

@ -3,3 +3,5 @@ module VerifactuMidAPI
go 1.26 go 1.26
require gopkg.in/yaml.v3 v3.0.1 require gopkg.in/yaml.v3 v3.0.1
require golang.org/x/crypto v0.51.0 // indirect

2
go.sum
View File

@ -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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -3,6 +3,7 @@ package cert
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"os" "os"
@ -19,7 +20,6 @@ type Storage struct {
type Certificate struct { type Certificate struct {
ID string `json:"id"` ID string `json:"id"`
OriginalPath string `json:"original_path"`
StoredPath string `json:"stored_path"` StoredPath string `json:"stored_path"`
Password string `json:"password,omitempty"` Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"` Token string `json:"token,omitempty"`
@ -46,8 +46,6 @@ func (s *Storage) Init() error {
if err := os.MkdirAll(s.basePath, 0700); err != nil { if err := os.MkdirAll(s.basePath, 0700); err != nil {
return fmt.Errorf("creating cert storage directory: %w", err) return fmt.Errorf("creating cert storage directory: %w", err)
} }
// Load existing certificates from disk
return s.loadFromDisk() return s.loadFromDisk()
} }
@ -80,23 +78,19 @@ func (s *Storage) loadFromDisk() error {
return nil return nil
} }
func (s *Storage) StoreTemp(id, origPath, password string) (string, error) { func (s *Storage) StoreFromBase64(id, base64Content string) (string, error) {
tmpPath := filepath.Join(s.basePath, "tmp") if err := os.MkdirAll(s.basePath, 0700); err != nil {
if err := os.MkdirAll(tmpPath, 0700); err != nil { return "", fmt.Errorf("creating cert directory: %w", err)
return "", fmt.Errorf("creating tmp directory: %w", err)
} }
ext := filepath.Ext(origPath) der, err := base64.StdEncoding.DecodeString(base64Content)
storedFilename := fmt.Sprintf("%s%s", id, ext)
storedPath := filepath.Join(tmpPath, storedFilename)
data, err := os.ReadFile(origPath)
if err != nil { 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 { storedPath := filepath.Join(s.basePath, id+".p12")
return "", fmt.Errorf("storing certificate: %w", err) if err := os.WriteFile(storedPath, der, 0600); err != nil {
return "", fmt.Errorf("writing certificate: %w", err)
} }
return storedPath, nil return storedPath, nil
@ -106,9 +100,7 @@ func (s *Storage) MoveToPerm(id, tempPath, password string) (string, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
ext := filepath.Ext(tempPath) storedPath := filepath.Join(s.basePath, id+".p12")
storedFilename := fmt.Sprintf("%s%s", id, ext)
storedPath := filepath.Join(s.basePath, storedFilename)
if _, err := os.Stat(storedPath); err == nil { if _, err := os.Stat(storedPath); err == nil {
if err := os.Remove(storedPath); err != nil { if err := os.Remove(storedPath); err != nil {

View File

@ -1,10 +1,10 @@
package cert package cert
import ( import (
"os" "encoding/base64"
"os/exec" "time"
"strconv"
"strings" "golang.org/x/crypto/pkcs12"
) )
type ValidationResult struct { type ValidationResult struct {
@ -26,70 +26,57 @@ type CertInfo struct {
const WarningDaysThreshold = 30 const WarningDaysThreshold = 30
func ValidateP12(filePath, password string) *ValidationResult { func ValidateP12(base64Content, password string) *ValidationResult {
result := &ValidationResult{Valid: true} 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.Valid = false
result.Error = "file_not_found" result.Error = "invalid_base64"
return result return result
} }
scriptPath := "C:\\Users\\jmest\\GolandProjects\\VerifactuMidAPI\\validate_cert.ps1" _, cert, err := pkcs12.Decode(der, password)
cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", scriptPath, "-p12Path", filePath, "-pwd", password) if err != nil {
out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out))
if err != nil || output == "" {
result.Valid = false result.Valid = false
result.Error = "invalid_password_or_format" result.Error = "invalid_password_or_format"
return result return result
} }
if strings.HasPrefix(output, "NOT_FOUND") { if cert == nil {
result.Valid = false result.Valid = false
result.Error = "file_not_found" result.Error = "no_certificate_found"
return result return result
} }
if strings.HasPrefix(output, "INVALID") { now := time.Now()
result.Valid = false if now.Before(cert.NotBefore) {
result.Error = "invalid_password_or_format"
return result
}
if strings.HasPrefix(output, "NOT_YET_VALID") {
result.Valid = false result.Valid = false
result.Error = "certificate_not_yet_valid" result.Error = "certificate_not_yet_valid"
return result return result
} }
if strings.HasPrefix(output, "EXPIRED") { if now.After(cert.NotAfter) {
result.Valid = false result.Valid = false
result.Error = "certificate_expired" result.Error = "certificate_expired"
result.CertInfo = &CertInfo{Expired: true} result.CertInfo = &CertInfo{Expired: true}
return result return result
} }
if strings.HasPrefix(output, "OK:") { daysUntilExpiry := int(cert.NotAfter.Sub(now).Hours() / 24)
daysStr := strings.TrimPrefix(output, "OK:")
days, _ := strconv.Atoi(daysStr)
result.CertInfo = &CertInfo{ result.CertInfo = &CertInfo{
Subject: "Certificate", Subject: cert.Subject.String(),
Issuer: "Certificate", Issuer: cert.Issuer.String(),
DaysUntilExpiry: days, NotBefore: cert.NotBefore.Format("2006-01-02"),
NotAfter: cert.NotAfter.Format("2006-01-02"),
DaysUntilExpiry: daysUntilExpiry,
} }
if days <= WarningDaysThreshold { if daysUntilExpiry <= WarningDaysThreshold {
result.Warnings = append(result.Warnings, "certificate_expiring_soon") result.Warnings = append(result.Warnings, "certificate_expiring_soon")
result.CertInfo.ExpiringSoon = true result.CertInfo.ExpiringSoon = true
} }
return result
}
result.Valid = false
result.Error = "invalid_password_or_format"
return result return result
} }

View File

@ -9,9 +9,9 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"os/exec"
"path/filepath"
"time" "time"
"golang.org/x/crypto/pkcs12"
) )
type Client struct { type Client struct {
@ -54,35 +54,37 @@ func NewClient(cfg ClientConfig) (*Client, error) {
} }
func LoadCertificate(certPath, password string) (*tls.Certificate, error) { func LoadCertificate(certPath, password string) (*tls.Certificate, error) {
dir := filepath.Dir(certPath) der, err := os.ReadFile(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)
if err != nil { 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 { if err != nil {
return nil, fmt.Errorf("reading cert: %w", err) return nil, fmt.Errorf("decoding PKCS#12: %w", err)
}
keyData, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("reading key: %w", err)
} }
cert, err := tls.X509KeyPair(certData, keyData) if cert == nil {
if err != nil { return nil, fmt.Errorf("no certificate found in PKCS#12")
return nil, fmt.Errorf("parsing: %w", err)
} }
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 { func (c *Client) SetCertificate(certPath, password string) error {