package client import ( "bytes" "context" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "errors" "fmt" "io" "net/http" "os" "strings" "sync" "time" "go-backend-template/config" "go-backend-template/internal/contract" "go-backend-template/internal/logger" ) // DukcapilClient performs HTTPS calls to the Dukcapil 1:N face recognition // endpoint (CALL_FN). It loads and caches the configured RSA public key used // to encrypt sensitive credentials. type DukcapilClient struct { cfg config.Dukcapil http *http.Client pubKey *rsa.PublicKey keyMu sync.Mutex } func NewDukcapilClient(cfg config.Dukcapil) *DukcapilClient { return &DukcapilClient{ cfg: cfg, http: &http.Client{ Timeout: cfg.Timeout(), }, } } // FaceMatch performs a 1:N face match call. user_id/password are encrypted // with the configured public key (RSA PKCS1v15 -> base64). The image must // already be base64 encoded. func (c *DukcapilClient) FaceMatch(ctx context.Context, req *contract.FaceMatchRequest) (*contract.DukcapilFaceResponse, error) { if c.cfg.BaseURL == "" || c.cfg.CustomerID == "" || c.cfg.Methode == "" { return nil, errors.New("dukcapil: incomplete configuration") } pub, err := c.loadPublicKey() if err != nil { return nil, fmt.Errorf("dukcapil: load public key: %w", err) } encUserID, err := encryptAndEncode(pub, []byte(c.cfg.UserID)) if err != nil { return nil, fmt.Errorf("dukcapil: encrypt user_id: %w", err) } encPassword, err := encryptAndEncode(pub, []byte(c.cfg.Password)) if err != nil { return nil, fmt.Errorf("dukcapil: encrypt password: %w", err) } ip := req.IP if strings.TrimSpace(ip) == "" { ip = c.cfg.DefaultIP } body := contract.DukcapilFaceRequest{ TransactionID: req.TransactionID, TransactionSource: req.TransactionSource, Threshold: req.Threshold, Image: req.Image, UserID: encUserID, Password: encPassword, IP: ip, } payload, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("dukcapil: marshal request: %w", err) } url := fmt.Sprintf("%s/%s/%s", strings.TrimRight(c.cfg.BaseURL, "/"), c.cfg.CustomerID, c.cfg.Methode, ) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { return nil, fmt.Errorf("dukcapil: build request: %w", err) } httpReq.Header.Set("Accept", "application/json") httpReq.Header.Set("Content-Type", "application/json") start := time.Now() resp, err := c.http.Do(httpReq) if err != nil { return nil, fmt.Errorf("dukcapil: do request: %w", err) } defer resp.Body.Close() respBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("dukcapil: read response: %w", err) } logger.FromContext(ctx).Infof("DukcapilClient::FaceMatch -> status=%d duration=%s", resp.StatusCode, time.Since(start)) if resp.StatusCode >= 500 { return nil, fmt.Errorf("dukcapil: upstream status %d: %s", resp.StatusCode, string(respBytes)) } var out contract.DukcapilFaceResponse if err := json.Unmarshal(respBytes, &out); err != nil { return nil, fmt.Errorf("dukcapil: decode response: %w (body=%s)", err, string(respBytes)) } return &out, nil } func (c *DukcapilClient) loadPublicKey() (*rsa.PublicKey, error) { c.keyMu.Lock() defer c.keyMu.Unlock() if c.pubKey != nil { return c.pubKey, nil } if c.cfg.PublicKeyPath == "" { return nil, errors.New("public key path not configured") } raw, err := os.ReadFile(c.cfg.PublicKeyPath) if err != nil { return nil, err } block, _ := pem.Decode(raw) if block == nil { return nil, errors.New("invalid PEM file") } // Try PKIX first (BEGIN PUBLIC KEY) then PKCS1 (BEGIN RSA PUBLIC KEY). if pub, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil { rsaPub, ok := pub.(*rsa.PublicKey) if !ok { return nil, errors.New("public key is not RSA") } c.pubKey = rsaPub return rsaPub, nil } rsaPub, err := x509.ParsePKCS1PublicKey(block.Bytes) if err != nil { return nil, fmt.Errorf("parse rsa public key: %w", err) } c.pubKey = rsaPub return rsaPub, nil } // encryptAndEncode mimics PHP openssl_public_encrypt (default padding = // PKCS1v15) and base64-encodes the ciphertext. func encryptAndEncode(pub *rsa.PublicKey, plaintext []byte) (string, error) { cipherBytes, err := rsa.EncryptPKCS1v15(rand.Reader, pub, plaintext) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(cipherBytes), nil }