mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
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:
1 parent
46ff51f3d6
commit
51f1358da4
10 files changed
+21451
-9
No files matched your search
@@ -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)",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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¶ms=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
|
||||
|
||||
Reference in new issue
Block a user