StudentVoice - Voting by Pointing a Camera at a QR Code
There’s a screen in the hallway at XU Exponential University that mostly shows nothing. The obvious thing to put on it is a running poll: question up top, people vote as they walk past, the result moves live.
Every way I could think of to build that was worse than leaving the screen blank. An app means installing an app for one question. A link on the screen means standing in a corridor typing a URL off a display while people walk around you.
What everyone already has is a phone camera and a university Microsoft account. So: two QR codes, one for yes and one for no. You point your camera at the answer you want. That’s the whole interaction.
The awkward part is that a QR code is a static URL, and a static URL has no idea who scanned it.
One scan has to do two things
The vote needs identity, for boring reasons. One person shouldn’t be able to vote forty times, and a screenshot of the code shouldn’t work from somebody’s sofa. We run Microsoft 365 here, so every student already has an Azure AD account and I don’t have to build a login at all.
That leaves a sequencing problem. The answer is in the QR code. The login happens at Microsoft, on a different domain, and the user comes back through a redirect. The answer has to survive that trip, and I don’t want to write it into a cookie before I know who the user is.
OAuth already has a parameter that makes the round trip, so the answer goes in state:
if(!isset($_COOKIE['xu_session'])){
header("Location: https://login.microsoftonline.com/<tenant>/oauth2/authorize"
."?client_id=<client>"
."&response_type=id_token"
."&redirect_uri=https://dev.felix.codes/qr/auth"
."&response_mode=form_post"
."&scope=openid"
."&state=".$_GET['ans'] // "y" or "n", carried through Microsoft
."&nonce=...");
die();
}
Microsoft hands state back untouched alongside the id_token. One scan by a logged-out student
does the login and the vote without ever asking them to pick their answer twice.
flowchart TD
Scan[Scan the yes or no code] --> Vote["/qr/vote/id?ans=y"]
Vote --> Cookie{Session cookie?}
Cookie -- yes --> Cast[Record vote]
Cookie -- no --> Azure[Azure AD, answer rides in state]
Azure --> Auth["/qr/auth receives id_token + state"]
Auth --> Verify[Verify signature against Microsoft's keys]
Verify --> Cast
Cast --> Result[Live result display]
This is a mild abuse of the spec and I know it. state is there for CSRF protection: you put an
unguessable value in, you check it comes back, and that’s how you know the response belongs to a
request you actually made. Using it to carry data alongside that is common enough. Using it to
carry data only, which is what I’m doing, means I’ve spent the CSRF defence on something else and
got nothing back for it.
Verifying the token by hand
response_type=id_token means Microsoft posts a signed JWT straight back instead of a code I’d
have to exchange. Less work. The work that’s left is real, though: nothing in that token is worth
anything until I’ve checked its signature against Microsoft’s published keys.
I couldn’t find a library that handles the Azure end of this, so the key handling is manual.
Microsoft publishes its signing keys as JWKS, and each key’s certificate sits in an x5c field as
bare base64. OpenSSL won’t touch that. You have to build a real PEM certificate back around it,
line wrapping and all:
function loadKeysFromAzure($string_microsoftPublicKeyURL) {
$array_keys = array();
$array_microsoftPublicKeys = json_decode(
file_get_contents($string_microsoftPublicKeyURL), true);
foreach($array_microsoftPublicKeys['keys'] as $array_publicKey) {
$string_certText = "-----BEGIN CERTIFICATE-----\r\n"
.chunk_split($array_publicKey['x5c'][0], 64)
."-----END CERTIFICATE-----\r\n";
$array_keys[$array_publicKey['kid']] = getPublicKeyFromX5C($string_certText);
}
return $array_keys;
}
function getPublicKeyFromX5C($string_certText) {
$object_cert = openssl_x509_read($string_certText);
$object_pubkey = openssl_pkey_get_public($object_cert);
$array_publicKey = openssl_pkey_get_details($object_pubkey);
return $array_publicKey['key'];
}
The keys come back indexed by kid, which is the shape firebase/php-jwt wants, so verification
is one line. The algorithm gets pinned to RS256 instead of being read out of the token’s own
header:
$decoded = JWT::decode($_POST['id_token'], $array_publicKeysWithKIDasArrayKey, array('RS256'));
That last bit is the whole point. A verifier that trusts the alg field in the header it’s busy
verifying is the classic JWT footgun, and passing the allowed algorithms in explicitly is what
closes it.
One vote per student, without storing which student
The token comes back with the student’s university email in unique_name. I don’t want that
anywhere near the votes table. A poll where the organiser can look up how you voted isn’t a poll,
it’s a survey with extra steps, and people vote differently when they suspect that’s what they’re
in.
So what goes in is a hash, and the session cookie is a random 90-character token pointing at it:
$statement = $pdo->prepare("INSERT INTO sessions VALUES (?, ?)");
$token = getToken(90);
$statement->execute(array( md5("salzIstToll".$decoded->unique_name), $token ));
setcookie('xu_session', $token, time() + 1209600, '/');
The vote goes in against that hash, with a uniqueness check and a check that the question is open right now:
$statement = $pdo->prepare(
"SELECT count(*) as c FROM questions WHERE id = ? AND NOW() BETWEEN startDate AND endDate");
For a hallway that’s enough. One account gets one vote, and afterwards there’s nothing in the database to read off.
It’s weaker than I’d like and I know it. MD5, a salt sitting as a constant in the source file, and university emails follow a completely predictable format, so anyone with the student directory and a free afternoon could rebuild the whole mapping. What I want is a per-question salt, so the same student hashes differently in every poll, and something that isn’t MD5. For a poll about whether the cafeteria should stock oat milk, this is fine. I wouldn’t ship it for anything people actually care about hiding.
Drawing a QR code that survives having a logo in it
The codes are generated per question and per answer, mapped straight off the filename:
RewriteRule ^code-n-(.*).png$ codegen.php?id=$1&ans=n [L,QSA]
RewriteRule ^code-y-(.*).png$ codegen.php?id=$1&ans=y [L,QSA]
The display asks for /code-y-7.png and gets a finished image back. codegen.php starts from a
plain black-on-white code and rewrites it pixel by pixel with GD, because I want it in XU’s blue
instead of black, and I want the background transparent so it sits on the coloured panels of the
display:
imagealphablending($QR, false);
imagesavealpha($QR, true);
$newColor = imagecolorallocatealpha($QR, 255, 255, 255, 127); // transparent
$xuColor = imagecolorallocatealpha($QR, 44, 39, 184, 0); // XU blue
for ($x = imagesx($QR); $x--; ) {
for ($y = imagesy($QR); $y--; ) {
$c = imagecolorat($QR, $x, $y);
if ($c == 16777215) {
imagesetpixel($QR, $x, $y, $newColor);
} elseif ($c == 0) {
imagesetpixel($QR, $x, $y, $xuColor);
}
}
}
Turning alpha blending off before that loop is what cost me the most time. Leave it on and setting
a fully transparent pixel composites it over whatever’s already there, which does nothing visible,
so you sit staring at a white background wondering why transparency is broken. You want
replacement, not blending. imagealphablending($QR, false) is the switch.
Then the university logo goes over the middle third of the code:
$logo_qr_width = $QR_width / 3;
$scale = $logo_width / $logo_qr_width;
$logo_qr_height = $logo_height / $scale;
imagecopyresampled($QR, $logo, $QR_width/3, $QR_height/3, 0, 0,
$logo_qr_width, $logo_qr_height, $logo_width, $logo_height);
Covering the middle of a QR code with something opaque should wreck it. The only reason it doesn’t is that the code gets requested at error correction level H. QR codes carry Reed-Solomon error correction at four levels, and H is the strongest: about 30% of the code can be missing or wrong and a reader will still get the payload out. A logo over the middle ninth sits well inside that. The request that starts the whole thing asks for it:
$imageurl = 'https://chart.googleapis.com/chart?cht=qr&chld=H|1&chs='.$size.'&chl='.urlencode($data);
chld=H|1 is doing all the work. Without it you get level L, the logo eats data modules nothing
can reconstruct, and you’ve made a nice square that no phone will read.
The display
The wall view shows the question, both codes and the split, in XU’s colours: #E42D5B behind the
no code, #00D8A3 behind the yes code, and an animated SVG wave whose height is the
yes-percentage. That’s the photo at the top of this post.
The first question I put on it was “Do you like this public display?”, which felt like the only
fair one to open with. It sat at almost exactly even. The countdown in the corner comes off
endsAt, and it’s in German while the question is in English because the chrome is hardcoded and
the question is just whatever got typed into the database.
The display polls a small JSON endpoint every five seconds:
$statement = $pdo->prepare(
"SELECT count(*) as c, avg(vote) as avg FROM votes WHERE questionid = ?");
Votes are stored as booleans, so avg(vote) * 100 is the yes-percentage directly and there’s no
counting logic anywhere. The endpoint also returns an md5 of its own payload, so the client can
tell whether anything changed without diffing the response.
Still on the list
Two things I know are broken and haven’t fixed.
The nonce in the authorize URL is a hardcoded constant. Its whole job is replay protection:
generate a fresh one per request, check that the token echoes back the one you just sent. A
constant passes that check for every token the application has ever been issued, so right now it’s
decoration.
And auth.php sends everyone to question 1 on the way back:
header("Location: https://dev.felix.codes/qr/vote/1?ans=".$_POST['state']);
The question ID is right there in the URL that got scanned, and it belongs in state next to the
answer. It works because there’s only ever one poll open at a time, which is the sort of thing
that’s true until it isn’t.
Three days of work. It’s been up in the hallway for a week now and the wave moves, which is all I wanted from it.