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;