c2e25edbf8c48361a71398664ec0c279d5fc58f5 braney Sat Sep 19 18:11:31 2026 -0700 hmacTest: pin the signature hgLogin puts on a pending social identity hgLogin signs a pending social identity with hmacMd5(login.cookieSalt, fields) and hands the signature to the browser in the account chooser, so a forgeable signature lets an attacker choose whose account the chooser links to. Nothing about the page looks different either way. Before #37984 that signature was a plain MD5 of the salt concatenated in front of the same fields, and a hash of a secret followed by attacker-chosen text is the wrong shape for the job. boundaryMoves() is the case that says so: with concatenation, hmacMd5("a", "bc") and hmacMd5("ab", "c") hash the same bytes and sign the same, while HMAC keeps them apart. Known answers are RFC 2202 test case 2 for MD5 and SHA1, cross-checked here with the openssl command line. Only the string-keyed vectors from the RFC can be used, since this interface takes key and data as C strings. Watched to fail and then pass: putting hmacMd5 back to the concatenation shape turns the known answer red and makes the two boundary cases identical, both of which the test catches. Recorded as sandbox-ab in utils/testRegistry. refs #37984, refs #38391 diff --git src/lib/tests/hmacTest.c src/lib/tests/hmacTest.c new file mode 100644 index 00000000000..4f9c0797ef5 --- /dev/null +++ src/lib/tests/hmacTest.c @@ -0,0 +1,134 @@ +/* hmacTest - check hmacMd5 and hmacSha1 against known answers, and check the one + * property hgLogin depends on. + * + * hgLogin signs a pending social identity with hmacMd5(login.cookieSalt, "provider|id|email") + * and hands the signature to the browser in the account chooser. If the signature can be + * forged, an attacker picks whose account the chooser links to, so this is a security + * boundary with no visible symptom: a forged signature and a real one produce the same page. + * + * Before #37984 that signature was a plain MD5 of the salt concatenated in front of the same + * fields. A plain hash of a secret followed by attacker-chosen text is the wrong shape for + * the job, and the test that says so is boundaryMoves() below: with concatenation, moving a + * character from the end of the key to the front of the data leaves the hashed bytes + * identical, so two different (key, data) pairs sign the same. HMAC keeps them apart, and a + * change that quietly went back to concatenation would fail there rather than in a page + * nobody can tell apart. + * + * The known answers are RFC 2202 test case 2 for both algorithms, cross-checked on this + * machine with `openssl dgst -md5 -hmac Jefe`. Only the string-keyed cases from the RFC are + * usable here, because this interface takes the key and the data as C strings and measures + * both with strlen, so a vector with an embedded zero or a 0x0b key cannot be expressed. + * + * refs #37984 */ + +/* Copyright (C) 2026 The Regents of the University of California + * See kent/LICENSE or http://genome.ucsc.edu/license/ for licensing information. */ + +#include "common.h" +#include "hmac.h" + +static int errCount = 0; + +static void expect(char *what, char *got, char *want) +/* Print the answer, and say so when it is not the one expected. Printing every line rather + * than only the failures keeps the expected/ file a readable record of what this pins. */ +{ +printf("%-34s %s\n", what, got); +if (!sameString(got, want)) + { + printf(" FAIL: expected %s\n", want); + ++errCount; + } +} + +static void knownAnswers() +/* RFC 2202 test case 2, the one whose key and data are both plain text. */ +{ +char *key = "Jefe"; +char *data = "what do ya want for nothing?"; +expect("md5 rfc2202 case 2", hmacMd5(key, data), + "750c783e6ab0b503eaa86e310a5db738"); +expect("sha1 rfc2202 case 2", hmacSha1(key, data), + "effcdf6ae5eb2fa2d27416d5f184df9c259a7c79"); +} + +static void boundaryMoves() +/* The property hgLogin's signature rests on: the split between key and data is part of what + * is signed. Concatenating the two, which is what the code did before #37984, makes these + * two calls hash the same bytes. */ +{ +char *ab = hmacMd5("a", "bc"); +char *aB = hmacMd5("ab", "c"); +printf("%-34s %s\n", "md5 key a data bc", ab); +printf("%-34s %s\n", "md5 key ab data c", aB); +if (sameString(ab, aB)) + { + printf(" FAIL: the key/data boundary is not being signed\n"); + ++errCount; + } +else + printf("%-34s differ, as they must\n", " the two"); +} + +static void keyMatters() +/* A signature that does not depend on the whole key is not a signature. One bit of the salt + * has to change the answer. */ +{ +char *withSalt = hmacMd5("s3cret", "google|12345|a@example.org"); +char *withOther = hmacMd5("s3crat", "google|12345|a@example.org"); +printf("%-34s %s\n", "md5 salt s3cret", withSalt); +printf("%-34s %s\n", "md5 salt s3crat", withOther); +if (sameString(withSalt, withOther)) + { + printf(" FAIL: the key does not reach the answer\n"); + ++errCount; + } +} + +static void shapeOfTheAnswer() +/* hgLogin puts the signature in a form field and compares it with sameString, so the text has + * to be exactly this: lower case hex, no prefix, and the full width of the digest. The + * buffers in hmac.c are sized for exactly these lengths. */ +{ +char *md5 = hmacMd5("k", "d"); +char *sha1 = hmacSha1("k", "d"); +printf("%-34s %d\n", "md5 length", (int)strlen(md5)); +printf("%-34s %d\n", "sha1 length", (int)strlen(sha1)); +if (strlen(md5) != 32 || strlen(sha1) != 40) + { + printf(" FAIL: expected 32 and 40\n"); + ++errCount; + } +int i; +for (i = 0; md5[i] != '\0'; ++i) + if (strchr("0123456789abcdef", md5[i]) == NULL) + { + printf(" FAIL: '%c' is not lower case hex\n", md5[i]); + ++errCount; + break; + } +} + +static void emptyData() +/* An empty string is a legitimate thing to sign, and it must not come back empty or equal to + * the signature of anything else. */ +{ +char *empty = hmacMd5("k", ""); +printf("%-34s %s\n", "md5 empty data", empty); +if (strlen(empty) != 32 || sameString(empty, hmacMd5("k", "x"))) + { + printf(" FAIL: empty data signs wrong\n"); + ++errCount; + } +} + +int main(int argc, char *argv[]) +{ +knownAnswers(); +boundaryMoves(); +keyMatters(); +shapeOfTheAnswer(); +emptyData(); +printf("\n%d failures\n", errCount); +return errCount == 0 ? 0 : 1; +}