From 9a1ea917988b56e03902dcbe63936874c30ae24d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 17:35:19 +0200 Subject: [PATCH] Read a v3 pairing code: v2 and the home id at FTW Cloud The box's QR adds the home id as a sixth segment once Core flips appenroll.QRNamesHome. Each parser that ships must read v3 before then, so this one does, and keeps reading v2. The app does not use the home id yet; the browser session moves to FTW Cloud in a later step. Co-Authored-By: Claude Opus 5.5 --- .changeset/qr-v3-home.md | 5 + src/lib/identity/enrollment.test.ts | 448 ++++++++++++++++++---------- src/lib/identity/enrollment.ts | 233 ++++++++++----- 3 files changed, 446 insertions(+), 240 deletions(-) create mode 100644 .changeset/qr-v3-home.md diff --git a/.changeset/qr-v3-home.md b/.changeset/qr-v3-home.md new file mode 100644 index 0000000..eb9ff2d --- /dev/null +++ b/.changeset/qr-v3-home.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +Read a v3 pairing code, which adds the box's home id at FTW Cloud to v2. The app keeps reading v2 and does not use the home id yet. diff --git a/src/lib/identity/enrollment.test.ts b/src/lib/identity/enrollment.test.ts index 47a4737..2d31997 100644 --- a/src/lib/identity/enrollment.test.ts +++ b/src/lib/identity/enrollment.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect } from "vitest"; import { parseEnrollmentUrl, parseEnrollmentFragment, @@ -7,202 +7,324 @@ import { ENROLLMENT_HOST, type Enrollment, type EnrollmentErrorCode, -} from './enrollment' -import { encodeBase64url } from './base64url' +} from "./enrollment"; +import { encodeBase64url } from "./base64url"; -const boxKey = new Uint8Array(32).fill(7) -const pairingCode = new Uint8Array(16).fill(9) -const rendezvousSecret = new Uint8Array(32).fill(11) +const boxKey = new Uint8Array(32).fill(7); +const pairingCode = new Uint8Array(16).fill(9); +const rendezvousSecret = new Uint8Array(32).fill(11); const payload: Enrollment = { boxStaticPublic: boxKey, pairingCode, - lanHint: '192.168.1.42:8443', + lanHint: "192.168.1.42:8443", rendezvousSecret, -} +}; const fragment = ( - version = 'v2', + version = "v2", key = encodeBase64url(boxKey), code = encodeBase64url(pairingCode), - hint = encodeBase64url(new TextEncoder().encode('192.168.1.42:8443')), - secret = encodeBase64url(rendezvousSecret) -) => [version, key, code, hint, secret].join('.') + hint = encodeBase64url(new TextEncoder().encode("192.168.1.42:8443")), + secret = encodeBase64url(rendezvousSecret), +) => [version, key, code, hint, secret].join("."); function codeOf(fn: () => unknown): EnrollmentErrorCode { try { - fn() + fn(); } catch (err) { - expect(err).toBeInstanceOf(EnrollmentError) - return (err as EnrollmentError).code + expect(err).toBeInstanceOf(EnrollmentError); + return (err as EnrollmentError).code; } - throw new Error('expected a throw') + throw new Error("expected a throw"); } -describe('the fragment never reaches a server', () => { +describe("the fragment never reaches a server", () => { // The whole trust argument rests on this. A pairing code in a query string // would be in the cloud's access log before the user finished blinking. - it('puts every secret after the #', () => { - const url = new URL(buildEnrollmentUrl(payload)) - - expect(url.search).toBe('') - expect(url.pathname).toBe('/p') - expect(url.hash).toContain(encodeBase64url(pairingCode)) - expect(url.hash).toContain(encodeBase64url(rendezvousSecret)) - expect(`${url.origin}${url.pathname}`).not.toContain(encodeBase64url(pairingCode)) - expect(`${url.origin}${url.pathname}`).not.toContain(encodeBase64url(rendezvousSecret)) - }) - - it('round trips through the QR text', () => { - const parsed = parseEnrollmentUrl(buildEnrollmentUrl(payload)) - - expect(parsed.boxStaticPublic).toEqual(boxKey) - expect(parsed.pairingCode).toEqual(pairingCode) - expect(parsed.lanHint).toBe('192.168.1.42:8443') - expect(parsed.rendezvousSecret).toEqual(rendezvousSecret) - }) - - it('reads location.hash with or without the leading #', () => { - expect(parseEnrollmentFragment(`#${fragment()}`).lanHint).toBe('192.168.1.42:8443') - expect(parseEnrollmentFragment(fragment()).lanHint).toBe('192.168.1.42:8443') - }) -}) - -describe('what the QR is allowed to be', () => { - it('accepts a box with no LAN hint', () => { - const parsed = parseEnrollmentFragment(fragment('v2', undefined, undefined, '')) - expect(parsed.lanHint).toBe('') - expect(parsed.boxStaticPublic).toEqual(boxKey) - }) - - it('accepts a dev origin, so the simulator can hand out a scannable URL', () => { - const url = buildEnrollmentUrl(payload, 'http://localhost:5173') - expect(parseEnrollmentUrl(url).pairingCode).toEqual(pairingCode) - }) - - it('rejects another host wearing the same shape', () => { - expect(codeOf(() => parseEnrollmentUrl(`https://evil.example/p#${fragment()}`))).toBe( - 'E_QR_NOT_FTW' - ) - }) - - it('rejects plain http on the real host', () => { - expect(codeOf(() => parseEnrollmentUrl(`http://${ENROLLMENT_HOST}/p#${fragment()}`))).toBe( - 'E_QR_NOT_FTW' - ) - }) - - it('rejects a QR that is not a URL at all', () => { - expect(codeOf(() => parseEnrollmentUrl('WIFI:S=Home;T=WPA;P=hunter2;;'))).toBe('E_QR_NOT_FTW') - }) - - it('rejects the right path but an empty fragment', () => { - expect(codeOf(() => parseEnrollmentUrl(`https://${ENROLLMENT_HOST}/p`))).toBe('E_QR_NOT_FTW') - }) -}) - -describe('every way the payload can be wrong', () => { - it('tells a newer box from a damaged scan', () => { + it("puts every secret after the #", () => { + const url = new URL(buildEnrollmentUrl(payload)); + + expect(url.search).toBe(""); + expect(url.pathname).toBe("/p"); + expect(url.hash).toContain(encodeBase64url(pairingCode)); + expect(url.hash).toContain(encodeBase64url(rendezvousSecret)); + expect(`${url.origin}${url.pathname}`).not.toContain( + encodeBase64url(pairingCode), + ); + expect(`${url.origin}${url.pathname}`).not.toContain( + encodeBase64url(rendezvousSecret), + ); + }); + + it("round trips through the QR text", () => { + const parsed = parseEnrollmentUrl(buildEnrollmentUrl(payload)); + + expect(parsed.boxStaticPublic).toEqual(boxKey); + expect(parsed.pairingCode).toEqual(pairingCode); + expect(parsed.lanHint).toBe("192.168.1.42:8443"); + expect(parsed.rendezvousSecret).toEqual(rendezvousSecret); + }); + + it("reads location.hash with or without the leading #", () => { + expect(parseEnrollmentFragment(`#${fragment()}`).lanHint).toBe( + "192.168.1.42:8443", + ); + expect(parseEnrollmentFragment(fragment()).lanHint).toBe( + "192.168.1.42:8443", + ); + }); +}); + +describe("what the QR is allowed to be", () => { + it("accepts a box with no LAN hint", () => { + const parsed = parseEnrollmentFragment( + fragment("v2", undefined, undefined, ""), + ); + expect(parsed.lanHint).toBe(""); + expect(parsed.boxStaticPublic).toEqual(boxKey); + }); + + it("accepts a dev origin, so the simulator can hand out a scannable URL", () => { + const url = buildEnrollmentUrl(payload, "http://localhost:5173"); + expect(parseEnrollmentUrl(url).pairingCode).toEqual(pairingCode); + }); + + it("rejects another host wearing the same shape", () => { + expect( + codeOf(() => parseEnrollmentUrl(`https://evil.example/p#${fragment()}`)), + ).toBe("E_QR_NOT_FTW"); + }); + + it("rejects plain http on the real host", () => { + expect( + codeOf(() => + parseEnrollmentUrl(`http://${ENROLLMENT_HOST}/p#${fragment()}`), + ), + ).toBe("E_QR_NOT_FTW"); + }); + + it("rejects a QR that is not a URL at all", () => { + expect( + codeOf(() => parseEnrollmentUrl("WIFI:S=Home;T=WPA;P=hunter2;;")), + ).toBe("E_QR_NOT_FTW"); + }); + + it("rejects the right path but an empty fragment", () => { + expect( + codeOf(() => parseEnrollmentUrl(`https://${ENROLLMENT_HOST}/p`)), + ).toBe("E_QR_NOT_FTW"); + }); +}); + +/** + * v3 is v2 and the home id at FTW Cloud, as the box's appenroll makes it. The + * iPhone's and Android's parsers read this same string in their own tests. + */ +const V3 = + "https://app.ftw.energy/p#v3.AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE.AgICAgICAgICAgICAgICAg." + + "MTAuMC4wLjI6ODA4MA.AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM.Zmp6eXI2N2J1enB5bmNxbWpsaXdnZXp2ZGU"; + +describe("a v3 code names the home", () => { + it("reads the home id and the five v2 segments unchanged", () => { + const parsed = parseEnrollmentUrl(V3); + + expect(parsed.boxStaticPublic).toEqual(new Uint8Array(32).fill(1)); + expect(parsed.pairingCode).toEqual(new Uint8Array(16).fill(2)); + expect(parsed.lanHint).toBe("10.0.0.2:8080"); + expect(parsed.rendezvousSecret).toEqual(new Uint8Array(32).fill(3)); + expect(parsed.home).toBe("fjzyr67buzpyncqmjliwgezvde"); + expect(buildEnrollmentUrl(parsed)).toBe(V3); + }); + + it("leaves a v2 code as it was", () => { + const parsed = parseEnrollmentUrl(buildEnrollmentUrl(payload)); + + expect(parsed.home).toBeUndefined(); + expect(buildEnrollmentUrl(payload)).toContain("#v2."); + }); +}); + +describe("every way the payload can be wrong", () => { + it("tells a newer box from a damaged scan", () => { // These two need different answers: one is "update the app", the other is // "scan again", and guessing wrong sends the user round a loop. - expect(codeOf(() => parseEnrollmentFragment(fragment('v3')))).toBe('E_QR_VERSION') - expect(codeOf(() => parseEnrollmentFragment(fragment('x1')))).toBe('E_QR_NOT_FTW') - }) + expect(codeOf(() => parseEnrollmentFragment(fragment("v4")))).toBe( + "E_QR_VERSION", + ); + expect(codeOf(() => parseEnrollmentFragment(fragment("x1")))).toBe( + "E_QR_NOT_FTW", + ); + }); + + it("wants a home id in a v3 code, and nowhere else", () => { + const segments = V3.slice(V3.indexOf("#") + 1).split("."); + const noHome = segments.slice(0, 5).join("."); + const home = (text: string) => + encodeBase64url(new TextEncoder().encode(text)); + expect(codeOf(() => parseEnrollmentFragment(noHome))).toBe("E_QR_SHAPE"); + expect( + codeOf(() => parseEnrollmentFragment(`${noHome}.${segments[5]}.AA`)), + ).toBe("E_QR_SHAPE"); + expect( + codeOf(() => parseEnrollmentFragment(`${fragment()}.${segments[5]}`)), + ).toBe("E_QR_SHAPE"); + expect( + codeOf(() => + parseEnrollmentFragment( + `${noHome}.${home("NOT-A-HOME-AT-ALL-12345678")}`, + ), + ), + ).toBe("E_QR_HOME"); + expect( + codeOf(() => + parseEnrollmentFragment( + `${noHome}.${home("fjzyr67buzpyncqmjliwgezvd")}`, + ), + ), + ).toBe("E_QR_HOME"); + expect(codeOf(() => parseEnrollmentFragment(`${noHome}.!!`))).toBe( + "E_QR_ENCODING", + ); + }); + + it("rejects a rendezvous secret of the wrong length", () => { + const short = encodeBase64url(new Uint8Array(16).fill(11)); + expect( + codeOf(() => + parseEnrollmentFragment( + fragment("v2", undefined, undefined, undefined, short), + ), + ), + ).toBe("E_QR_SECRET"); + }); - it('rejects a rendezvous secret of the wrong length', () => { - const short = encodeBase64url(new Uint8Array(16).fill(11)) + it("rejects a truncated payload", () => { expect( - codeOf(() => parseEnrollmentFragment(fragment('v2', undefined, undefined, undefined, short))) - ).toBe('E_QR_SECRET') - }) - - it('rejects a truncated payload', () => { - expect(codeOf(() => parseEnrollmentFragment('v2.' + encodeBase64url(boxKey)))).toBe('E_QR_SHAPE') - expect(codeOf(() => parseEnrollmentFragment(fragment() + '.extra'))).toBe('E_QR_SHAPE') - }) - - it('rejects padded base64', () => { - const padded = encodeBase64url(boxKey) + '=' - expect(codeOf(() => parseEnrollmentFragment(fragment('v2', padded)))).toBe('E_QR_ENCODING') - }) - - it('rejects the standard base64 alphabet', () => { - const standard = btoa(String.fromCharCode(...new Uint8Array(32).fill(0xfb))) - .replaceAll('=', '') - expect(standard).toContain('+') - expect(codeOf(() => parseEnrollmentFragment(fragment('v2', standard)))).toBe('E_QR_ENCODING') - }) - - it('rejects non-canonical trailing bits', () => { + codeOf(() => parseEnrollmentFragment("v2." + encodeBase64url(boxKey))), + ).toBe("E_QR_SHAPE"); + expect(codeOf(() => parseEnrollmentFragment(fragment() + ".extra"))).toBe( + "E_QR_SHAPE", + ); + }); + + it("rejects padded base64", () => { + const padded = encodeBase64url(boxKey) + "="; + expect(codeOf(() => parseEnrollmentFragment(fragment("v2", padded)))).toBe( + "E_QR_ENCODING", + ); + }); + + it("rejects the standard base64 alphabet", () => { + const standard = btoa( + String.fromCharCode(...new Uint8Array(32).fill(0xfb)), + ).replaceAll("=", ""); + expect(standard).toContain("+"); + expect( + codeOf(() => parseEnrollmentFragment(fragment("v2", standard))), + ).toBe("E_QR_ENCODING"); + }); + + it("rejects non-canonical trailing bits", () => { // 32 bytes need 43 characters, which carry 258 bits. The last two are // padding and must be zero — one anchor gets exactly one spelling. - const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' - const canonical = encodeBase64url(boxKey) - const dirty = canonical.slice(0, -1) + alphabet[alphabet.indexOf(canonical.at(-1)!) | 0b11] - - expect(dirty).not.toBe(canonical) - expect(codeOf(() => parseEnrollmentFragment(fragment('v2', dirty)))).toBe('E_QR_ENCODING') - }) - - it('rejects a length that cannot be whole bytes', () => { - expect(codeOf(() => parseEnrollmentFragment(fragment('v2', 'A')))).toBe('E_QR_ENCODING') - }) - - it('rejects a box key of the wrong length', () => { - const short = encodeBase64url(new Uint8Array(31).fill(7)) - expect(codeOf(() => parseEnrollmentFragment(fragment('v2', short)))).toBe('E_QR_KEY') - - const long = encodeBase64url(new Uint8Array(33).fill(7)) - expect(codeOf(() => parseEnrollmentFragment(fragment('v2', long)))).toBe('E_QR_KEY') - }) - - it('rejects a pairing code of the wrong length', () => { - const short = encodeBase64url(new Uint8Array(8).fill(9)) - expect(codeOf(() => parseEnrollmentFragment(fragment('v2', undefined, short)))).toBe('E_QR_CODE') - }) - - it('rejects a LAN hint that is not a plain address', () => { - const control = encodeBase64url(new TextEncoder().encode('192.168.1.1\n:80')) - expect(codeOf(() => parseEnrollmentFragment(fragment('v2', undefined, undefined, control)))).toBe( - 'E_QR_HINT' - ) - - const long = encodeBase64url(new TextEncoder().encode('a'.repeat(65))) - expect(codeOf(() => parseEnrollmentFragment(fragment('v2', undefined, undefined, long)))).toBe( - 'E_QR_HINT' - ) - }) -}) - -describe('what the user is told', () => { - it('says what to do next, not which check failed', () => { + const alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + const canonical = encodeBase64url(boxKey); + const dirty = + canonical.slice(0, -1) + + alphabet[alphabet.indexOf(canonical.at(-1)!) | 0b11]; + + expect(dirty).not.toBe(canonical); + expect(codeOf(() => parseEnrollmentFragment(fragment("v2", dirty)))).toBe( + "E_QR_ENCODING", + ); + }); + + it("rejects a length that cannot be whole bytes", () => { + expect(codeOf(() => parseEnrollmentFragment(fragment("v2", "A")))).toBe( + "E_QR_ENCODING", + ); + }); + + it("rejects a box key of the wrong length", () => { + const short = encodeBase64url(new Uint8Array(31).fill(7)); + expect(codeOf(() => parseEnrollmentFragment(fragment("v2", short)))).toBe( + "E_QR_KEY", + ); + + const long = encodeBase64url(new Uint8Array(33).fill(7)); + expect(codeOf(() => parseEnrollmentFragment(fragment("v2", long)))).toBe( + "E_QR_KEY", + ); + }); + + it("rejects a pairing code of the wrong length", () => { + const short = encodeBase64url(new Uint8Array(8).fill(9)); + expect( + codeOf(() => parseEnrollmentFragment(fragment("v2", undefined, short))), + ).toBe("E_QR_CODE"); + }); + + it("rejects a LAN hint that is not a plain address", () => { + const control = encodeBase64url( + new TextEncoder().encode("192.168.1.1\n:80"), + ); + expect( + codeOf(() => + parseEnrollmentFragment(fragment("v2", undefined, undefined, control)), + ), + ).toBe("E_QR_HINT"); + + const long = encodeBase64url(new TextEncoder().encode("a".repeat(65))); + expect( + codeOf(() => + parseEnrollmentFragment(fragment("v2", undefined, undefined, long)), + ), + ).toBe("E_QR_HINT"); + }); +}); + +describe("what the user is told", () => { + it("says what to do next, not which check failed", () => { const help = (f: () => unknown) => { try { - f() + f(); } catch (err) { - return (err as EnrollmentError).help + return (err as EnrollmentError).help; } - throw new Error('expected a throw') - } + throw new Error("expected a throw"); + }; // Which side is behind decides the sentence. A v1 box is old firmware - // meeting this app; a v3 payload is this app meeting a newer box. Telling + // meeting this app; a v4 payload is this app meeting a newer box. Telling // someone to update the wrong thing sends them round a loop. - expect(help(() => parseEnrollmentFragment(fragment('v3')))).toMatch(/update the app/i) - expect(help(() => parseEnrollmentFragment(fragment('v1')))).toMatch(/update the box/i) - expect(help(() => parseEnrollmentFragment(fragment('v2', 'A')))).toMatch(/scan it again/i) - expect(help(() => parseEnrollmentUrl('https://evil.example/p#x'))).toMatch( - /settings → ftw app → show pairing code/i - ) + expect(help(() => parseEnrollmentFragment(fragment("v4")))).toMatch( + /update the app/i, + ); + expect(help(() => parseEnrollmentFragment(fragment("v1")))).toMatch( + /update the box/i, + ); + expect(help(() => parseEnrollmentFragment(fragment("v2", "A")))).toMatch( + /scan it again/i, + ); + expect(help(() => parseEnrollmentUrl("https://evil.example/p#x"))).toMatch( + /settings → ftw app → show pairing code/i, + ); // No error prose leaks a field name, a length or a byte count. for (const f of [ - () => parseEnrollmentFragment(fragment('v3')), - () => parseEnrollmentFragment(fragment('v1')), - () => parseEnrollmentFragment(fragment('v2', 'A')), - () => parseEnrollmentFragment(fragment('v2', encodeBase64url(new Uint8Array(31)))), + () => parseEnrollmentFragment(fragment("v4")), + () => parseEnrollmentFragment(fragment("v1")), + () => parseEnrollmentFragment(fragment("v2", "A")), + () => + parseEnrollmentFragment( + fragment("v2", encodeBase64url(new Uint8Array(31))), + ), ]) { - expect(help(f)).not.toMatch(/base64|byte|segment|canonical|null|undefined/i) + expect(help(f)).not.toMatch( + /base64|byte|segment|canonical|null|undefined/i, + ); } - }) -}) + }); +}); diff --git a/src/lib/identity/enrollment.ts b/src/lib/identity/enrollment.ts index 523722d..8843dea 100644 --- a/src/lib/identity/enrollment.ts +++ b/src/lib/identity/enrollment.ts @@ -1,6 +1,12 @@ /* The enrollment payload. * * https://app.ftw.energy/p#v2.... + * https://app.ftw.energy/p#v3..... + * + * v3 is v2 with the home id at FTW Cloud, base64url of its 26 characters, so a + * new phone can reach the box through FTW Cloud before any session with it + * (srcfl/ftw docs/protocol-cloud.md, "Pairing through the service"). Both are + * read; this app does not use the home id yet. * * Everything after the `#` is a URL fragment, and a fragment is never sent in * an HTTP request. That is the whole design: the trust anchor reaches the app @@ -15,38 +21,40 @@ * See docs/architecture.md, "Identity". */ -import { decodeBase64url, encodeBase64url, Base64urlError } from './base64url' +import { decodeBase64url, encodeBase64url, Base64urlError } from "./base64url"; -import { APP_HOST, isDevHost } from './origin' +import { APP_HOST, isDevHost } from "./origin"; -export const ENROLLMENT_VERSION = 'v2' +export const ENROLLMENT_VERSION = "v2"; +/** v2 and the home id at FTW Cloud. */ +export const ENROLLMENT_VERSION_HOME = "v3"; /** * Re-exported rather than restated: the QR's host and the RP ID must be the * same string, and two literals is how they stop being. */ -export const ENROLLMENT_HOST = APP_HOST -export const ENROLLMENT_PATH = '/p' +export const ENROLLMENT_HOST = APP_HOST; +export const ENROLLMENT_PATH = "/p"; -export const BOX_KEY_BYTES = 32 -export const PAIRING_CODE_BYTES = 16 +export const BOX_KEY_BYTES = 32; +export const PAIRING_CODE_BYTES = 16; /** * 256 bits of HKDF input keying material. Not a key anything encrypts under — * it only ever feeds `rendezvousHandle`, and 32 bytes is what a SHA-256 PRF * wants so that two epochs' handles are unrelated strings. */ -export const RENDEZVOUS_SECRET_BYTES = 32 +export const RENDEZVOUS_SECRET_BYTES = 32; /** A real box's hint is a host and a port. This is a bound, not a budget. */ -export const MAX_LAN_HINT_CHARS = 64 +export const MAX_LAN_HINT_CHARS = 64; export interface Enrollment { /** The box's Noise static public key. The trust anchor, out of band. */ - boxStaticPublic: Uint8Array + boxStaticPublic: Uint8Array; /** Single use — the box refuses it a second time. Never log this. */ - pairingCode: Uint8Array + pairingCode: Uint8Array; /** Where the box believes it can be reached on the LAN. Empty when it has no idea. */ - lanHint: string + lanHint: string; /** * The long-lived secret both ends derive the rendezvous handle from. * @@ -57,114 +65,155 @@ export interface Enrollment { * * Never sent anywhere. It reaches the app optically and stays here. */ - rendezvousSecret: Uint8Array + rendezvousSecret: Uint8Array; + /** The home id at FTW Cloud, from a v3 code. Absent from a v2 one. */ + home?: string; } export type EnrollmentErrorCode = - | 'E_QR_NOT_FTW' - | 'E_QR_VERSION' - | 'E_QR_SHAPE' - | 'E_QR_ENCODING' - | 'E_QR_KEY' - | 'E_QR_CODE' - | 'E_QR_HINT' - | 'E_QR_SECRET' + | "E_QR_NOT_FTW" + | "E_QR_VERSION" + | "E_QR_SHAPE" + | "E_QR_ENCODING" + | "E_QR_KEY" + | "E_QR_CODE" + | "E_QR_HINT" + | "E_QR_SECRET" + | "E_QR_HOME"; export class EnrollmentError extends Error { constructor( message: string, readonly code: EnrollmentErrorCode, /** What the user does now. Never what broke inside. */ - readonly help: string + readonly help: string, ) { - super(message) - this.name = 'EnrollmentError' + super(message); + this.name = "EnrollmentError"; } } -const SCAN_AGAIN = 'That code did not read cleanly. Hold the phone steady and scan it again.' +const SCAN_AGAIN = + "That code did not read cleanly. Hold the phone steady and scan it again."; const WRONG_CODE = - "That is not an FTW pairing code. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan the QR shown there." -const APP_TOO_OLD = 'This box needs a newer version of the app. Update the app, then scan again.' -const BOX_TOO_OLD = 'This box needs a software update before it can pair. Update the box, then scan again.' + "That is not an FTW pairing code. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan the QR shown there."; +const APP_TOO_OLD = + "This box needs a newer version of the app. Update the app, then scan again."; +const BOX_TOO_OLD = + "This box needs a software update before it can pair. Update the box, then scan again."; + +/** The newest version read. Payloads either side name a different culprit. */ +const OUR_VERSION = Number(ENROLLMENT_VERSION_HOME.slice(1)); -/** The number in `v2`. Payloads either side of ours name a different culprit. */ -const OUR_VERSION = Number(ENROLLMENT_VERSION.slice(1)) +/** A home id as FTW Cloud cuts one: 26 characters of lowercase base32. */ +const HOME_ID = /^[a-z2-7]{26}$/; export function parseEnrollmentUrl(raw: string): Enrollment { - let url: URL + let url: URL; try { - url = new URL(raw) + url = new URL(raw); } catch { - throw new EnrollmentError(`not a URL: ${raw.slice(0, 24)}`, 'E_QR_NOT_FTW', WRONG_CODE) + throw new EnrollmentError( + `not a URL: ${raw.slice(0, 24)}`, + "E_QR_NOT_FTW", + WRONG_CODE, + ); } - if (url.protocol !== 'https:' && !isLocalDev(url.hostname)) { - throw new EnrollmentError(`scheme ${url.protocol} is not https`, 'E_QR_NOT_FTW', WRONG_CODE) + if (url.protocol !== "https:" && !isLocalDev(url.hostname)) { + throw new EnrollmentError( + `scheme ${url.protocol} is not https`, + "E_QR_NOT_FTW", + WRONG_CODE, + ); } if (url.hostname !== ENROLLMENT_HOST && !isLocalDev(url.hostname)) { - throw new EnrollmentError(`host ${url.hostname} is not FTW`, 'E_QR_NOT_FTW', WRONG_CODE) + throw new EnrollmentError( + `host ${url.hostname} is not FTW`, + "E_QR_NOT_FTW", + WRONG_CODE, + ); } if (url.pathname !== ENROLLMENT_PATH) { throw new EnrollmentError( `path ${url.pathname} is not ${ENROLLMENT_PATH}`, - 'E_QR_NOT_FTW', - WRONG_CODE - ) + "E_QR_NOT_FTW", + WRONG_CODE, + ); } - return parseEnrollmentFragment(url.hash) + return parseEnrollmentFragment(url.hash); } /** Takes `location.hash`, with or without its leading `#`. */ export function parseEnrollmentFragment(fragment: string): Enrollment { - const body = fragment.startsWith('#') ? fragment.slice(1) : fragment - if (body === '') { - throw new EnrollmentError('empty fragment', 'E_QR_NOT_FTW', WRONG_CODE) + const body = fragment.startsWith("#") ? fragment.slice(1) : fragment; + if (body === "") { + throw new EnrollmentError("empty fragment", "E_QR_NOT_FTW", WRONG_CODE); } - const parts = body.split('.') - const version = parts[0]! + const parts = body.split("."); + const version = parts[0]!; // Version before shape, and which side is behind decides the sentence. A // v1 payload is an old box meeting this app, so the box is what needs - // updating; a v3 payload is the reverse. Telling someone to update the + // updating; a v4 payload is the reverse. Telling someone to update the // wrong thing is worse than saying nothing. - if (version !== ENROLLMENT_VERSION) { + if (version !== ENROLLMENT_VERSION && version !== ENROLLMENT_VERSION_HOME) { if (/^v\d+$/.test(version)) { - const help = Number(version.slice(1)) < OUR_VERSION ? BOX_TOO_OLD : APP_TOO_OLD - throw new EnrollmentError(`payload version ${version}`, 'E_QR_VERSION', help) + const help = + Number(version.slice(1)) < OUR_VERSION ? BOX_TOO_OLD : APP_TOO_OLD; + throw new EnrollmentError( + `payload version ${version}`, + "E_QR_VERSION", + help, + ); } - throw new EnrollmentError(`fragment does not start with a version`, 'E_QR_NOT_FTW', WRONG_CODE) + throw new EnrollmentError( + `fragment does not start with a version`, + "E_QR_NOT_FTW", + WRONG_CODE, + ); } - if (parts.length !== 5) { - throw new EnrollmentError(`${parts.length} segments, expected 5`, 'E_QR_SHAPE', SCAN_AGAIN) + const expected = version === ENROLLMENT_VERSION ? 5 : 6; + if (parts.length !== expected) { + throw new EnrollmentError( + `${parts.length} segments, expected ${expected}`, + "E_QR_SHAPE", + SCAN_AGAIN, + ); } - const boxStaticPublic = decodeSegment(parts[1]!, 'box key') - const pairingCode = decodeSegment(parts[2]!, 'pairing code') - const lanHint = new TextDecoder().decode(decodeSegment(parts[3]!, 'lan hint')) - const rendezvousSecret = decodeSegment(parts[4]!, 'rendezvous secret') + const boxStaticPublic = decodeSegment(parts[1]!, "box key"); + const pairingCode = decodeSegment(parts[2]!, "pairing code"); + const lanHint = new TextDecoder().decode( + decodeSegment(parts[3]!, "lan hint"), + ); + const rendezvousSecret = decodeSegment(parts[4]!, "rendezvous secret"); if (boxStaticPublic.length !== BOX_KEY_BYTES) { throw new EnrollmentError( `box key is ${boxStaticPublic.length} bytes, expected ${BOX_KEY_BYTES}`, - 'E_QR_KEY', - SCAN_AGAIN - ) + "E_QR_KEY", + SCAN_AGAIN, + ); } if (pairingCode.length !== PAIRING_CODE_BYTES) { throw new EnrollmentError( `pairing code is ${pairingCode.length} bytes, expected ${PAIRING_CODE_BYTES}`, - 'E_QR_CODE', - SCAN_AGAIN - ) + "E_QR_CODE", + SCAN_AGAIN, + ); } // The hint becomes a connection target later, so it is checked here rather // than trusted there. if (lanHint.length > MAX_LAN_HINT_CHARS || !/^[\x21-\x7e]*$/.test(lanHint)) { - throw new EnrollmentError(`lan hint is not a plain address`, 'E_QR_HINT', SCAN_AGAIN) + throw new EnrollmentError( + `lan hint is not a plain address`, + "E_QR_HINT", + SCAN_AGAIN, + ); } // Checked here, where the sentence can still say "scan again". Downstream // this is HKDF input keying material, and a short secret there is a handle @@ -172,37 +221,67 @@ export function parseEnrollmentFragment(fragment: string): Enrollment { if (rendezvousSecret.length !== RENDEZVOUS_SECRET_BYTES) { throw new EnrollmentError( `rendezvous secret is ${rendezvousSecret.length} bytes, expected ${RENDEZVOUS_SECRET_BYTES}`, - 'E_QR_SECRET', - SCAN_AGAIN - ) + "E_QR_SECRET", + SCAN_AGAIN, + ); + } + + if (parts.length === 6) { + // Where a phone dials FTW Cloud, so only a home id the service could have + // cut is taken. + const home = new TextDecoder().decode(decodeSegment(parts[5]!, "home")); + if (!HOME_ID.test(home)) { + throw new EnrollmentError( + `home is not a home id`, + "E_QR_HOME", + SCAN_AGAIN, + ); + } + return { boxStaticPublic, pairingCode, lanHint, rendezvousSecret, home }; } - return { boxStaticPublic, pairingCode, lanHint, rendezvousSecret } + return { boxStaticPublic, pairingCode, lanHint, rendezvousSecret }; } -/** The inverse, for the box simulator and for round-trip tests. */ -export function buildEnrollmentUrl(enrollment: Enrollment, origin?: string): string { - const fragment = [ - ENROLLMENT_VERSION, +/** + * The inverse, for the box simulator and for round-trip tests: v3 when the + * enrollment names a home, else v2. + */ +export function buildEnrollmentUrl( + enrollment: Enrollment, + origin?: string, +): string { + const segments = [ + enrollment.home === undefined + ? ENROLLMENT_VERSION + : ENROLLMENT_VERSION_HOME, encodeBase64url(enrollment.boxStaticPublic), encodeBase64url(enrollment.pairingCode), encodeBase64url(new TextEncoder().encode(enrollment.lanHint)), encodeBase64url(enrollment.rendezvousSecret), - ].join('.') + ]; + if (enrollment.home !== undefined) { + segments.push(encodeBase64url(new TextEncoder().encode(enrollment.home))); + } + const fragment = segments.join("."); - return `${origin ?? `https://${ENROLLMENT_HOST}`}${ENROLLMENT_PATH}#${fragment}` + return `${origin ?? `https://${ENROLLMENT_HOST}`}${ENROLLMENT_PATH}#${fragment}`; } // --------------------------------------------------------------------------- function decodeSegment(segment: string, what: string): Uint8Array { try { - return decodeBase64url(segment) + return decodeBase64url(segment); } catch (err) { if (err instanceof Base64urlError) { - throw new EnrollmentError(`${what}: ${err.message}`, 'E_QR_ENCODING', SCAN_AGAIN) + throw new EnrollmentError( + `${what}: ${err.message}`, + "E_QR_ENCODING", + SCAN_AGAIN, + ); } - throw err + throw err; } } @@ -216,4 +295,4 @@ function decodeSegment(segment: string, what: string): Uint8Array { * but it removed the one shape check in front of a hostile QR while reading * as though the check were enforced. */ -const isLocalDev = isDevHost +const isLocalDev = isDevHost;