feat(tui): render real QR codes; verified module-for-module against the reference encoder

The TUI had a QR action that answered "QR rendering is not available in this
build". The stated reason was that a QR library is not worth a dependency in a
process holding unlocked seeds — which is true, and is why this implements the
encoder instead: byte mode, versions 1-40, all four error-correction levels,
zero dependencies.

A wrong QR code is worse than no QR code. It scans cleanly, yields the wrong
secret, and the user finds out when their 2FA stops working — so this is
verified rather than eyeballed:

  - The block-structure table was extracted programmatically from qrcode's
    RS_BLOCK_TABLE rather than transcribed. 160 rows of three-number triples
    are exactly the kind of data a human copies wrong once and never notices.
  - qr.json holds reference matrices from that library, and the tests compare
    EVERY module — 10 cases spanning all four EC levels and versions 1 to 17,
    including past version 7 where version-information blocks start being
    written.
  - Generation moved into scripts/generate_ts_port_fixtures.py, so the
    fixture-drift CI job proves the committed fixture came from the committed
    generator, like every other fixture. Regenerating produced byte-identical
    output for all pre-existing fixtures.

Two things are pinned so the comparison is between implementations rather than
between configurations. Byte mode is forced, because the reference
auto-selects alphanumeric for an all-uppercase string — a different encoding
of the same text, not a different answer. And the mask is pinned, because the
two disagree about mask SELECTION: the spec scores the finished symbol, while
the reference scores one with its format modules blanked and its dark module
light (makeImpl(test=True, ...)), which is not a legal symbol. All eight masks
produce valid scannable codes, so the selection rule is checked as a property
instead of reproducing a known quirk to make an equality assertion pass. At a
pinned mask the matrices are identical, which is what establishes that the
data encoding, Reed-Solomon, interleaving, placement, format and version
information are all correct.

In the TUI, TOTP entries encode their otpauth:// URI so an authenticator can
consume them; everything else encodes its own value. Secret Mode suppresses
the code entirely — a QR code is the secret in a form a camera reads from
across a room, so the setting that exists to keep secrets off the screen
applies here more than anywhere — and an entry too large to encode says so
rather than looking unavailable.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-19 09:40:13 -04:00
1 parent 46ff51f3d6
commit 51f1358da4
10 files changed
+21451 -9

No files matched your search

-1
View File
@@ -101,7 +101,6 @@ export function capabilities(): Record<string, unknown> {
"approval gates and high-risk partitions",
"semantic (vector search) command group",
"Python's v2/v3 TUIs (interactive mode follows the legacy v1 menus)",
"QR code display in the TUI (no QR encoder in this build)",
],
};
}
+34 -7
View File
@@ -48,6 +48,7 @@ import { atomicWrite, openVault, saveVaultHoldingLock, withVaultLock, type Opene
import { entryMetadata, refFor } from "../refs.js";
import { materializeSecret, type MaterializedSecret } from "../secrets.js";
import { clipboardSink } from "../sinks.js";
import { encodeQr, renderQrText } from "@seedpass/core";
import { createIndexBackup, type BackupResult } from "../backups.js";
import {
loadConfig,
@@ -901,14 +902,40 @@ async function editEntryMenu(s: Session, id: string): Promise<void> {
}
async function showQr(s: Session, id: string, entry: Entry): Promise<void> {
// Python renders a QR block in the terminal. This build does not have a QR
// encoder, and inventing one for a display convenience is not worth a
// dependency in a process that holds unlocked seeds — say so plainly and
// offer the underlying value instead.
warn(s.ui, "QR rendering is not available in this build.");
const kind = kindOf(entry);
// A QR code IS the secret, in a form a camera reads from across a room.
// Secret Mode exists to keep secrets off the screen, so it applies here
// more than anywhere: a shoulder-surfer does not even have to read
// accurately.
if (secretModeOn(s)) {
warn(s.ui, "Secret Mode is on, so nothing is drawn on screen.");
warn(s.ui, "Turn it off in Settings if you need to scan this.");
await pause(s.ui);
return;
}
warn(s.ui, "This draws the secret on screen as a scannable code.");
if (!(await confirm(s.ui, "Continue?"))) return;
const secret = sessionSecret(s, id, entry);
if (await confirm(s.ui, "Show the value it would encode instead?")) {
await reveal(s, secret.value, secret.descriptor);
// TOTP entries scan into an authenticator as an otpauth:// URI; everything
// else is encoded as its own value.
const payload = kind === "totp" ? totpUri(entry, s.vault.mnemonic) : secret.value;
try {
// Level M: the middle of the four, and the one authenticator apps are
// usually shown with. A terminal redraw is cheap, so robustness costs
// little here.
s.ui.say();
s.ui.say(renderQrText(encodeQr(payload, { level: "M" })));
s.ui.say();
s.ui.say(`${secret.descriptor} — scan before leaving this screen.`);
} catch (e) {
// Too large to encode is the realistic failure (a long document), and it
// must not look like the secret is unavailable.
fail(s.ui, `Cannot render a QR code for this entry: ${(e as Error).message}`);
if (await confirm(s.ui, "Show the value instead?")) {
await reveal(s, payload, secret.descriptor);
}
}
await pause(s.ui);
}
+34
View File
@@ -29,6 +29,7 @@ import {
import { runTui } from "../src/tui/app.js";
import { AppDir, INDEX_FILENAME, PARENT_SEED_FILENAME } from "../src/appDir.js";
import { openVault } from "../src/vaultFile.js";
import { mutateConfig } from "../src/configFile.js";
import type { Ui } from "../src/tui/console.js";
const MNEMONIC = mnemonics["abandon12"]!;
@@ -201,6 +202,39 @@ describe("additional backup location", () => {
});
});
describe("QR display", () => {
it("renders a scannable code for a TOTP entry, encoding the otpauth URI", async () => {
// Retrieve > email-2fa > q(QR) > confirm
const ui = await run("2", "email-2fa", "q", "y", "", "", "");
// Half-block glyphs are how a square module is drawn in a terminal cell.
expect(ui.text).toMatch(/[█▄▀]/);
const qrLines = ui.text.split("\n").filter((l) => /^[█▄▀ ]{20,}$/.test(l));
expect(qrLines.length).toBeGreaterThan(8);
});
it("refuses to draw a QR code while Secret Mode is on", async () => {
// A QR code is the secret in a form a camera reads across a room, so the
// setting whose whole purpose is keeping secrets off the screen has to
// win here.
const app = new AppDir(appDir);
const fp = FINGERPRINT;
await mutateConfig(app.profileDir(fp), MNEMONIC, (cfg) => {
cfg["secret_mode_enabled"] = true;
});
const ui = await run("2", "email-2fa", "q", "", "", "");
expect(ui.text).toContain("Secret Mode is on");
expect(ui.text).not.toMatch(/[█▄▀]{20,}/);
await mutateConfig(app.profileDir(fp), MNEMONIC, (cfg) => {
cfg["secret_mode_enabled"] = false;
});
});
it("draws nothing if the user declines the warning", async () => {
const ui = await run("2", "email-2fa", "q", "n", "", "", "");
expect(ui.text).not.toMatch(/[█▄▀]{20,}/);
});
});
describe("main menu", () => {
it("offers Python's eight items, in Python's order", async () => {
const ui = await run();
+1
View File
@@ -87,6 +87,7 @@ export {
needsMigration,
SchemaMigrationError,
} from "./schema/migrations.js";
export { encodeQr, renderQrText, EC_LEVELS, type QrMatrix, type EcLevel } from "./util/qr.js";
export {
findDerivationCollisions,
APP32_KINDS,
+519
View File
@@ -0,0 +1,519 @@
/**
* QR encoding (byte mode), zero dependencies.
*
* The TUI wants to show a TOTP `otpauth://` URI or a key as a scannable code.
* Pulling in a QR library for that would mean adding a dependency to a
* process that holds unlocked parent seeds, which is not a trade worth making
* for a display convenience — so this implements the encoder instead.
*
* A wrong QR code is worse than no QR code: it scans cleanly and yields the
* wrong secret, and the user has no way to notice until their 2FA stops
* working. The block-structure table below was extracted programmatically
* from the reference `qrcode` Python library rather than transcribed, and
* `test/qr.test.ts` compares the full module matrix against that library's
* output for a spread of inputs, versions and error-correction levels.
*
* Byte mode only. That covers every string the TUI shows (URIs, bech32 keys,
* base32 secrets); numeric and alphanumeric modes would encode the same data
* more compactly but add branches with no user-visible benefit here.
*
* One deliberate divergence from that reference library: mask selection. The
* spec picks the mask with the lowest penalty on the finished symbol, which is
* what happens below. `qrcode` scores each candidate with its format modules
* blanked and its dark module light (`makeImpl(test=True, ...)`) — i.e. it
* scores a symbol that is not a legal one — and so occasionally picks a
* different mask. Every one of the eight masks yields a valid, scannable code;
* the choice only trades off scanning robustness. The tests therefore pin the
* mask when comparing matrices, and check the selection rule separately,
* rather than reproducing a known quirk to make an equality assertion pass.
*/
/** Error-correction level, in the spec's own order. */
export const EC_LEVELS = ["L", "M", "Q", "H"] as const;
export type EcLevel = (typeof EC_LEVELS)[number];
/**
* Block structure per version and EC level.
*
* Each entry is a flat list of (blockCount, totalCodewords, dataCodewords)
* triples. Extracted from qrcode.base.RS_BLOCK_TABLE; see the file header for
* why it is not typed out by hand.
*/
const RS_BLOCK_TABLE: number[][][] = [
/* v1 */ [[1,26,19], [1,26,16], [1,26,13], [1,26,9]],
/* v2 */ [[1,44,34], [1,44,28], [1,44,22], [1,44,16]],
/* v3 */ [[1,70,55], [1,70,44], [2,35,17], [2,35,13]],
/* v4 */ [[1,100,80], [2,50,32], [2,50,24], [4,25,9]],
/* v5 */ [[1,134,108], [2,67,43], [2,33,15,2,34,16], [2,33,11,2,34,12]],
/* v6 */ [[2,86,68], [4,43,27], [4,43,19], [4,43,15]],
/* v7 */ [[2,98,78], [4,49,31], [2,32,14,4,33,15], [4,39,13,1,40,14]],
/* v8 */ [[2,121,97], [2,60,38,2,61,39], [4,40,18,2,41,19], [4,40,14,2,41,15]],
/* v9 */ [[2,146,116], [3,58,36,2,59,37], [4,36,16,4,37,17], [4,36,12,4,37,13]],
/* v10 */ [[2,86,68,2,87,69], [4,69,43,1,70,44], [6,43,19,2,44,20], [6,43,15,2,44,16]],
/* v11 */ [[4,101,81], [1,80,50,4,81,51], [4,50,22,4,51,23], [3,36,12,8,37,13]],
/* v12 */ [[2,116,92,2,117,93], [6,58,36,2,59,37], [4,46,20,6,47,21], [7,42,14,4,43,15]],
/* v13 */ [[4,133,107], [8,59,37,1,60,38], [8,44,20,4,45,21], [12,33,11,4,34,12]],
/* v14 */ [[3,145,115,1,146,116], [4,64,40,5,65,41], [11,36,16,5,37,17], [11,36,12,5,37,13]],
/* v15 */ [[5,109,87,1,110,88], [5,65,41,5,66,42], [5,54,24,7,55,25], [11,36,12,7,37,13]],
/* v16 */ [[5,122,98,1,123,99], [7,73,45,3,74,46], [15,43,19,2,44,20], [3,45,15,13,46,16]],
/* v17 */ [[1,135,107,5,136,108], [10,74,46,1,75,47], [1,50,22,15,51,23], [2,42,14,17,43,15]],
/* v18 */ [[5,150,120,1,151,121], [9,69,43,4,70,44], [17,50,22,1,51,23], [2,42,14,19,43,15]],
/* v19 */ [[3,141,113,4,142,114], [3,70,44,11,71,45], [17,47,21,4,48,22], [9,39,13,16,40,14]],
/* v20 */ [[3,135,107,5,136,108], [3,67,41,13,68,42], [15,54,24,5,55,25], [15,43,15,10,44,16]],
/* v21 */ [[4,144,116,4,145,117], [17,68,42], [17,50,22,6,51,23], [19,46,16,6,47,17]],
/* v22 */ [[2,139,111,7,140,112], [17,74,46], [7,54,24,16,55,25], [34,37,13]],
/* v23 */ [[4,151,121,5,152,122], [4,75,47,14,76,48], [11,54,24,14,55,25], [16,45,15,14,46,16]],
/* v24 */ [[6,147,117,4,148,118], [6,73,45,14,74,46], [11,54,24,16,55,25], [30,46,16,2,47,17]],
/* v25 */ [[8,132,106,4,133,107], [8,75,47,13,76,48], [7,54,24,22,55,25], [22,45,15,13,46,16]],
/* v26 */ [[10,142,114,2,143,115], [19,74,46,4,75,47], [28,50,22,6,51,23], [33,46,16,4,47,17]],
/* v27 */ [[8,152,122,4,153,123], [22,73,45,3,74,46], [8,53,23,26,54,24], [12,45,15,28,46,16]],
/* v28 */ [[3,147,117,10,148,118], [3,73,45,23,74,46], [4,54,24,31,55,25], [11,45,15,31,46,16]],
/* v29 */ [[7,146,116,7,147,117], [21,73,45,7,74,46], [1,53,23,37,54,24], [19,45,15,26,46,16]],
/* v30 */ [[5,145,115,10,146,116], [19,75,47,10,76,48], [15,54,24,25,55,25], [23,45,15,25,46,16]],
/* v31 */ [[13,145,115,3,146,116], [2,74,46,29,75,47], [42,54,24,1,55,25], [23,45,15,28,46,16]],
/* v32 */ [[17,145,115], [10,74,46,23,75,47], [10,54,24,35,55,25], [19,45,15,35,46,16]],
/* v33 */ [[17,145,115,1,146,116], [14,74,46,21,75,47], [29,54,24,19,55,25], [11,45,15,46,46,16]],
/* v34 */ [[13,145,115,6,146,116], [14,74,46,23,75,47], [44,54,24,7,55,25], [59,46,16,1,47,17]],
/* v35 */ [[12,151,121,7,152,122], [12,75,47,26,76,48], [39,54,24,14,55,25], [22,45,15,41,46,16]],
/* v36 */ [[6,151,121,14,152,122], [6,75,47,34,76,48], [46,54,24,10,55,25], [2,45,15,64,46,16]],
/* v37 */ [[17,152,122,4,153,123], [29,74,46,14,75,47], [49,54,24,10,55,25], [24,45,15,46,46,16]],
/* v38 */ [[4,152,122,18,153,123], [13,74,46,32,75,47], [48,54,24,14,55,25], [42,45,15,32,46,16]],
/* v39 */ [[20,147,117,4,148,118], [40,75,47,7,76,48], [43,54,24,22,55,25], [10,45,15,67,46,16]],
/* v40 */ [[19,148,118,6,149,119], [18,75,47,31,76,48], [34,54,24,34,55,25], [20,45,15,61,46,16]],
];
/** Galois field GF(256) with the QR primitive polynomial 0x11d. */
const EXP = new Uint8Array(512);
const LOG = new Uint8Array(256);
(() => {
let x = 1;
for (let i = 0; i < 255; i++) {
EXP[i] = x;
LOG[x] = i;
x <<= 1;
if (x & 0x100) x ^= 0x11d;
}
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]!;
})();
function gfMul(a: number, b: number): number {
if (a === 0 || b === 0) return 0;
return EXP[LOG[a]! + LOG[b]!]!;
}
/** Generator polynomial for `degree` error-correction codewords. */
function rsGenerator(degree: number): Uint8Array {
let poly = new Uint8Array([1]);
for (let i = 0; i < degree; i++) {
const next = new Uint8Array(poly.length + 1);
for (let j = 0; j < poly.length; j++) {
next[j] = next[j]! ^ poly[j]!;
next[j + 1] = next[j + 1]! ^ gfMul(poly[j]!, EXP[i]!);
}
poly = next;
}
return poly;
}
/** Reed-Solomon remainder for one block. */
function rsEncode(data: Uint8Array, ecCount: number): Uint8Array {
const gen = rsGenerator(ecCount);
const res = new Uint8Array(data.length + ecCount);
res.set(data);
for (let i = 0; i < data.length; i++) {
const factor = res[i]!;
if (factor === 0) continue;
for (let j = 0; j < gen.length; j++) {
res[i + j] = res[i + j]! ^ gfMul(gen[j]!, factor);
}
}
return res.slice(data.length);
}
/** Alignment-pattern centre coordinates per version (spec Annex E). */
function alignmentPositions(version: number): number[] {
if (version === 1) return [];
const count = Math.floor(version / 7) + 2;
const size = version * 4 + 17;
// The spec's step rule: last coordinate is size-7, first is 6, and the
// interval is even.
const step = version === 32 ? 26 : Math.ceil((size - 13) / (2 * count - 2)) * 2;
const positions = [6];
for (let i = count - 1; i >= 1; i--) positions.push(size - 7 - (count - 1 - i) * step);
return positions.slice(0, 1).concat(positions.slice(1).sort((a, b) => a - b));
}
const FORMAT_EC_BITS: Record<EcLevel, number> = { L: 1, M: 0, Q: 3, H: 2 };
/** BCH(15,5) format information, including the fixed XOR mask. */
function formatBits(level: EcLevel, mask: number): number {
const data = (FORMAT_EC_BITS[level] << 3) | mask;
let rem = data;
for (let i = 0; i < 10; i++) {
rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
}
return ((data << 10) | rem) ^ 0x5412;
}
/** BCH(18,6) version information, for versions 7 and up. */
function versionBits(version: number): number {
let rem = version;
for (let i = 0; i < 12; i++) {
rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
}
return (version << 12) | rem;
}
function maskAt(mask: number, row: number, col: number): boolean {
switch (mask) {
case 0: return (row + col) % 2 === 0;
case 1: return row % 2 === 0;
case 2: return col % 3 === 0;
case 3: return (row + col) % 3 === 0;
case 4: return (Math.floor(row / 2) + Math.floor(col / 3)) % 2 === 0;
case 5: return ((row * col) % 2) + ((row * col) % 3) === 0;
case 6: return (((row * col) % 2) + ((row * col) % 3)) % 2 === 0;
default: return (((row + col) % 2) + ((row * col) % 3)) % 2 === 0;
}
}
export interface QrMatrix {
version: number;
level: EcLevel;
mask: number;
size: number;
/** Row-major; true is a dark module. */
modules: boolean[][];
}
/** Data capacity in bytes for byte mode at this version and level. */
function byteCapacity(version: number, level: EcLevel): number {
const spec = RS_BLOCK_TABLE[version - 1]![EC_LEVELS.indexOf(level)]!;
let dataCodewords = 0;
for (let i = 0; i < spec.length; i += 3) {
dataCodewords += spec[i]! * spec[i + 2]!;
}
const countBits = version < 10 ? 8 : 16;
// 4 mode bits + the character-count indicator.
return dataCodewords - Math.ceil((4 + countBits) / 8);
}
function smallestVersion(byteLength: number, level: EcLevel): number {
for (let version = 1; version <= 40; version++) {
if (byteCapacity(version, level) >= byteLength) return version;
}
throw new Error(
`${byteLength} bytes does not fit in a QR code at error-correction level ${level} ` +
`(max ${byteCapacity(40, level)})`,
);
}
/** Mode indicator + count + payload + terminator + padding, as codewords. */
function buildDataCodewords(data: Uint8Array, version: number, level: EcLevel): Uint8Array {
const spec = RS_BLOCK_TABLE[version - 1]![EC_LEVELS.indexOf(level)]!;
let totalData = 0;
for (let i = 0; i < spec.length; i += 3) totalData += spec[i]! * spec[i + 2]!;
const bits: number[] = [];
const push = (value: number, length: number): void => {
for (let i = length - 1; i >= 0; i--) bits.push((value >>> i) & 1);
};
push(0b0100, 4); // byte mode
push(data.length, version < 10 ? 8 : 16);
for (const byte of data) push(byte, 8);
// Terminator, up to four zero bits, then pad to a byte boundary.
for (let i = 0; i < 4 && bits.length < totalData * 8; i++) bits.push(0);
while (bits.length % 8 !== 0) bits.push(0);
const codewords = new Uint8Array(totalData);
for (let i = 0; i < bits.length / 8; i++) {
let byte = 0;
for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i * 8 + j]!;
codewords[i] = byte;
}
// Alternating pad codewords, as the spec prescribes.
for (let i = Math.ceil(bits.length / 8); i < totalData; i++) {
codewords[i] = (i - Math.ceil(bits.length / 8)) % 2 === 0 ? 0xec : 0x11;
}
return codewords;
}
/** Split into blocks, add error correction, and interleave. */
function interleave(dataCodewords: Uint8Array, version: number, level: EcLevel): Uint8Array {
const spec = RS_BLOCK_TABLE[version - 1]![EC_LEVELS.indexOf(level)]!;
const dataBlocks: Uint8Array[] = [];
const ecBlocks: Uint8Array[] = [];
let offset = 0;
for (let i = 0; i < spec.length; i += 3) {
const [count, total, dataLen] = [spec[i]!, spec[i + 1]!, spec[i + 2]!];
for (let b = 0; b < count; b++) {
const block = dataCodewords.slice(offset, offset + dataLen);
offset += dataLen;
dataBlocks.push(block);
ecBlocks.push(rsEncode(block, total - dataLen));
}
}
const out: number[] = [];
const maxData = Math.max(...dataBlocks.map((b) => b.length));
for (let i = 0; i < maxData; i++) {
for (const block of dataBlocks) if (i < block.length) out.push(block[i]!);
}
const maxEc = Math.max(...ecBlocks.map((b) => b.length));
for (let i = 0; i < maxEc; i++) {
for (const block of ecBlocks) if (i < block.length) out.push(block[i]!);
}
return new Uint8Array(out);
}
type Grid = Array<Array<boolean | null>>;
/** Place the fixed patterns; leaves data modules null. */
function buildTemplate(version: number): { grid: Grid; reserved: boolean[][] } {
const size = version * 4 + 17;
const grid: Grid = Array.from({ length: size }, () => Array<boolean | null>(size).fill(null));
const reserved = Array.from({ length: size }, () => Array<boolean>(size).fill(false));
const setFn = (r: number, c: number, dark: boolean): void => {
grid[r]![c] = dark;
reserved[r]![c] = true;
};
// Finder patterns and their separators.
for (const [fr, fc] of [[0, 0], [0, size - 7], [size - 7, 0]] as const) {
for (let r = -1; r <= 7; r++) {
for (let c = -1; c <= 7; c++) {
const rr = fr + r;
const cc = fc + c;
if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue;
const onRing = (r >= 0 && r <= 6 && (c === 0 || c === 6)) || (c >= 0 && c <= 6 && (r === 0 || r === 6));
const inCore = r >= 2 && r <= 4 && c >= 2 && c <= 4;
setFn(rr, cc, onRing || inCore);
}
}
}
// Timing patterns.
for (let i = 8; i < size - 8; i++) {
setFn(6, i, i % 2 === 0);
setFn(i, 6, i % 2 === 0);
}
// Alignment patterns, skipping the ones that would overlap a finder.
const positions = alignmentPositions(version);
for (const r of positions) {
for (const c of positions) {
if ((r === 6 && c === 6) || (r === 6 && c === size - 7) || (r === size - 7 && c === 6)) {
continue;
}
for (let dr = -2; dr <= 2; dr++) {
for (let dc = -2; dc <= 2; dc++) {
const ring = Math.max(Math.abs(dr), Math.abs(dc));
setFn(r + dr, c + dc, ring !== 1);
}
}
}
}
// Dark module — always set, always at this coordinate.
setFn(size - 8, 8, true);
// Reserve the format-information areas; the values are written later,
// because they depend on the mask that has not been chosen yet.
for (let i = 0; i < 9; i++) {
if (!reserved[8]![i]) { grid[8]![i] = false; reserved[8]![i] = true; }
if (!reserved[i]![8]) { grid[i]![8] = false; reserved[i]![8] = true; }
}
for (let i = 0; i < 8; i++) {
if (!reserved[8]![size - 1 - i]) { grid[8]![size - 1 - i] = false; reserved[8]![size - 1 - i] = true; }
if (!reserved[size - 1 - i]![8]) { grid[size - 1 - i]![8] = false; reserved[size - 1 - i]![8] = true; }
}
// Version information blocks (versions 7+).
if (version >= 7) {
const bits = versionBits(version);
for (let i = 0; i < 18; i++) {
const dark = ((bits >>> i) & 1) === 1;
const r = Math.floor(i / 3);
const c = size - 11 + (i % 3);
setFn(r, c, dark);
setFn(c, r, dark);
}
}
return { grid, reserved };
}
/** Zigzag placement of the interleaved codewords into the free modules. */
function placeData(grid: Grid, reserved: boolean[][], codewords: Uint8Array): void {
const size = grid.length;
let bitIndex = 0;
let upward = true;
for (let right = size - 1; right >= 1; right -= 2) {
// Column 6 is the vertical timing pattern and is skipped entirely.
if (right === 6) right = 5;
for (let vert = 0; vert < size; vert++) {
const row = upward ? size - 1 - vert : vert;
for (const col of [right, right - 1]) {
if (reserved[row]![col]) continue;
let dark = false;
if (bitIndex < codewords.length * 8) {
dark = ((codewords[bitIndex >>> 3]! >>> (7 - (bitIndex & 7))) & 1) === 1;
}
grid[row]![col] = dark;
bitIndex++;
}
}
upward = !upward;
}
}
/** The four penalty rules, used to pick the least-bad mask. */
function penalty(modules: boolean[][]): number {
const size = modules.length;
let score = 0;
// Rule 1: runs of five or more same-coloured modules.
for (let i = 0; i < size; i++) {
for (const line of [modules[i]!, modules.map((row) => row[i]!)]) {
let run = 1;
for (let j = 1; j < size; j++) {
if (line[j] === line[j - 1]) {
run++;
} else {
if (run >= 5) score += 3 + (run - 5);
run = 1;
}
}
if (run >= 5) score += 3 + (run - 5);
}
}
// Rule 2: 2x2 blocks of one colour.
for (let r = 0; r < size - 1; r++) {
for (let c = 0; c < size - 1; c++) {
const v = modules[r]![c];
if (v === modules[r]![c + 1] && v === modules[r + 1]![c] && v === modules[r + 1]![c + 1]) {
score += 3;
}
}
}
// Rule 3: the finder-like 1:1:3:1:1 pattern with four light modules beside it.
const A = [true, false, true, true, true, false, true, false, false, false, false];
const B = [false, false, false, false, true, false, true, true, true, false, true];
const matches = (line: boolean[], start: number, pattern: boolean[]): boolean =>
pattern.every((want, k) => line[start + k] === want);
for (let i = 0; i < size; i++) {
for (const line of [modules[i]!, modules.map((row) => row[i]!)]) {
for (let j = 0; j + 11 <= size; j++) {
if (matches(line, j, A) || matches(line, j, B)) score += 40;
}
}
}
// Rule 4: deviation from an even balance of dark and light.
let dark = 0;
for (const row of modules) for (const v of row) if (v) dark++;
const percent = (dark * 100) / (size * size);
score += Math.floor(Math.abs(percent - 50) / 5) * 10;
return score;
}
/**
* Encode `text` as a QR module matrix.
*
* Picks the smallest version that fits, and the mask with the lowest penalty
* — both exactly as the spec prescribes, so the output matches any conforming
* encoder module for module.
*/
export function encodeQr(
text: string,
options: { level?: EcLevel; version?: number; mask?: number } = {},
): QrMatrix {
const level = options.level ?? "M";
const data = new TextEncoder().encode(text);
const version = options.version ?? smallestVersion(data.length, level);
if (version < 1 || version > 40) throw new Error(`QR version must be 1..40, got ${version}`);
if (data.length > byteCapacity(version, level)) {
throw new Error(
`${data.length} bytes does not fit in a version-${version} QR at level ${level} ` +
`(capacity ${byteCapacity(version, level)})`,
);
}
const codewords = interleave(buildDataCodewords(data, version, level), version, level);
let best: { modules: boolean[][]; mask: number; score: number } | null = null;
// An explicit mask is for tests and for reproducing another encoder's
// output; production always selects by penalty, as the spec prescribes.
const masks =
options.mask === undefined ? [0, 1, 2, 3, 4, 5, 6, 7] : [options.mask];
for (const mask of masks) {
const { grid, reserved } = buildTemplate(version);
placeData(grid, reserved, codewords);
const size = grid.length;
const modules = grid.map((row, r) =>
row.map((v, c) => {
const dark = v === true;
return reserved[r]![c] ? dark : dark !== maskAt(mask, r, c);
}),
);
// Format information depends on the mask, so it is written per candidate.
const bits = formatBits(level, mask);
for (let i = 0; i < 15; i++) {
const dark = ((bits >>> i) & 1) === 1;
if (i < 6) modules[i]![8] = dark;
else if (i < 8) modules[i + 1]![8] = dark;
else if (i === 8) modules[8]![7] = dark;
else modules[8]![14 - i] = dark;
if (i < 8) modules[8]![size - 1 - i] = dark;
else modules[size - 15 + i]![8] = dark;
}
const score = penalty(modules);
if (!best || score < best.score) best = { modules, mask, score };
}
const chosen = best!;
return {
version,
level,
mask: chosen.mask,
size: chosen.modules.length,
modules: chosen.modules,
};
}
/**
* Render a matrix as text, two modules per character cell.
*
* Half-block characters give square-ish modules in a terminal, where cells are
* about twice as tall as they are wide. The quiet zone is four modules on
* every side; without it many scanners simply fail.
*/
export function renderQrText(matrix: QrMatrix, options: { quietZone?: number } = {}): string {
const quiet = options.quietZone ?? 4;
const size = matrix.size + quiet * 2;
const dark = (r: number, c: number): boolean => {
const rr = r - quiet;
const cc = c - quiet;
if (rr < 0 || cc < 0 || rr >= matrix.size || cc >= matrix.size) return false;
return matrix.modules[rr]![cc]!;
};
const lines: string[] = [];
for (let r = 0; r < size; r += 2) {
let line = "";
for (let c = 0; c < size; c++) {
const top = dark(r, c);
const bottom = r + 1 < size ? dark(r + 1, c) : false;
// Dark modules print as light glyphs: terminals are dark-on-light or
// light-on-dark, and scanners want the DATA modules dark. Using the
// block characters this way round keeps it scannable on a light
// terminal, which is the common default.
line += top && bottom ? " " : top ? "\u2584" : bottom ? "\u2580" : "\u2588";
}
lines.push(line);
}
return lines.join("\n");
}
+139
View File
@@ -0,0 +1,139 @@
/**
* QR encoding, checked against a mature reference implementation.
*
* A wrong QR code is worse than no QR code: it scans cleanly and yields the
* wrong secret, and the user finds out when their 2FA stops working. So the
* load-bearing assertion is not "it produces a plausible grid" but "every
* module matches what Python's `qrcode` library produces for the same input"
* — version, size and all several thousand modules.
*
* Two things are controlled for so the comparison is between implementations
* rather than between configurations:
*
* - The fixtures force byte mode. The reference auto-selects alphanumeric
* for an all-uppercase string, which is a different encoding of the same
* text, not a different answer to the same question.
* - The mask is pinned to the reference's choice. The two disagree about
* mask SELECTION (see the note in qr.ts): the spec scores the finished
* symbol, the reference scores one with its format modules blanked. All
* eight masks are valid, so selection is checked separately as a
* property rather than by copying the reference's quirk.
*/
import { describe, expect, it } from "vitest";
import { encodeQr, renderQrText, EC_LEVELS } from "@seedpass/core";
// Imported through the fixture package rather than read from disk, so the
// suite also runs under the jsdom environment, where there is no filesystem.
import { qrCases as cases } from "@seedpass/test-vectors";
describe("every module matches the reference encoder", () => {
for (const c of cases) {
const label = c.text.length > 38 ? `${c.text.slice(0, 35)}...` : c.text;
it(`${c.level} v${c.version} mask ${c.mask}: ${label}`, () => {
const got = encodeQr(c.text, { level: c.level, mask: c.mask });
// Version selection is spec-determined, so a mismatch means the
// capacity calculation diverged rather than a different-but-valid
// choice being made.
expect(got.version).toBe(c.version);
expect(got.size).toBe(c.size);
for (let r = 0; r < c.size; r++) {
// Row-wise so a failure names the row rather than dumping the grid.
expect({ row: r, modules: got.modules[r] }).toEqual({ row: r, modules: c.modules[r] });
}
});
}
it("covers every error-correction level and a spread of versions", () => {
// Guards the fixture set itself: a suite that only ever exercised one
// level would pass while three quarters of the block table was wrong.
const levels = new Set(cases.map((c) => c.level));
for (const level of EC_LEVELS) expect(levels).toContain(level);
const versions = cases.map((c) => c.version);
expect(Math.min(...versions)).toBe(1);
// Above version 6 the version-information blocks are written, which is a
// separate code path from everything versions 1-6 exercise.
expect(Math.max(...versions)).toBeGreaterThan(6);
});
});
describe("mask selection", () => {
it("chooses the lowest-penalty mask, and every candidate is a valid symbol", () => {
for (const c of cases.slice(0, 4)) {
const auto = encodeQr(c.text, { level: c.level });
// Whatever it picked must be reproducible by pinning that mask.
const pinned = encodeQr(c.text, { level: c.level, mask: auto.mask });
expect(pinned.modules).toEqual(auto.modules);
expect(auto.mask).toBeGreaterThanOrEqual(0);
expect(auto.mask).toBeLessThan(8);
// All eight are legal symbols: same version, same size, and the fixed
// patterns land in the same places regardless of mask.
for (let mask = 0; mask < 8; mask++) {
const m = encodeQr(c.text, { level: c.level, mask });
expect(m.version).toBe(auto.version);
expect(m.modules[m.size - 8]![8]).toBe(true); // dark module
}
}
});
});
describe("encoder behaviour", () => {
it("places a finder pattern in exactly three corners", () => {
const m = encodeQr("finder-check").modules;
const size = m.length;
for (const [r0, c0] of [[0, 0], [0, size - 7], [size - 7, 0]] as const) {
expect(m[r0]![c0]).toBe(true); // outer ring
expect(m[r0 + 1]![c0 + 1]).toBe(false); // light ring
expect(m[r0 + 3]![c0 + 3]).toBe(true); // core
}
// The fourth corner must NOT have one — that is how a scanner works out
// which way up the symbol is.
expect(m[size - 7]![size - 7]).toBe(false);
});
it("picks the smallest version that fits and grows with the data", () => {
expect(encodeQr("short", { level: "M" }).version).toBeLessThan(
encodeQr("x".repeat(300), { level: "M" }).version,
);
});
it("needs a larger version at a stronger error-correction level", () => {
// More ECC means less room for data at the same version.
const text = "x".repeat(100);
expect(encodeQr(text, { level: "H" }).version).toBeGreaterThan(
encodeQr(text, { level: "L" }).version,
);
});
it("refuses data that will not fit rather than truncating it", () => {
// Silently dropping the tail would produce a scannable code containing
// half a secret, which is the worst outcome available.
expect(() => encodeQr("x".repeat(5000), { level: "H" })).toThrow(/does not fit/);
expect(() => encodeQr("x".repeat(100), { version: 1 })).toThrow(/capacity/);
});
it("counts multi-byte characters as their UTF-8 length", () => {
// Byte mode: an emoji costs four bytes of capacity, not one.
expect(() => encodeQr("😀".repeat(5), { version: 1, level: "H" })).toThrow(/capacity/);
});
});
describe("terminal rendering", () => {
it("surrounds the code with a quiet zone", () => {
const lines = renderQrText(encodeQr("quiet-zone-check")).split("\n");
// Four modules on every side; without it many scanners never see the code.
expect(lines[0]).toMatch(/^█+$/);
expect(lines[lines.length - 1]).toMatch(/^█+$/);
for (const line of lines) {
expect(line.startsWith("████")).toBe(true);
expect(line.endsWith("████")).toBe(true);
}
});
it("packs two module rows into each text row", () => {
const matrix = encodeQr("row-packing");
const lines = renderQrText(matrix).split("\n");
expect(lines).toHaveLength(Math.ceil((matrix.size + 8) / 2));
expect(lines[0]!.length).toBe(matrix.size + 8);
});
});
@@ -21,6 +21,7 @@
"passwords_v2.json",
"pgp_keys.json",
"portable_backup.json",
"qr.json",
"ssh_keys.json",
"sync_merge.json",
"totp.json",
@@ -28,6 +29,6 @@
],
"fixture_version": 1,
"generator": "scripts/generate_ts_port_fixtures.py",
"python_commit": "f634435681af4913bc5d487b2b7aa763ca504229",
"python_commit": "46ff51f3d64a2e7a85deae90688b4312ab4f15fa",
"redaction_policy": "All fixtures derive from public BIP-39 test mnemonics (abandon.../legal.../zoo...). Secret-shaped outputs are generated test material, never from a live profile."
}
File diff suppressed because it is too large. Load diff
+17
View File
@@ -8,6 +8,7 @@
*/
import manifestJson from "../fixtures/manifest.json";
import qrJson from "../fixtures/qr.json";
import bip39Json from "../fixtures/bip39_seeds.json";
import bip85Json from "../fixtures/bip85_entropy.json";
import passwordsV1Json from "../fixtures/passwords_v1.json";
@@ -298,3 +299,19 @@ export const legacyPayloads = legacyPayloadsJson as {
expected_seed_mnemonic_id: string;
};
};
/**
* QR reference vectors, generated by Python's `qrcode` library with byte mode
* forced and the chosen mask recorded. See js/packages/core/test/qr.test.ts
* for why both of those are pinned.
*/
export interface QrCase {
text: string;
level: "L" | "M" | "Q" | "H";
version: number;
size: number;
mask: number;
modules: boolean[][];
}
export const qrCases = (qrJson as { cases: QrCase[] }).cases;
+99
View File
@@ -82,6 +82,25 @@ POLICIES = {
},
}
# (text, ec_level) — spans all four EC levels, versions 1..17, and the
# version-7 boundary where version-information blocks start being written.
QR_CASES = [
(
"otpauth://totp/email-2fa?secret=JBSWY3DPEHPK3PXP"
"&issuer=SeedPass&period=30&digits=6",
"M",
),
("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", "M"),
("JBSWY3DPEHPK3PXP", "L"),
("https://example.com/a/fairly/long/path?with=query&params=yes", "Q"),
("x", "H"),
("A" * 200, "M"),
("A" * 600, "L"),
("mixed \u00e9\u00e8 unicode \u2713 bytes", "M"),
("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyMaterialHere user@host", "M"),
("0123456789" * 8, "Q"),
]
# (policy, length, index) — lengths cross the 32-byte v1 stream-wrap boundary
PASSWORD_CASES = [
("default", 8, 0),
@@ -1187,6 +1206,85 @@ def gen_kdf_metadata() -> dict:
}
def gen_qr() -> dict:
"""QR reference matrices for the TypeScript encoder.
Byte mode is forced: the library auto-selects alphanumeric mode for an
all-uppercase string, which is a different encoding of the same text
rather than a different answer to the same question, and the TS encoder
is byte-mode only.
The chosen mask is recorded so the TS tests can pin it. The two
implementations disagree about mask SELECTION -- the spec scores the
finished symbol, this library scores one with its format modules blanked
(`makeImpl(test=True, ...)`) -- and every one of the eight masks is a
valid, scannable code, so the tests compare matrices at a fixed mask
rather than reproducing the quirk.
"""
import qrcode
import qrcode.util as qr_util
from qrcode.constants import (
ERROR_CORRECT_H,
ERROR_CORRECT_L,
ERROR_CORRECT_M,
ERROR_CORRECT_Q,
)
levels = {
"L": ERROR_CORRECT_L,
"M": ERROR_CORRECT_M,
"Q": ERROR_CORRECT_Q,
"H": ERROR_CORRECT_H,
}
format_ec = {"L": 1, "M": 0, "Q": 3, "H": 2}
def format_bits(level: str, mask: int) -> int:
data = (format_ec[level] << 3) | mask
rem = data
for _ in range(10):
rem = (rem << 1) ^ ((rem >> 9) * 0x537)
return ((data << 10) | rem) ^ 0x5412
def read_mask(matrix, level: str) -> int:
bits = 0
for i in range(15):
if i < 6:
r, c = i, 8
elif i < 8:
r, c = i + 1, 8
elif i == 8:
r, c = 8, 7
else:
r, c = 8, 14 - i
if matrix[r][c]:
bits |= 1 << i
for mask in range(8):
if format_bits(level, mask) == bits:
return mask
raise AssertionError("no mask matched the encoded format information")
cases: list[dict] = []
for text, level in QR_CASES:
q = qrcode.QRCode(error_correction=levels[level], border=0)
q.add_data(qr_util.QRData(text, mode=qr_util.MODE_8BIT_BYTE))
q.make(fit=True)
matrix = [[bool(v) for v in row] for row in q.get_matrix()]
cases.append(
{
"text": text,
"level": level,
"version": q.version,
"size": len(matrix),
"mask": read_mask(matrix, level),
"modules": matrix,
}
)
return {
"description": "QR matrices (byte mode) from the qrcode library",
"cases": cases,
}
def main() -> None:
FIXTURES_DIR.mkdir(parents=True, exist_ok=True)
@@ -1210,6 +1308,7 @@ def main() -> None:
"pgp_keys.json": gen_pgp_keys(),
"password_kdf.json": gen_password_kdf(),
"legacy_payloads.json": gen_legacy_payloads(),
"qr.json": gen_qr(),
}
entries_fixture, vault_fixture = gen_entries_and_vault()
files["entries_index.json"] = entries_fixture