Il contesto

Un cliente con un piccolo e-commerce custom su Firebase e Netlify Functions usava, per il checkout, un semplice link di pagamento generato manualmente dal portale myPOS. Il passo successivo era ovvio: integrare direttamente l'API IPC di myPOS, così che il totale dell'ordine venga trasmesso in automatico al gateway invece di essere copiato a mano in un link. Il cliente mi ha girato via chat le credenziali fornite dal suo provider di pagamento — pratica non ideale, ma comune quando l'interlocutore tecnico è terzo rispetto a chi gestisce l'account myPOS.

Il piano iniziale era semplice: una Netlify Function riceve il totale dal frontend, firma la richiesta con la chiave privata RSA e la inoltra a myPOS lato server, restituendo l'URL di pagamento al browser. Ho scoperto passo dopo passo che quasi nessuna di queste assunzioni era corretta.

⚠️

Nota di sicurezza: qualunque chiave privata o certificato ricevuto via chat va considerato compromesso. Le chiavi vanno inserite solo come variabili d'ambiente sul servizio che le usa (in questo caso Netlify), mai committate nel codice, e — se sono transitate in chiaro su una chat — rigenerate dal portale del provider appena possibile.

Ostacolo #1 — la chiave RSA a 1024 bit e OpenSSL 3

Il primo tentativo usava il modulo crypto nativo di Node.js per firmare la richiesta con la chiave privata PEM ricevuta. Il deploy su Netlify falliva subito con un errore poco parlante:

errore in produzione
Errore tecnico: error:1E08010C:DECODER routines::unsupported

Il primo sospetto era un problema di formato: la chiave era in PKCS#1 (-----BEGIN RSA PRIVATE KEY-----) e forse Node si aspettava PKCS#8. Ho provato a forzare il parsing esplicito con crypto.createPrivateKey({ key, format: 'pem', type: 'pkcs1' }), senza risultato — stesso errore. La causa reale era un'altra: la chiave era una RSA a 1024 bit, e Node.js 18+ si appoggia a OpenSSL 3 come libreria crittografica, che ha deprecato le chiavi RSA sotto i 2048 bit per motivi di sicurezza. Le rifiuta silenziosamente, sia in PKCS#1 sia in PKCS#8, con un messaggio che sembra parlare di formato ma in realtà nasconde un limite di lunghezza.

Senza la possibilità di far rigenerare al cliente una chiave a 2048 bit in tempi brevi, la soluzione è stata sostituire crypto con node-forge, un'implementazione crittografica in puro JavaScript che non passa da OpenSSL e quindi non eredita quella restrizione:

firma con node-forge invece di crypto nativo
const forge = require('node-forge');

// Node.js crypto rifiuta chiavi RSA < 2048 bit su OpenSSL 3.
// node-forge non passa da OpenSSL: nessuna restrizione di lunghezza.
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
const md = forge.md.sha256.create();
md.update(dataToSign, 'utf8');
const signatureBytes = privateKey.sign(md);
const signature = Buffer.from(signatureBytes, 'binary').toString('base64');

Ostacolo #2 — il mutual TLS che non serviva

Risolta la firma, la chiamata HTTPS verso myPOS falliva con un errore diverso ma dalla stessa radice:

errore handshake TLS
error:0480006C:PEM routines::no start line

La richiesta HTTPS includeva cert e key nelle opzioni per stabilire un mutual TLS con myPOS — pattern comune in altre integrazioni con certificati client. Ma qui i due meccanismi di autenticazione sono ridondanti: l'autenticazione applicativa avviene interamente tramite la firma RSA nel corpo della richiesta, verificata dal gateway lato server. Il mutual TLS a livello di connessione era superfluo e, con una chiave sotto i 2048 bit, introduceva lo stesso rifiuto OpenSSL già visto, questa volta nell'handshake TLS invece che nella firma applicativa. Rimuovere cert e key dalle opzioni della richiesta HTTPS ha eliminato l'errore senza alcuna perdita di sicurezza.

Ostacolo #3 — server-to-server invece di redirect dal browser

A questo punto la funzione firmava correttamente e otteneva una risposta da myPOS — ma quella risposta era una pagina HTML completa, non un JSON con un URL di pagamento:

log Netlify Function
INFO   myPOS IPC response keys: [ '<!DOCTYPE html><html lang', 'subset' ]
INFO   IPCResult: undefined
ERROR  myPOS HTML error code: unknown

Il malinteso di fondo era architetturale: IPC non è un'API REST che risponde con JSON a una chiamata server-to-server, ma un protocollo redirect flow. Il cliente finale deve arrivare fisicamente, con il proprio browser, sulla pagina di checkout ospitata da myPOS — perché lì avvengono la raccolta dei dati carta e l'eventuale autenticazione 3D Secure, entrambe operazioni che per gli standard PCI DSS non possono transitare per un server intermedio.

La correzione ha spostato la responsabilità: la Netlify Function genera solo i parametri e la firma e li restituisce al browser, che li usa per costruire ed autosottomettere un form HTML direttamente verso l'endpoint myPOS:

autosubmit form dal browser
function redirectToMyPOS(params) {
  const form = document.createElement('form');
  form.method = 'POST';
  form.action = 'https://www.mypos.com/vmp/checkout/';

  Object.entries(params).forEach(([key, value]) => {
    const input = document.createElement('input');
    input.type = 'hidden';
    input.name = key;
    input.value = value;
    form.appendChild(input);
  });

  document.body.appendChild(form);
  form.submit(); // il browser del cliente, non il server, arriva su myPOS
}
💡

Pattern generale: quando un gateway di pagamento richiede la raccolta di dati carta o l'autenticazione 3D Secure, aspettati un redirect flow lato browser, non un endpoint REST server-to-server — anche se la documentazione mostra esempi di POST che sembrano suggerire il contrario.

Ostacolo #4 — l'algoritmo di firma con doppio base64

Con il redirect flow in campo, myPOS finalmente mostrava la sua pagina di checkout — ma rifiutava la richiesta con Error Code 3, firma non valida. La documentazione ufficiale descrive l'algoritmo in una riga facile da leggere male:

PassaggioCosa fa
1Concatena tutti i valori dei parametri (esclusa la firma) con un trattino -, nell'ordine esatto della richiesta
2Codifica il risultato in base64
3Firma la stringa base64 (non i dati originali) con RSA-SHA256
4Codifica la firma binaria risultante in base64 → questo è il valore del parametro Signature

Il primo tentativo aveva sbagliato sia il separatore (usava | invece di -) sia saltava del tutto il passaggio 2, firmando direttamente la stringa concatenata. Corretto l'algoritmo, l'errore è cambiato ma non è sparito — segno che il problema si era spostato su un dettaglio diverso: la case-sensitivity dei nomi dei parametri. Il payload usava IPCmethod, ma myPOS si aspetta esattamente IPCMethod: un parametro obbligatorio scritto con la lettera sbagliata viene trattato come mancante, non come errato, e produce un più generico Error Code 1.

L'esempio ufficiale in PHP nasconde un'altra trappola speculare: nel campo che rappresenta il numero di wallet, il codice di esempio usa walletnumber tutto minuscolo, in contrasto con lo stile PascalCase di tutti gli altri parametri — copiare lo stile "coerente" degli altri campi porta a un nome sbagliato. Infine, l'SDK PHP ufficiale include sempre un blocco carrello (CartItems, Article_1, Quantity_1, Price_1, Currency_1, Amount_1) e il parametro PaymentMethod, entrambi assenti dagli esempi minimali della documentazione ma di fatto richiesti.

generazione firma — versione corretta
// Ordine esplicito, non affidato a Object.values() su un oggetto
const orderedValues = [
  params.IPCMethod, params.IPCVersion, params.IPCLanguage,
  params.SID, params.walletnumber, params.Amount, params.Currency,
  params.OrderID, params.URL_OK, params.URL_Cancel, params.URL_Notify,
  params.CardTokenRequest, params.KeyIndex, params.PaymentMethod,
  params.CartItems, params.Article_1, params.Quantity_1,
  params.Price_1, params.Currency_1, params.Amount_1
];

const joined   = orderedValues.join('-');
const b64input = Buffer.from(joined).toString('base64');

const md = forge.md.sha256.create();
md.update(b64input, 'utf8');
params.Signature = Buffer.from(privateKey.sign(md), 'binary').toString('base64');

Ostacolo #5 — Error Code 25 e il dominio .netlify.app

Firma finalmente valida, restava un ultimo errore: Error Code 25, che secondo la documentazione myPOS significa "store restricted" — lo store non è approvato, oppure uno degli URL della richiesta non è whitelistato.

La configurazione nel portale myPOS elencava correttamente tutti e quattro gli URL richiesti (pagina di checkout, notifica, esito positivo, esito annullato). Il problema non era un URL mancante, ma il dominio stesso: il sito era ospitato su un sottodominio gratuito *.netlify.app, condiviso a livello di dominio radice con migliaia di altri progetti Netlify. I sistemi antifrode dei gateway di pagamento trattano spesso questi domini con più diffidenza di un dominio proprio, ed è probabile che questo — più che un problema di configurazione puntuale — fosse la causa dell'Error Code 25 persistente.

La soluzione è stata migrare il checkout su un sottodominio personalizzato, senza toccare il dominio principale del cliente (già in uso per un altro sito):

  • Aggiunto un record CNAME nel DNS del dominio del cliente, puntato al sito Netlify esistente
  • Configurato il dominio custom in Netlify → Domain management, lasciando il sottodominio .netlify.app originale attivo in parallelo
  • Aggiornati tutti gli URL nel portale myPOS (sito, checkout, notifica, esito) sul nuovo dominio
  • Atteso il provisioning del certificato SSL Let's Encrypt — myPOS rifiuta silenziosamente URL HTTPS con certificato non ancora attivo, quindi un test troppo anticipato dà l'impressione che il problema persista quando in realtà manca solo il certificato
Error Code Significato Causa in questo caso
1Parametri obbligatori mancantiNome parametro con maiuscola/minuscola errata (IPCmethod invece di IPCMethod)
3Firma non validaSeparatore errato e passaggio di base64 mancante nella stringa da firmare
25Store limitato / URL non approvatiDominio *.netlify.app gratuito, risolto con un sottodominio custom

Cosa mi porto a casa

  1. Un errore criptico di libreria crittografica va tradotto, non preso alla lettera. "DECODER routines::unsupported" sembra un problema di formato PEM, ma su Node 18+/OpenSSL 3 è quasi sempre un limite di lunghezza chiave (RSA < 2048 bit). node-forge è una via d'uscita quando non si può agire sulla chiave a monte.
  2. Non tutti i meccanismi di sicurezza in un'integrazione sono necessari contemporaneamente. Firma applicativa nel payload e mutual TLS a livello di connessione sono spesso alternativi, non cumulativi: aggiungerli entrambi può introdurre errori invece di rafforzare la sicurezza.
  3. Un endpoint che restituisce HTML invece di JSON è un indizio architetturale. Se un gateway di pagamento risponde con una pagina invece di un payload strutturato, probabilmente si aspetta un redirect flow dal browser del cliente finale, non una chiamata server-to-server.
  4. Gli esempi ufficiali delle API vanno letti carattere per carattere. Case-sensitivity nei nomi dei parametri e incoerenze di stile tra un campo e l'altro (come walletnumber minuscolo in mezzo a parametri PascalCase) sono errori silenziosi: il sistema non segnala "nome sbagliato", segnala "parametro mancante".
  5. Un dominio di hosting gratuito può essere trattato con sospetto dai sistemi antifrode. Se un gateway di pagamento rifiuta uno store apparentemente ben configurato, vale la pena testare la migrazione a un dominio proprio prima di continuare a inseguire dettagli di configurazione.

Domande frequenti

Perché myPOS rifiuta chiavi RSA a 1024 bit con errore DECODER unsupported?

Node.js 18+ usa OpenSSL 3 come libreria crittografica sottostante, e OpenSSL 3 ha deprecato le chiavi RSA sotto i 2048 bit per motivi di sicurezza: le rifiuta sia in formato PKCS#1 sia PKCS#8, con un errore poco esplicito che sembra un problema di formato ma in realtà è un limite di lunghezza chiave. La soluzione senza dover rigenerare le chiavi lato provider è usare una libreria in puro JavaScript come node-forge, che non passa da OpenSSL.

Se la firma RSA è nel body della richiesta, perché serve anche il mutual TLS?

Non serve: sono due meccanismi di autenticazione distinti e ridondanti. La firma RSA-SHA256 nel payload autentica il contenuto della richiesta ed è quella verificata dal gateway. Aggiungere anche il certificato client TLS è superfluo e, con una chiave sotto i 2048 bit, introduce lo stesso errore OpenSSL nell'handshake TLS. Rimuoverlo risolve l'errore senza indebolire la sicurezza.

Perché il pagamento deve partire con un form POST dal browser e non da una chiamata server-to-server?

Il protocollo IPC è un redirect flow: il cliente deve arrivare fisicamente sulla pagina di checkout del gateway, dove avvengono raccolta dati carta ed eventuale 3D Secure — operazioni che per PCI DSS non possono transitare per un server intermedio. Una funzione serverless può generare parametri e firma, ma il POST finale deve partire dal browser stesso.

Come si costruisce correttamente la stringa da firmare per IPCPurchase in IPC v1.4?

Si concatenano tutti i valori dei parametri (esclusa la firma) con un trattino, nell'ordine esatto della richiesta. Il risultato va codificato in base64: questo valore, non i dati originali, è ciò che si firma con RSA-SHA256. Il risultato della firma binaria va a sua volta codificato in base64 prima di essere inserito nel parametro Signature.

Perché un dominio gratuito tipo *.netlify.app può causare un Error Code 25 (store restricted)?

L'Error Code 25 segnala che lo store non supera la validazione del gateway, spesso perché gli URL dichiarati non sono considerati affidabili. I sottodomini gratuiti condividono il dominio radice con migliaia di altri progetti e sono trattati con più diffidenza dai sistemi antifrode. Migrare a un sottodominio personalizzato — senza toccare il sito originale — e attendere il provisioning del certificato SSL è la soluzione più affidabile.