Compare commits

..

4 Commits

Author SHA1 Message Date
lite 1d3e781c7b fix: soporte PascalCase en registro de certificados y actualizacion de documentacion
- Añadir campos PascalCase (CertName, CertFile, PasswordEncrypted) para compatibilidad con frontend
- Metodos CertNameResolved(), CertFileResolved(), PasswordResolved() para resolver campos
- Actualizar documentacion/api.md con ejemplos PascalCase, anulacion completa y errores AEAT
- Actualizar documentacion/certificado_pruebas.md con solucion de problemas P12 legacy
- Actualizar README.md con estado actual del proyecto
2026-05-21 19:31:11 -04:00
lite bed50a49a3 fix: StoreFromBase64 writes to tmp/ instead of permanent location
- Fix bug where certificate was written directly to data/certs/
- MoveToPerm now correctly renames from tmp/ to permanent location
2026-05-19 17:19:44 -04:00
lite 3ede32ef20 refactor: only accept certificate as base64, remove cert_path field
- RegisterInput now only has cert_file (base64 content)
- Removed cert_path fallback and raw map parsing
- Clean struct-based JSON unmarshaling
2026-05-19 17:16:48 -04:00
lite 9c4f11d7c7 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)
2026-05-19 16:03:28 -04:00
11 changed files with 204 additions and 139 deletions

View File

@ -43,6 +43,8 @@ Recibe facturas en JSON, calcula el hash encadenado, genera el XML SOAP y lo env
- [x] Alta de facturas con hash encadenado - [x] Alta de facturas con hash encadenado
- [x] Fallback local - [x] Fallback local
- [x] Registro y validación de certificados - [x] Registro y validación de certificados
- [ ] Anulación con AEAT - [x] Anulación de facturas
- [ ] Consultas - [ ] Consultas
- [ ] Subsanación - [ ] Subsanación
- [x] Soporte de campos PascalCase y snake_case en registro
- [x] Documentación de errores AEAT y solución de problemas con certificados P12

View File

@ -17,8 +17,32 @@ import (
type RegisterInput struct { type RegisterInput struct {
CertName string `json:"cert_name"` CertName string `json:"cert_name"`
CertPath string `json:"cert_path"` CertNamePascal string `json:"CertName"`
CertFile string `json:"cert_file"`
CertFilePascal string `json:"CertFile"`
PasswordEncrypted string `json:"password_encrypted"` PasswordEncrypted string `json:"password_encrypted"`
PasswordPascal string `json:"PasswordEncrypted"`
}
func (r RegisterInput) CertNameResolved() string {
if r.CertName != "" {
return r.CertName
}
return r.CertNamePascal
}
func (r RegisterInput) CertFileResolved() string {
if r.CertFile != "" {
return r.CertFile
}
return r.CertFilePascal
}
func (r RegisterInput) PasswordResolved() string {
if r.PasswordEncrypted != "" {
return r.PasswordEncrypted
}
return r.PasswordPascal
} }
type Handler struct { type Handler struct {
@ -72,13 +96,16 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
return return
} }
if input.CertName == "" || input.CertPath == "" || input.PasswordEncrypted == "" { certName := input.CertNameResolved()
certFile := input.CertFileResolved()
passwordEnc := input.PasswordResolved()
if certName == "" || certFile == "" || passwordEnc == "" {
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
} }
decodedPass, err := base64.StdEncoding.DecodeString(input.PasswordEncrypted) decodedPass, err := base64.StdEncoding.DecodeString(passwordEnc)
if err != nil { if err != nil {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"invalid_password_encrypted"}`)) w.Write([]byte(`{"success":false,"error":"invalid_password_encrypted"}`))
@ -94,7 +121,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(certFile, 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 +133,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(certName, certFile)
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")
@ -115,7 +142,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
} }
if len(validation.Warnings) > 0 { if len(validation.Warnings) > 0 {
storedPath, err := h.cert.MoveToPerm(input.CertName, tempPath, plainPass) storedPath, err := h.cert.MoveToPerm(certName, tempPath, plainPass)
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")
@ -123,7 +150,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
return return
} }
tokenData, err := h.cert.GenerateToken(input.CertName, storedPath, plainPass) tokenData, err := h.cert.GenerateToken(certName, storedPath, plainPass)
if err != nil { if err != nil {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"token_generation_failed"}`)) w.Write([]byte(`{"success":false,"error":"token_generation_failed"}`))
@ -141,7 +168,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
return return
} }
storedPath, err := h.cert.MoveToPerm(input.CertName, tempPath, plainPass) storedPath, err := h.cert.MoveToPerm(certName, tempPath, plainPass)
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")
@ -149,7 +176,7 @@ func (h *Handler) RegisterCert(w http.ResponseWriter, r *http.Request) {
return return
} }
tokenData, err := h.cert.GenerateToken(input.CertName, storedPath, plainPass) tokenData, err := h.cert.GenerateToken(certName, storedPath, plainPass)
if err != nil { if err != nil {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":false,"error":"token_generation_failed"}`)) w.Write([]byte(`{"success":false,"error":"token_generation_failed"}`))

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,17 +32,29 @@ 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 (snake_case):**
```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_encrypted_password"
} }
``` ```
**Request (PascalCase — compatibilidad con frontend):**
```json
{
"CertName": "mi-certificado",
"CertFile": "BASE64_CONTENT_OF_P12_FILE",
"PasswordEncrypted": "base64_encoded_encrypted_password"
}
```
> **Nota:** `password_encrypted` debe ser la contraseña del certificado cifrada con la clave pública RSA obtenida en `/api/v1/auth/public-key`. No se envía en texto plano.
```
**Response (éxito):** **Response (éxito):**
```json ```json
{ {
@ -155,7 +167,7 @@ Ver [formatos.md](formatos.md) para detalles de cada formato y cómo añadir nue
```json ```json
{ {
"success": true, "success": true,
"csv": "CSV1234567ABC", "csv": "A-FSZKDA8UG7WD9U",
"estado": "Correcto" "estado": "Correcto"
} }
``` ```
@ -169,13 +181,16 @@ Ver [formatos.md](formatos.md) para detalles de cada formato y cómo añadir nue
} }
``` ```
> **Nota:** El campo `sistema.nombre` debe coincidir con el nombre registrado en la AEAT para el NIF del emisor. Si no coincide, la AEAT rechazará la factura con error de censo (código 1110 o 1239).
```
--- ---
### Anular Factura ### Anular Factura
``` ```
POST /api/v1/facturas/anular POST /api/v1/facturas/anular
``` ```
Anula una factura previamente registrada. Anula una factura previamente registrada. Usa el mismo formato que el alta pero con `tipo: "anulacion"` y `tipo_factura: "R1"` (u otro tipo de rectificativa).
**Request:** **Request:**
```json ```json
@ -183,12 +198,35 @@ Anula una factura previamente registrada.
"tipo": "anulacion", "tipo": "anulacion",
"factura": { "factura": {
"emisor_nif": "53950250R", "emisor_nif": "53950250R",
"emisor_nombre": "JOSEP VICENT MESTRE LLOBELL",
"num_serie": "FV2026/001", "num_serie": "FV2026/001",
"fecha_expedicion": "17-04-2026" "fecha_expedicion": "21-05-2026",
"tipo_factura": "R1",
"descripcion": "Anulacion de factura de prueba",
"iva": [
{"base": 1000.00, "cuota": 210.00, "tipo": 21.0}
],
"importe_total": 1210.00
},
"sistema": {
"nombre": "JOSEP VICENT MESTRE LLOBELL",
"nif_proveedor": "53950250R",
"version": "1.0"
} }
} }
``` ```
**Response (éxito):**
```json
{
"success": true,
"estado": "Anulada"
}
```
> **Nota:** El campo `sistema.nombre` debe coincidir con el nombre registrado en la AEAT para el NIF del emisor. Si no coincide, la AEAT rechazará la factura con error de censo.
```
## Códigos de Error ## Códigos de Error
| Código | Descripción | | Código | Descripción |
@ -203,3 +241,17 @@ Anula una factura previamente registrada.
| `aeat_fault` | Error SOAP de la AEAT | | `aeat_fault` | Error SOAP de la AEAT |
| `hash_save_error` | Error guardando el hash local | | `hash_save_error` | Error guardando el hash local |
| `hash_storage_error` | Error leyendo el hash anterior | | `hash_storage_error` | Error leyendo el hash anterior |
| `missing_fields` | Faltan campos obligatorios en el registro |
| `invalid_json` | El JSON enviado no es válido |
| `decrypt_failed` | No se pudo descifrar la contraseña con la clave privada |
| `emisor_nif_required` | Falta el NIF del emisor en la anulación |
| `num_serie_required` | Falta el número de serie en la anulación |
| `fecha_expedicion_required` | Falta la fecha de expedición en la anulación |
## Errores de la AEAT más comunes
| Código | Descripción | Solución |
|--------|------------|----------|
| 1110 | NIF no identificado en el censo | Verificar que `sistema.nombre` coincide con el nombre del NIF |
| 1189 | Bloque Destinatarios obligatorio para tipo F1/F3/R1-R4 | Incluir `destinatario` con nombre y NIF |
| 1239 | Error en bloque Destinatario | Verificar formato del NIF del destinatario |

View File

@ -90,6 +90,27 @@ VerifactuMidAPI/
## Problemas Comunes ## Problemas Comunes
### "pkcs12: expected exactly two safe bags in the PFX PDU"
El archivo .p12 contiene más de un certificado (cadena completa con CA intermedias). La librería Go `pkcs12` espera solo el certificado de cliente y la clave privada.
**Solución:** Extraer solo el certificado de cliente y crear un nuevo P12:
```bash
# Extraer clave privada
openssl pkcs12 -in original.p12 -nocerts -nodes -passin pass:PASSWORD -out key.pem
# Extraer solo el certificado de cliente
openssl pkcs12 -in original.p12 -clcerts -nokeys -passin pass:PASSWORD -out client_cert.pem
# Crear nuevo P12 simplificado
openssl pkcs12 -export -out personal.p12 -inkey key.pem -in client_cert.pem \
-passout pass:PASSWORD -name "personal" -legacy -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES
```
### "pkcs12: unknown digest algorithm: 2.16.840.1.101.3.4.2.1"
La librería Go `pkcs12` no soporta cifrado SHA-256 en archivos P12. Se necesita usar cifrado legacy (SHA1/3DES).
**Solución:** Recrear el P12 con cifrado legacy (ver comando `-legacy -certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES` arriba).
### "403 Forbidden" ### "403 Forbidden"
- El certificado no está autorizado en el entorno de pruebas - El certificado no está autorizado en el entorno de pruebas
- O el certificado es de producción y estás en test - O el certificado es de producción y estás en test

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,20 @@ 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") tmpDir := filepath.Join(s.basePath, "tmp")
if err := os.MkdirAll(tmpPath, 0700); err != nil { if err := os.MkdirAll(tmpDir, 0700); err != nil {
return "", fmt.Errorf("creating tmp 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(tmpDir, 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 +101,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 {