from a third-party QR service, the otpauth:// URI in its address, * which handed this account's TOTP secret — the whole of the second factor — * to a third party on every page view, and did it from a plugin that makes no * outbound request of any kind. That went on 2026-09-04 and left the screen * with a key to type in by hand. * * This puts the QR back with nothing leaving the server. Scope is exactly what * an otpauth:// URI needs and no more: * * - byte mode only (a provisioning URI is ASCII; UTF-8 encodes fine too), * - error correction level M, the level every authenticator expects, * - versions 1 to 10, i.e. up to 213 bytes — an otpauth URI with a long site * name and a long e-mail address runs to about 130. * * Bigger payloads throw rather than silently producing a code that scans to * something truncated. * * Written against ISO/IEC 18004. Verified module-for-module against segno's * output for eight payloads — including a real provisioning URI and both ends * of the size range — in plugin/tests/class-qr-test.php; the fixtures record * version, mask and every module, so a wrong Reed-Solomon block, a wrong mask * choice or an off-by-one in the alignment patterns all fail loudly rather * than producing a plausible-looking square that no phone can read. * * @package GuardForge */ if ( ! defined( 'ABSPATH' ) ) { exit; } class GUARDFORGE_QR { /** * Byte-mode capacity in bytes, error correction level M, by version. */ private const CAPACITY = [ 1 => 14, 2 => 26, 3 => 42, 4 => 62, 5 => 84, 6 => 106, 7 => 122, 8 => 152, 9 => 180, 10 => 213, ]; /** * version => [ EC codewords per block, [ [block count, data codewords], … ] ] * Level M throughout. Two entries mean the version splits into two groups * whose blocks differ in length by one codeword. */ private const BLOCKS = [ 1 => [ 10, [ [ 1, 16 ] ] ], 2 => [ 16, [ [ 1, 28 ] ] ], 3 => [ 26, [ [ 1, 44 ] ] ], 4 => [ 18, [ [ 2, 32 ] ] ], 5 => [ 24, [ [ 2, 43 ] ] ], 6 => [ 16, [ [ 4, 27 ] ] ], 7 => [ 18, [ [ 4, 31 ] ] ], 8 => [ 22, [ [ 2, 38 ], [ 2, 39 ] ] ], 9 => [ 22, [ [ 3, 36 ], [ 2, 37 ] ] ], 10 => [ 26, [ [ 4, 43 ], [ 1, 44 ] ] ], ]; /** Centre coordinates of the alignment patterns, by version. */ private const ALIGN = [ 1 => [], 2 => [ 6, 18 ], 3 => [ 6, 22 ], 4 => [ 6, 26 ], 5 => [ 6, 30 ], 6 => [ 6, 34 ], 7 => [ 6, 22, 38 ], 8 => [ 6, 24, 42 ], 9 => [ 6, 26, 46 ], 10 => [ 6, 28, 50 ], ]; /** Bits of padding after the interleaved codewords, by version. */ private const REMAINDER_BITS = [ 1 => 0, 2 => 7, 3 => 7, 4 => 7, 5 => 7, 6 => 7, 7 => 0, 8 => 0, 9 => 0, 10 => 0, ]; /** Level M, as the two bits the format information carries. */ private const EC_LEVEL_BITS = 0b00; /** @var int|null log table for GF(256), primitive polynomial 0x11D */ private static $gf_exp = null; /** @var int|null */ private static $gf_log = null; /** * Encode $text and return the module matrix. * * @param string $text Payload. Encoded as bytes exactly as given. * @return int Rows of 0/1, top-left origin, no quiet zone. * @throws InvalidArgumentException When the payload is empty or too long. */ public static function matrix( $text ) { $text = (string) $text; $len = strlen( $text ); if ( $len === 0 ) { throw new InvalidArgumentException( 'Nothing to encode.' ); } $version = 0; foreach ( self::CAPACITY as $candidate => $bytes ) { if ( $len <= $bytes ) { $version = $candidate; break; } } if ( $version === 0 ) { throw new InvalidArgumentException( sprintf( 'Payload of %d bytes exceeds the %d this encoder handles.', (int) $len, (int) self::CAPACITY[10] ) ); } $codewords = self::interleave( self::codewords( $text, $version ), $version ); // Function patterns first, on a matrix that also records which modules // are reserved — the data path must skip every one of them. $size = self::size( $version ); $modules = array_fill( 0, $size, array_fill( 0, $size, 0 ) ); $reserved = array_fill( 0, $size, array_fill( 0, $size, false ) ); self::draw_function_patterns( $modules, $reserved, $version ); self::place_data( $modules, $reserved, $codewords, $version ); // Every mask is tried and scored; the specification's penalty rules // pick the winner, and a wrong scoring implementation shows up as a // different mask number than the reference encoder chose. $best = null; $best_score = PHP_INT_MAX; for ( $mask = 0; $mask < 8; $mask++ ) { $candidate = self::apply_mask( $modules, $reserved, $mask ); self::draw_format_info( $candidate, $mask ); $score = self::penalty( $candidate ); if ( $score < $best_score ) { $best_score = $score; $best = $candidate; } } return $best; } /** * Encode $text and return it as an element. * * Self-contained markup with no external reference of any kind: one path * of dark modules over a light rect, sized in modules through viewBox so * the caller decides pixels in CSS. * * @param string $text Payload. * @param array $args label => accessible name (default 'QR code'); * quiet => quiet-zone modules (default 4, the minimum * the specification allows; scanners need it). * @return string */ public static function svg( $text, array $args = [] ) { $label = isset( $args['label'] ) ? (string) $args['label'] : 'QR code'; $quiet = isset( $args['quiet'] ) ? max( 0, (int) $args['quiet'] ) : 4; $matrix = self::matrix( $text ); $size = count( $matrix ) + ( $quiet * 2 ); $path = ''; foreach ( $matrix as $y => $row ) { $x = 0; $n = count( $row ); while ( $x < $n ) { if ( ! $row[ $x ] ) { $x++; continue; } $run = 1; while ( $x + $run < $n && $row[ $x + $run ] ) { $run++; } // One horizontal run per command instead of one per // module: the same picture in a fraction of the markup. $path .= sprintf( 'M%d %dh%dv1h-%dz', $x + $quiet, $y + $quiet, $run, $run ); $x += $run; } } return sprintf( '' . '' . '' . '', $size, htmlspecialchars( $label, ENT_QUOTES, 'UTF-8' ), $path ); } // ── bitstream ──────────────────────────────────────────────────────── /** Matrix side length in modules. */ private static function size( $version ) { return ( $version * 4 ) + 17; } /** * Mode indicator, length, payload, terminator, padding — as codewords. * * @return int data codewords, exactly as many as the version holds. */ private static function codewords( $text, $version ) { $count_bits = $version >= 10 ? 16 : 8; $bits = '0100'; // byte mode $bits .= str_pad( decbin( strlen( $text ) ), $count_bits, '0', STR_PAD_LEFT ); foreach ( str_split( $text ) as $char ) { $bits .= str_pad( decbin( ord( $char ) ), 8, '0', STR_PAD_LEFT ); } $total_data = 0; foreach ( self::BLOCKS[ $version ][1] as list( $count, $data_cw ) ) { $total_data += $count * $data_cw; } $capacity_bits = $total_data * 8; // Terminator: up to four zero bits, fewer if the stream nearly fills. $bits .= str_repeat( '0', min( 4, $capacity_bits - strlen( $bits ) ) ); // …then to a byte boundary, then the two alternating pad codewords. if ( strlen( $bits ) % 8 !== 0 ) { $bits .= str_repeat( '0', 8 - ( strlen( $bits ) % 8 ) ); } $pad = [ 0xEC, 0x11 ]; for ( $i = 0; strlen( $bits ) < $capacity_bits; $i++ ) { $bits .= str_pad( decbin( $pad[ $i % 2 ] ), 8, '0', STR_PAD_LEFT ); } $out = []; foreach ( str_split( $bits, 8 ) as $byte ) { $out[] = bindec( $byte ); } return $out; } /** * Split into blocks, add Reed-Solomon parity, interleave. * * @param int $data * @return int the final codeword stream, data interleaved then EC. */ private static function interleave( array $data, $version ) { list( $ec_per_block, $groups ) = self::BLOCKS[ $version ]; $data_blocks = []; $ec_blocks = []; $offset = 0; foreach ( $groups as list( $count, $data_cw ) ) { for ( $i = 0; $i < $count; $i++ ) { $block = array_slice( $data, $offset, $data_cw ); $offset += $data_cw; $data_blocks[] = $block; $ec_blocks[] = self::reed_solomon( $block, $ec_per_block ); } } $out = []; $longest = 0; foreach ( $data_blocks as $block ) { $longest = max( $longest, count( $block ) ); } for ( $i = 0; $i < $longest; $i++ ) { foreach ( $data_blocks as $block ) { if ( isset( $block[ $i ] ) ) { $out[] = $block[ $i ]; } } } for ( $i = 0; $i < $ec_per_block; $i++ ) { foreach ( $ec_blocks as $block ) { $out[] = $block[ $i ]; } } return $out; } // ── GF(256) ────────────────────────────────────────────────────────── private static function init_gf() { if ( self::$gf_exp !== null ) { return; } self::$gf_exp = array_fill( 0, 512, 0 ); self::$gf_log = array_fill( 0, 256, 0 ); $x = 1; for ( $i = 0; $i < 255; $i++ ) { self::$gf_exp[ $i ] = $x; self::$gf_log[ $x ] = $i; $x <<= 1; if ( $x & 0x100 ) { $x ^= 0x11D; // the QR primitive polynomial } } for ( $i = 255; $i < 512; $i++ ) { self::$gf_exp[ $i ] = self::$gf_exp[ $i - 255 ]; } } private static function gf_mul( $a, $b ) { if ( $a === 0 || $b === 0 ) { return 0; } return self::$gf_exp[ self::$gf_log[ $a ] + self::$gf_log[ $b ] ]; } /** * @param int $data * @return int $degree parity codewords. */ private static function reed_solomon( array $data, $degree ) { self::init_gf(); // Generator polynomial: (x - α⁰)(x - α¹)…(x - α^(degree-1)). $gen = [ 1 ]; for ( $i = 0; $i < $degree; $i++ ) { // Index 0 is the leading coefficient, so multiplying by (x + αⁱ) // keeps each term where it is for the x half and shifts it one // place down for the αⁱ half. The other way round produces a // polynomial that is no longer monic, and every parity codeword // comes out wrong while still looking like plausible noise. $next = array_fill( 0, count( $gen ) + 1, 0 ); foreach ( $gen as $j => $coeff ) { $next[ $j ] ^= $coeff; $next[ $j + 1 ] ^= self::gf_mul( $coeff, self::$gf_exp[ $i ] ); } $gen = $next; } $remainder = array_merge( $data, array_fill( 0, $degree, 0 ) ); for ( $i = 0; $i < count( $data ); $i++ ) { $factor = $remainder[ $i ]; if ( $factor === 0 ) { continue; } foreach ( $gen as $j => $coeff ) { $remainder[ $i + $j ] ^= self::gf_mul( $coeff, $factor ); } } return array_slice( $remainder, count( $data ) ); } // ── geometry ───────────────────────────────────────────────────────── private static function draw_function_patterns( array &$m, array &$r, $version ) { $size = self::size( $version ); // Three finders, each with its separator. foreach ( [ [ 0, 0 ], [ $size - 7, 0 ], [ 0, $size - 7 ] ] as list( $fx, $fy ) ) { for ( $dy = -1; $dy <= 7; $dy++ ) { for ( $dx = -1; $dx <= 7; $dx++ ) { $x = $fx + $dx; $y = $fy + $dy; if ( $x < 0 || $y < 0 || $x >= $size || $y >= $size ) { continue; } // Inside the 7×7: the outer ring and the 3×3 core are // dark, the rest light. Outside it is the separator, which // is light — and reserved, so masking never touches it. $inside = $dx >= 0 && $dx <= 6 && $dy >= 0 && $dy <= 6; $on_border = $inside && ( $dx === 0 || $dx === 6 || $dy === 0 || $dy === 6 ); $in_core = $dx >= 2 && $dx <= 4 && $dy >= 2 && $dy <= 4; $m[ $y ][ $x ] = ( $on_border || $in_core ) ? 1 : 0; $r[ $y ][ $x ] = true; } } } // Timing patterns. for ( $i = 8; $i < $size - 8; $i++ ) { $bit = ( $i % 2 === 0 ) ? 1 : 0; $m[6][ $i ] = $bit; $r[6][ $i ] = true; $m[ $i ][6] = $bit; $r[ $i ][6] = true; } // Alignment patterns, at every pairing of the version's coordinates // except the three that would land on a finder. $centres = self::ALIGN[ $version ]; foreach ( $centres as $cy ) { foreach ( $centres as $cx ) { $on_finder = ( $cx === 6 && $cy === 6 ) || ( $cx === 6 && $cy === $size - 7 ) || ( $cx === $size - 7 && $cy === 6 ); if ( $on_finder ) { continue; } for ( $dy = -2; $dy <= 2; $dy++ ) { for ( $dx = -2; $dx <= 2; $dx++ ) { $on = ( abs( $dx ) === 2 || abs( $dy ) === 2 || ( $dx === 0 && $dy === 0 ) ) ? 1 : 0; $m[ $cy + $dy ][ $cx + $dx ] = $on; $r[ $cy + $dy ][ $cx + $dx ] = true; } } } } // The module that is always dark. $m[ $size - 8 ][8] = 1; $r[ $size - 8 ][8] = true; // Format information areas — written after masking, reserved now. for ( $i = 0; $i < 9; $i++ ) { if ( $i !== 6 ) { $r[8][ $i ] = true; $r[ $i ][8] = true; } } for ( $i = 0; $i < 8; $i++ ) { $r[8][ $size - 1 - $i ] = true; $r[ $size - 1 - $i ][8] = true; } // Version information, versions 7 and up: two 3×6 blocks. if ( $version >= 7 ) { $bits = self::version_bits( $version ); for ( $i = 0; $i < 18; $i++ ) { $bit = ( $bits >> $i ) & 1; $x = (int) ( $i / 3 ); $y = $size - 11 + ( $i % 3 ); $m[ $y ][ $x ] = $bit; $r[ $y ][ $x ] = true; $m[ $x ][ $y ] = $bit; $r[ $x ][ $y ] = true; } } } /** BCH(18,6) version information. */ private static function version_bits( $version ) { $rem = $version; for ( $i = 0; $i < 12; $i++ ) { $rem = ( $rem << 1 ) ^ ( ( $rem >> 11 ) * 0x1F25 ); } return ( $version << 12 ) | $rem; } /** * Zigzag placement: two-module columns walked bottom-to-top then * top-to-bottom, right to left, skipping the vertical timing column. * * @param int $codewords */ private static function place_data( array &$m, array $r, array $codewords, $version ) { $size = self::size( $version ); $bits = ''; foreach ( $codewords as $cw ) { $bits .= str_pad( decbin( $cw ), 8, '0', STR_PAD_LEFT ); } $bits .= str_repeat( '0', self::REMAINDER_BITS[ $version ] ); $i = 0; $up = true; for ( $right = $size - 1; $right > 0; $right -= 2 ) { if ( $right === 6 ) { $right = 5; // column 6 is the timing pattern } for ( $step = 0; $step < $size; $step++ ) { $y = $up ? ( $size - 1 - $step ) : $step; foreach ( [ $right, $right - 1 ] as $x ) { if ( $r[ $y ][ $x ] ) { continue; } $m[ $y ][ $x ] = isset( $bits[ $i ] ) ? (int) $bits[ $i ] : 0; $i++; } } $up = ! $up; } } private static function apply_mask( array $m, array $r, $mask ) { $size = count( $m ); for ( $y = 0; $y < $size; $y++ ) { for ( $x = 0; $x < $size; $x++ ) { if ( $r[ $y ][ $x ] ) { continue; } switch ( $mask ) { case 0: $flip = ( ( $y + $x ) % 2 ) === 0; break; case 1: $flip = ( $y % 2 ) === 0; break; case 2: $flip = ( $x % 3 ) === 0; break; case 3: $flip = ( ( $y + $x ) % 3 ) === 0; break; case 4: $flip = ( ( intdiv( $y, 2 ) + intdiv( $x, 3 ) ) % 2 ) === 0; break; case 5: $flip = ( ( ( $y * $x ) % 2 ) + ( ( $y * $x ) % 3 ) ) === 0; break; case 6: $flip = ( ( ( ( $y * $x ) % 2 ) + ( ( $y * $x ) % 3 ) ) % 2 ) === 0; break; default: $flip = ( ( ( ( $y + $x ) % 2 ) + ( ( $y * $x ) % 3 ) ) % 2 ) === 0; break; } if ( $flip ) { $m[ $y ][ $x ] ^= 1; } } } return $m; } /** BCH(15,5) format information, XOR-masked with 0x5412. */ private static function draw_format_info( array &$m, $mask ) { $size = count( $m ); $data = ( self::EC_LEVEL_BITS << 3 ) | $mask; $rem = $data; for ( $i = 0; $i < 10; $i++ ) { $rem = ( $rem << 1 ) ^ ( ( $rem >> 9 ) * 0x537 ); } $bits = ( ( $data << 10 ) | $rem ) ^ 0x5412; // Copy one: around the top-left finder. Copy two: split between the // bottom-left and top-right finders. Both carry the same 15 bits. for ( $i = 0; $i <= 5; $i++ ) { $m[ $i ][8] = ( $bits >> $i ) & 1; } $m[7][8] = ( $bits >> 6 ) & 1; $m[8][8] = ( $bits >> 7 ) & 1; $m[8][7] = ( $bits >> 8 ) & 1; for ( $i = 9; $i < 15; $i++ ) { $m[8][ 14 - $i ] = ( $bits >> $i ) & 1; } for ( $i = 0; $i < 8; $i++ ) { $m[8][ $size - 1 - $i ] = ( $bits >> $i ) & 1; } for ( $i = 8; $i < 15; $i++ ) { $m[ $size - 15 + $i ][8] = ( $bits >> $i ) & 1; } } /** * The four penalty rules. Lower is better; the mask with the lowest total * is the one a decoder will find easiest to read. */ private static function penalty( array $m ) { $size = count( $m ); $score = 0; // Rule 1 — runs of five or more of the same colour, each direction. for ( $pass = 0; $pass < 2; $pass++ ) { for ( $a = 0; $a < $size; $a++ ) { $run = 1; $prev = -1; for ( $b = 0; $b < $size; $b++ ) { $val = $pass === 0 ? $m[ $a ][ $b ] : $m[ $b ][ $a ]; if ( $val === $prev ) { $run++; continue; } if ( $run >= 5 ) { $score += 3 + ( $run - 5 ); } $prev = $val; $run = 1; } if ( $run >= 5 ) { $score += 3 + ( $run - 5 ); } } } // Rule 2 — every 2×2 block of one colour. for ( $y = 0; $y < $size - 1; $y++ ) { for ( $x = 0; $x < $size - 1; $x++ ) { $v = $m[ $y ][ $x ]; if ( $v === $m[ $y ][ $x + 1 ] && $v === $m[ $y + 1 ][ $x ] && $v === $m[ $y + 1 ][ $x + 1 ] ) { $score += 3; } } } // Rule 3 — the finder-lookalike 1:1:3:1:1 pattern with four light // modules on either side, in either direction. $needles = [ [ 1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0 ], [ 0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1 ], ]; for ( $pass = 0; $pass < 2; $pass++ ) { for ( $a = 0; $a < $size; $a++ ) { for ( $b = 0; $b <= $size - 11; $b++ ) { foreach ( $needles as $needle ) { $hit = true; for ( $k = 0; $k < 11; $k++ ) { $val = $pass === 0 ? $m[ $a ][ $b + $k ] : $m[ $b + $k ][ $a ]; if ( $val !== $needle[ $k ] ) { $hit = false; break; } } if ( $hit ) { $score += 40; } } } } } // Rule 4 — how far the dark:light ratio strays from half. $dark = 0; foreach ( $m as $row ) { $dark += array_sum( $row ); } $percent = ( $dark * 100 ) / ( $size * $size ); $score += ( (int) ( abs( $percent - 50 ) / 5 ) ) * 10; return $score; } }