SEC-009
Generate a QR code in PHP with no dependencies
For a TOTP enrolment screen, calling an image API posts the secret to a third party. A byte-mode encoder is a few hundred lines, and two specific bugs will cost you a day.
The usual way to put a QR on a two-factor setup page is to call an image service with the otpauth:// URI in the query string. That URI contains the shared secret, so it hands a third party everything needed to generate your admin's codes — and leaves it in their logs.
Encoding it yourself is not glamorous but it is bounded: byte mode, a fixed set of versions, one error-correction level, and an SVG out the other end. No GD, no Imagick, no network call.
Two bugs account for most of the time this takes, and both produce a QR that looks perfectly plausible and will not scan.
/**
* The two mistakes that cost a day each.
*
* 1. FORMAT INFORMATION ORIENTATION
* The 15 format bits are written twice. The two copies are NOT the same
* shape: the first runs down column 8 then left along row 8; the second
* runs right along row 8 then up column 8. Swap them and the matrix looks
* right, decodes as nothing, and the data area diffs clean against a
* reference encoder — because the data IS right.
*/
for ($i = 0; $i < 15; $i++) {
$bit = ($bits >> $i) & 1;
// First copy: down column 8, then left along row 8.
if ($i < 6) { $m[$i][8] = $bit; }
elseif ($i === 6) { $m[7][8] = $bit; }
elseif ($i === 7) { $m[8][8] = $bit; }
elseif ($i === 8) { $m[8][7] = $bit; }
else { $m[8][14 - $i] = $bit; }
// Second copy: right along row 8, then up column 8.
if ($i < 8) { $m[8][$n - 1 - $i] = $bit; }
else { $m[$n - 15 + $i][8] = $bit; }
}
/**
* 2. VERSION INFORMATION IS REQUIRED FROM VERSION 7
* Below version 7 there is none, so a small payload scans and a longer one
* does not — which reads like a capacity bug and is not. Two 6x3 blocks,
* 18 BCH-coded bits, reserved BEFORE data placement so the data skips them.
*/
const VERSION_BITS = [7 => 0x07C94, 8 => 0x085BC, 9 => 0x09A99, 10 => 0x0A4D3];
if ($version >= 7) {
$v = VERSION_BITS[$version];
for ($i = 0; $i < 18; $i++) {
$bit = ($v >> $i) & 1;
$a = intdiv($i, 3);
$b = $n - 11 + ($i % 3);
$this->set($b, $a, $bit, true); // beside the top-right finder
$this->set($a, $b, $bit, true); // above the bottom-left finder
}
}
// Rendering: a path of 1x1 rects, scaled by the viewBox. No image library.
// <svg viewBox="0 0 N N" shape-rendering="crispEdges"> with one <path d="M x y h1 v1 h-1 z …">
Using it
Verify against a reference encoder, not by eye. Generate the same payload with a known-good library, compare matrices mask for mask, and only then check that a decoder reads yours.
Use a payload that exactly fills the capacity when comparing. Padding differs legitimately between encoders, so an exact-fit payload isolates real differences from cosmetic ones.
Quiet zone of four modules. Without it some scanners refuse a code that is otherwise perfect.
What bites people
A matrix that differs from a reference only in the padding region is not necessarily wrong — both are valid. A matrix that differs in the format cells always is.
Mask selection is a penalty score, and implementations legitimately disagree. Any mask produces a valid code; do not chase an exact match on which one gets chosen.
Never send the secret to an image service. That is the entire reason to write this.