All files pairing.ts

100% Statements 34/34
100% Branches 41/41
100% Functions 4/4
100% Lines 34/34

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80                    2x 2x 2x 2x       16x 16x   1x   15x 15x 2x   13x 13x 13x 13x 13x 13x 2x   11x 2x   9x 2x   7x 7x 1x   6x 1x   5x               5x       17x 17x       3x 3x             2x       1x      
// Made with ❤️ by [Vibey](https://the-vibey-project.github.io/vibey/), Developed by [Adam Matthew Steinberger](https://vibewithadam.matthewsteinberger.com/) ([@adammatthewsteinberger](https://github.com/adammatthewsteinberger/)).
/**
 * Reads a pairing offer. The hub's pairing routes (ADR-0068, "the pairing change") have not
 * landed, so the QR format here is the client's side of that contract, and the exchange of
 * the code for a device key is refused plainly by `PairingExchange` until they do.
 * Declared by `interfaces/pairing-interface.ts`.
 */
import type { PairingInterface, PairingOffer, PairingParse } from './interfaces/pairing-interface';
 
export class Pairing implements PairingInterface {
  static readonly SCHEMES: readonly string[] = ['krypton:', 'krypton-nightly:'];
  static readonly CODE = /^\d{6}$/;
  static readonly FINGERPRINT = /^sha256:[0-9a-f]{64}$/i;
  static readonly LOOPBACK: readonly string[] = ['127.0.0.1', 'localhost', '::1', '[::1]'];
 
  parseQr(text: string): PairingParse {
    let url: URL;
    try {
      url = new URL(text.trim());
    } catch {
      return { ok: false, reason: 'That QR code is not a Krypton pairing code.' };
    }
    const target = `${url.host}${url.pathname}`.replace(/^\/+/, '');
    if (!Pairing.SCHEMES.includes(url.protocol) || target !== 'pair') {
      return { ok: false, reason: 'That QR code is not a Krypton pairing code.' };
    }
    const params = url.searchParams;
    const host = params.get('host') ?? '';
    const port = Number(params.get('port') ?? '');
    const code = this.normaliseCode(params.get('code') ?? '');
    const fingerprint = params.get('fp');
    if (host === '' || /[\s/?#@]/.test(host)) {
      return { ok: false, reason: 'The pairing code names no host.' };
    }
    if (!Number.isInteger(port) || port < 1 || port > 65535) {
      return { ok: false, reason: 'The pairing code names no valid port.' };
    }
    if (typeof code !== 'string') {
      return { ok: false, reason: code.error };
    }
    const loopback = Pairing.LOOPBACK.includes(host);
    if (fingerprint === null && !loopback) {
      return { ok: false, reason: 'The pairing code carries no certificate fingerprint, so the hub cannot be trusted.' };
    }
    if (fingerprint !== null && !Pairing.FINGERPRINT.test(fingerprint)) {
      return { ok: false, reason: 'The certificate fingerprint in the pairing code is malformed.' };
    }
    const offer: PairingOffer = {
      host,
      port,
      scheme: loopback && fingerprint === null ? 'http' : 'https',
      fingerprint: fingerprint === null ? null : fingerprint.toLowerCase(),
      code,
      name: params.get('name'),
    };
    return { ok: true, offer };
  }
 
  normaliseCode(typed: string): string | { readonly error: string } {
    const digits = typed.replace(/[\s-]/g, '');
    return Pairing.CODE.test(digits) ? digits : { error: 'The code is the 6 digits the host shows.' };
  }
 
  baseUrl(offer: PairingOffer): string {
    const host = offer.host.includes(':') && !offer.host.startsWith('[') ? `[${offer.host}]` : offer.host;
    return `${offer.scheme}://${host}:${offer.port}`;
  }
}
 
/** Exchanging a code for a device key: refused, honestly, until the hub offers the route. */
export class PairingExchange {
  static readonly NOT_YET =
    'This hub cannot pair devices yet: its pairing routes are still being built. ' +
    'For now, connect with the host token (Settings → Connect with a host token).';
 
  exchange(_offer: PairingOffer): Promise<never> {
    return Promise.reject(new Error(PairingExchange.NOT_YET));
  }
}