Files
bitlogin/packages/widget/src/qr.ts
thePR0M3TH3ANandClaude Fable 5 49682dd75c NIP-46 sign-in: remote-signer sessions behind the same facade, memory-only
Implements login-methods.md §LM5 phase one. Core gains nostr/nip46.ts -- a
Nip46Client speaking kind-24133 RPC with NIP-44 transport encryption,
bunker:// parsing, nostrconnect listen (secret echo is proof-of-scan),
auth_url surfacing, and typed deadline errors -- plus subscribeLive on
RelayConnection with re-REQ on reconnect, and live post-EOSE delivery in
the MockRelay so tests exercise real round trips against a fake bunker.

The ephemeral client key and Nip46Client live in the crypto worker
(§LM5.4); nip46* actions bypass the serial queue so a signer waiting
minutes for human approval cannot block other worker calls, and the
signer's auth_url reaches the UI as the worker's first notification frame.

The widget's connect screen runs both legs at once: a nostrconnect QR --
rendered as dot-style inline SVG with rounded finder eyes, per owner
preference -- for phone signers, and a bunker:// paste box (password
input; the URI carries a token). window.nostr is now an element-routed
provider, so a NIP-46 session claims the slot and hosts reading it get
the remote signer transparently; NIP-07 still leaves the slot to the
extension. Sessions stay thin per §LM7: no vault, no persistence.
Persistence remains gated on the external vault review (§LM5.3).

14 new tests (core fake-bunker e2e, QR geometry, signer delegation) and
two policy pins: bunker option below the password path, element stays
web-storage-free.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 10:31:36 -04:00

62 lines
2.6 KiB
TypeScript

/** Dot-style QR rendering for the nostrconnect connection code (§LM5).
*
* Renders the module matrix as inline SVG: data modules become circles, and
* the three finder "eyes" become rounded squares -- dots everywhere would look
* uniform but measurably hurts camera lock-on, since scanners find a code BY
* its three square eyes. Inline SVG keeps this self-contained under the
* strict deployment CSP (no canvas, no image assets) and crisp at any size.
*
* The white backing rect is part of the QR, not styling: scanners need quiet
* zone and contrast regardless of the host page's theme.
*/
import qrcodeGenerator from "qrcode-generator";
const QUIET_ZONE = 4; // modules of white margin, per the QR spec
const DOT_RADIUS = 0.42; // of a 1x1 module cell; large enough to scan, round enough to read as dots
const DARK = "#14101f";
const LIGHT = "#ffffff";
function inFinderZone(row: number, col: number, moduleCount: number): boolean {
const nearStart = (v: number) => v < 7;
const nearEnd = (v: number) => v >= moduleCount - 7;
return (
(nearStart(row) && nearStart(col)) ||
(nearStart(row) && nearEnd(col)) ||
(nearEnd(row) && nearStart(col))
);
}
function finderEye(x: number, y: number): string {
// Outer 7x7 ring drawn as a stroked rounded square, inner 3x3 filled.
return (
`<rect x="${x + 0.5}" y="${y + 0.5}" width="6" height="6" rx="2.1" fill="none" stroke="${DARK}" stroke-width="1"/>` +
`<rect x="${x + 2}" y="${y + 2}" width="3" height="3" rx="1.1" fill="${DARK}"/>`
);
}
/** Encodes `data` and returns a complete `<svg>` element string. */
export function renderQrSvg(data: string, ariaLabel = "QR code"): string {
const qr = qrcodeGenerator(0, "M");
qr.addData(data);
qr.make();
const moduleCount = qr.getModuleCount();
const size = moduleCount + QUIET_ZONE * 2;
const parts: string[] = [];
parts.push(`<rect x="0" y="0" width="${size}" height="${size}" rx="3" fill="${LIGHT}"/>`);
parts.push(finderEye(QUIET_ZONE, QUIET_ZONE));
parts.push(finderEye(QUIET_ZONE + moduleCount - 7, QUIET_ZONE));
parts.push(finderEye(QUIET_ZONE, QUIET_ZONE + moduleCount - 7));
for (let row = 0; row < moduleCount; row++) {
for (let col = 0; col < moduleCount; col++) {
if (inFinderZone(row, col, moduleCount) || !qr.isDark(row, col)) continue;
const cx = QUIET_ZONE + col + 0.5;
const cy = QUIET_ZONE + row + 0.5;
parts.push(`<circle cx="${cx}" cy="${cy}" r="${DOT_RADIUS}" fill="${DARK}"/>`);
}
}
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" role="img" aria-label="${ariaLabel}" shape-rendering="geometricPrecision">${parts.join("")}</svg>`;
}