El síntoma
Mi portal de licencias cobra a través de una pasarela de pago. Cuando alguien paga, la pasarela llama a un webhook de mi backend, que es Visual FoxPro 9 corriendo bajo FoxServer, y ese webhook emite la licencia y manda el correo. Cada llamada viene firmada con un HMAC-SHA256 del cuerpo de la petición, calculado con un secreto que solo conocemos la pasarela y yo. Si la firma no cuadra, el evento se descarta y queda una línea en el log: firma invalida.
En los logs del servidor había cuatro de esas líneas en tres semanas. Podían ser pruebas del simulador de la pasarela o reintentos fuera de la ventana de cinco minutos. Pero estaba diseñando un segundo webhook, el de Patreon, que también firma sobre el cuerpo, y antes de reutilizar el mismo camino quería comprobar que era correcto.
La hipótesis
El cuerpo de una petición HTTP llega como bytes. FoxServer lo decodifica como UTF-8 a una cadena de .NET y se la pasa a Visual FoxPro por COM. Al cruzar COM hacia VFP, esa cadena se convierte a la página de códigos ANSI de la máquina, CP1252, y lo que no cabe en CP1252 se sustituye por otro carácter. El helper que verifica la firma, también en .NET, recibe de vuelta lo que VFP le pasa, lo codifica otra vez como UTF-8 y calcula el HMAC sobre eso.
Con José Muñoz no pasa nada, porque la é y la ñ están en CP1252 y los bytes vuelven iguales. Con Şahin, la Ş no está en CP1252, los bytes ya no son los que firmó la pasarela y el HMAC no puede coincidir. Antes de tocar nada quise medirlo.
La medición
Escribí un banco de pruebas con FoxProof, mi runner de tests para VFP, sin levantar el servidor. Los dos saltos por COM se reproducen con ADODB.Stream: leer bytes UTF-8 y devolver un BSTR equivale a lo que hace el host al entregar el cuerpo, y recibir una cadena de VFP como BSTR y escribirla en UTF-8 equivale a lo que hace el helper al firmar. El HMAC de referencia lo calcula bcrypt.dll por DECLARE, comprobado antes con los vectores publicados. El último grupo de tests llama al helper .NET real, el mismo que corre en producción, con una firma calculada sobre los bytes originales.
FUNCTION HmacHex(lcAlg, lcKey, lcData)
LOCAL lnAlg, lnHash, lnRet, lcOut, lnLen
DECLARE INTEGER BCryptOpenAlgorithmProvider IN bcrypt.dll ;
INTEGER @hAlg, STRING pszAlgId, INTEGER pszImpl, INTEGER dwFlags
DECLARE INTEGER BCryptCreateHash IN bcrypt.dll ;
INTEGER hAlg, INTEGER @hHash, INTEGER pbHashObject, INTEGER cbHashObject, ;
STRING pbSecret, INTEGER cbSecret, INTEGER dwFlags
DECLARE INTEGER BCryptHashData IN bcrypt.dll ;
INTEGER hHash, STRING pbInput, INTEGER cbInput, INTEGER dwFlags
DECLARE INTEGER BCryptFinishHash IN bcrypt.dll ;
INTEGER hHash, STRING @pbOutput, INTEGER cbOutput, INTEGER dwFlags
lnAlg = 0
lnHash = 0
* BCRYPT_ALG_HANDLE_HMAC_FLAG = 8; el nombre del algoritmo va en UTF-16
lnRet = BCryptOpenAlgorithmProvider(@lnAlg, STRCONV(lcAlg + CHR(0), 5), 0, 8)
lnRet = BCryptCreateHash(lnAlg, @lnHash, 0, 0, lcKey, LEN(lcKey), 0)
lnLen = IIF(UPPER(lcAlg) == "MD5", 16, 32)
lcOut = REPLICATE(CHR(0), lnLen)
lnRet = BCryptHashData(lnHash, lcData, LEN(lcData), 0)
lnRet = BCryptFinishHash(lnHash, @lcOut, lnLen, 0)
RETURN LOWER(STRCONV(lcOut, 15))
ENDFUNCDentro del test los payloads van en hexadecimal, porque el propio .prg está en CP1252 y los caracteres que quería probar no se pueden escribir en él.
El resultado, con 14 tests:
| Payload | Ida y vuelta por COM | Helper real |
|---|---|---|
| ASCII | bytes idénticos | verifica |
José Muñoz, 5,99 € | bytes idénticos | verifica |
Zoë Müller-Ålund | bytes idénticos | verifica |
| S con cedilla turca, L barrada polaca, e vietnamita | Ş→S, Ł→L, ễ→? | firma inválida |
| Un emoji | ?? | firma inválida |
La hipótesis se confirmó, y la medición sitúa la pérdida en el punto que esperaba. El host decodifica bien el UTF-8; los caracteres se pierden en la conversión de Unicode a CP1252 al entrar en VFP, antes de que ningún controlador toque el cuerpo. Dos de las sustituciones son el «best fit» de Windows, que cambia Ş por S y Ł por L, así que la cadena resultante parece correcta y en el log no hay nada que indique que un carácter ha cambiado.
Lo que sí cabe en CP1252 llega intacto, el euro incluido. Un cliente español, alemán o sueco no lo habría notado nunca. Uno turco, polaco, checo o vietnamita, o cualquiera con un emoji en el nombre, pagaba y se quedaba sin licencia.
El arreglo
Pasar el cuerpo de VFP al helper en Base64 no serviría, porque cuando VFP lo tiene los caracteres ya se han perdido. La corrección tenía que estar en el host. FoxServer 0.9.25 guarda los bytes del cuerpo tal como llegan y se los pasa a VFP también en Base64, que cruza COM sin conversiones. En el controlador queda así:
lcPayload = req.GetRawBody() && el texto, para leerlo
lcSig = req.GetHeader("X-Signature", "")
TRY
lcPayloadB64 = req.GetRawBodyBase64() && los bytes originales, para firmarlos
CATCH
lcPayloadB64 = "" && host o core anteriores: se firma el texto
ENDTRY
llOk = loPagoSvc.VerifySignature(lcPayload, lcSig, lcPayloadB64)El helper .NET decodifica el Base64 y calcula el HMAC sobre esos bytes. GetRawBody() sigue igual, porque para leer el contenido el texto en CP1252 es lo que necesita un controlador; solo la firma necesita los bytes originales.
Me importaba no romper nada de lo que ya funciona. El host hace la llamada nueva con un TryInvoke, que devuelve falso sin lanzar error si el proyecto tiene un core anterior sin ese método, así que un proyecto viejo sigue funcionando igual. Y el controlador vuelve a firmar el texto si el Base64 llega vacío, de modo que la DLL del portal también funciona con un host anterior.
Los tests documentan la pérdida
La suite del host tiene ahora 16 casos que envían esos cuatro payloads a un endpoint de prueba y comprueban dos cosas: que GetRawBodyBase64() devuelve los mismos bytes que envió el cliente, y que GetRawBody(), el texto, los sigue perdiendo en los dos casos que no caben en CP1252. La segunda comprobación deja escrita la razón por la que existe la primera, para quien lea los tests dentro de un año.
El banco de FoxProof pasó de 10 tests en verde y 4 en rojo a 26 en verde. Los cuatro rojos se convirtieron en tests que documentan la pérdida por la vía del texto, y se añadieron los grupos que comprueban que la vía Base64 verifica todos los casos, emoji incluido.
Lo que aprendí
- Una firma HMAC se calcula sobre bytes. Cualquier conversión de página de códigos entre la red y el código que verifica cambia la firma, aunque no se vea. Si recibes webhooks firmados en VFP, te va a pasar.
- Una sustitución «best fit» es más difícil de detectar que un interrogante: un
?en un nombre se ve enseguida, unaSen lugar de unaŞno. - Conviene medir un camino antes de reutilizarlo. El webhook de Patreon habría tenido el mismo fallo y, como devuelve 401 cuando la firma no cuadra, Patreon habría reintentado esos eventos una y otra vez.
- Un test que documenta un límite es tan útil como uno que prueba un arreglo, porque explica por qué hay dos métodos donde parece que bastaba uno.
El arreglo salió al servidor ese mismo día. La siguiente nota cuenta el webhook de Patreon, que avisa a mi backend en VFP cada vez que un mecenas entra, se va o cambia de tier.