Das Symptom

Mein Lizenzportal kassiert über einen Zahlungsanbieter. Wenn jemand bezahlt, ruft der Anbieter einen Webhook meines Backends auf, das aus Visual FoxPro 9 unter FoxServer besteht, und dieser Webhook stellt die Lizenz aus und verschickt die E-Mail. Jeder Aufruf ist mit einem HMAC-SHA256 über den Request-Body signiert, berechnet mit einem Geheimnis, das nur der Anbieter und ich kennen. Passt die Signatur nicht, wird das Ereignis verworfen und es bleibt eine Zeile im Log: firma invalida.

In den Server-Logs standen in drei Wochen vier solcher Zeilen. Es konnten Tests aus dem Simulator des Anbieters sein oder Wiederholungen außerhalb des Fünf-Minuten-Fensters. Ich entwarf aber gerade einen zweiten Webhook, den von Patreon, der ebenfalls über den Body signiert, und bevor ich denselben Weg wiederverwendete, wollte ich prüfen, ob er stimmt.

Die Hypothese

Der Body einer HTTP-Anfrage kommt als Bytes an. FoxServer dekodiert ihn als UTF-8 in einen .NET-String und übergibt ihn per COM an Visual FoxPro. Beim Übergang nach VFP wird dieser String in die ANSI-Codepage der Maschine umgewandelt, CP1252, und was dort nicht hineinpasst, wird durch ein anderes Zeichen ersetzt. Der Helper, der die Signatur prüft und ebenfalls in .NET läuft, erhält zurück, was VFP ihm übergibt, kodiert es erneut als UTF-8 und berechnet den HMAC darüber.

Bei José Muñoz passiert nichts, denn é und ñ gibt es in CP1252, und die Bytes kommen unverändert zurück. Bei Şahin gibt es das Ş in CP1252 nicht, die Bytes sind nicht mehr die, die der Anbieter signiert hat, und der HMAC kann nicht übereinstimmen. Bevor ich etwas änderte, wollte ich das messen.

Die Messung

Ich habe mit FoxProof, meinem Test-Runner für VFP, einen Prüfstand geschrieben, ohne den Server zu starten. Die beiden Übergänge über COM werden mit ADODB.Stream nachgebildet: UTF-8-Bytes lesen und einen BSTR zurückgeben entspricht dem, was der Host beim Übergeben des Bodys tut, und einen VFP-String als BSTR annehmen und als UTF-8 schreiben entspricht dem, was der Helper beim Signieren tut. Den Referenz-HMAC berechnet bcrypt.dll über DECLARE, vorher mit den veröffentlichten Testvektoren geprüft. Die letzte Testgruppe ruft den echten .NET-Helper auf, denselben, der in Produktion läuft, mit einer Signatur über die ursprünglichen Bytes.

Referenz-HMAC mit CNG (bcrypt.dll)
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; der Algorithmusname geht in 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))
ENDFUNC

Im Test stehen die Payloads in Hexadezimal, weil die .prg selbst in CP1252 gespeichert ist und sich die Zeichen, die ich prüfen wollte, darin nicht schreiben lassen.

Das Ergebnis mit 14 Tests:

PayloadHin und zurück über COMEchter Helper
ASCIIidentische Bytesprüft erfolgreich
José Muñoz, 5,99 €identische Bytesprüft erfolgreich
Zoë Müller-Ålundidentische Bytesprüft erfolgreich
Türkisches S mit Cedille, polnisches L mit Strich, vietnamesisches eŞ→S, Ł→L, ễ→?ungültige Signatur
Ein Emoji??ungültige Signatur

Die Hypothese bestätigte sich, und die Messung verortet den Verlust dort, wo ich ihn erwartet hatte. Der Host dekodiert das UTF-8 korrekt; die Zeichen gehen bei der Umwandlung von Unicode nach CP1252 beim Eintritt in VFP verloren, bevor irgendein Controller den Body anfasst. Zwei der Ersetzungen sind die «Best Fit»-Zuordnungen von Windows, die aus Ş ein S und aus Ł ein L machen. Der entstehende String sieht also korrekt aus, und nichts im Log zeigt, dass sich ein Zeichen geändert hat.

Was in CP1252 passt, kommt unversehrt an, das Euro-Zeichen eingeschlossen. Ein spanischer, deutscher oder schwedischer Kunde hätte nie etwas bemerkt. Ein türkischer, polnischer, tschechischer oder vietnamesischer Kunde, oder jemand mit einem Emoji im Namen, bezahlte und bekam keine Lizenz.

Die Korrektur

Den Body von VFP in Base64 an den Helper zu übergeben, hätte nichts gebracht, denn sobald VFP ihn hat, sind die Zeichen schon verloren. Die Korrektur musste im Host liegen. FoxServer 0.9.25 bewahrt die Bytes des Bodys so auf, wie sie ankommen, und übergibt sie VFP zusätzlich in Base64, das ohne Umwandlung über COM geht. Im Controller sieht das so aus:

Der Zahlungs-Webhook mit dem Body in Base64
lcPayload = req.GetRawBody()           && der Text, zum Lesen
lcSig     = req.GetHeader("X-Signature", "")
TRY
    lcPayloadB64 = req.GetRawBodyBase64()  && die ursprünglichen Bytes, zum Signieren
CATCH
    lcPayloadB64 = ""                      && älterer Host oder Core: der Text wird signiert
ENDTRY
llOk = loZahlungSvc.VerifySignature(lcPayload, lcSig, lcPayloadB64)

Der .NET-Helper dekodiert das Base64 und berechnet den HMAC über diese Bytes. GetRawBody() bleibt unverändert, denn zum Lesen des Inhalts braucht ein Controller den Text in CP1252; nur die Signatur braucht die ursprünglichen Bytes.

Mir war wichtig, nichts kaputtzumachen, was schon funktionierte. Der Host führt den neuen Aufruf über ein TryInvoke aus, das ohne Fehler falsch zurückgibt, wenn das Projekt einen älteren Core ohne diese Methode hat, sodass ein altes Projekt weiterläuft wie bisher. Und der Controller signiert wieder den Text, wenn das Base64 leer ankommt, sodass die Portal-DLL auch mit einem älteren Host funktioniert.

Die Tests dokumentieren den Verlust

Die Testsuite des Hosts hat jetzt 16 Fälle, die diese vier Payloads an einen Test-Endpunkt schicken und zwei Dinge prüfen: dass GetRawBodyBase64() dieselben Bytes zurückgibt, die der Client geschickt hat, und dass GetRawBody(), der Text, sie in den beiden Fällen, die nicht in CP1252 passen, weiterhin verliert. Die zweite Prüfung hält fest, warum es die erste gibt, für jeden, der die Tests in einem Jahr liest.

Der FoxProof-Prüfstand ging von 10 grünen und 4 roten Tests auf 26 grüne. Die vier roten Tests wurden zu Tests, die den Verlust auf dem Textweg dokumentieren, und es kamen Gruppen hinzu, die prüfen, dass der Base64-Weg jeden Fall erfolgreich prüft, das Emoji eingeschlossen.

Was ich gelernt habe

  • Eine HMAC-Signatur wird über Bytes berechnet. Jede Codepage-Umwandlung zwischen dem Netz und dem prüfenden Code ändert die Signatur, auch wenn man es nicht sieht. Wer in VFP signierte Webhooks empfängt, wird das erleben.
  • Eine «Best Fit»-Ersetzung ist schwerer zu erkennen als ein Fragezeichen: Ein ? in einem Namen fällt sofort auf, ein S anstelle eines Ş nicht.
  • Es lohnt sich, einen Weg zu messen, bevor man ihn wiederverwendet. Der Patreon-Webhook hätte denselben Fehler gehabt, und weil er bei einer falschen Signatur 401 zurückgibt, hätte Patreon diese Ereignisse immer wieder gesendet.
  • Ein Test, der eine Grenze dokumentiert, ist so nützlich wie einer, der eine Korrektur belegt, weil er erklärt, warum es zwei Methoden gibt, wo eine zu reichen schien.

Die Korrektur ging am selben Tag auf den Server. Der nächste Beitrag handelt vom Patreon-Webhook, der mein VFP-Backend jedes Mal benachrichtigt, wenn ein Unterstützer dazukommt, geht oder die Stufe wechselt.