turnley.dev

PHP and MySQL code for faucet operators

Tools & Calculators

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.

PHP
/**
 * 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.

Also in Auth and Security