Bug 1490671 - Add FxA device pairing. r=markh,rfkelly,vladikoff,flod
authorEdouard Oger <eoger@fastmail.com>
Thu, 21 Feb 2019 20:58:04 +0000
changeset 460314 9dea142f2cc02912cc8bdaf2bddc5debed25a6d6
parent 460313 f830047054e55aee13b12099eb28501634826aee
child 460315 c23dbcea5508a173b9d7d5cf265110763fc15943
push id35590
push userrgurzau@mozilla.com
push dateFri, 22 Feb 2019 05:26:22 +0000
treeherdermozilla-central@cd28688c1642 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmarkh, rfkelly, vladikoff, flod
bugs1490671
milestone67.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1490671 - Add FxA device pairing. r=markh,rfkelly,vladikoff,flod Differential Revision: https://phabricator.services.mozilla.com/D6966
.eslintignore
browser/app/profile/firefox.js
browser/components/preferences/in-content/fxaPairDevice.js
browser/components/preferences/in-content/fxaPairDevice.xul
browser/components/preferences/in-content/jar.mn
browser/components/preferences/in-content/sync.js
browser/components/preferences/in-content/sync.xul
browser/components/preferences/in-content/tests/browser.ini
browser/components/preferences/in-content/tests/browser_sync_pairing.js
browser/locales/en-US/browser/preferences/fxaPairDevice.ftl
browser/locales/en-US/browser/preferences/preferences.ftl
browser/themes/shared/fxa/fxa-spinner.svg
browser/themes/shared/incontentprefs/fxaPairDevice.css
browser/themes/shared/jar.inc.mn
services/fxaccounts/FxAccounts.jsm
services/fxaccounts/FxAccountsClient.jsm
services/fxaccounts/FxAccountsCommon.js
services/fxaccounts/FxAccountsConfig.jsm
services/fxaccounts/FxAccountsOAuthGrantClient.jsm
services/fxaccounts/FxAccountsPairing.jsm
services/fxaccounts/FxAccountsPairingChannel.js
services/fxaccounts/FxAccountsProfileClient.jsm
services/fxaccounts/FxAccountsWebChannel.jsm
services/fxaccounts/moz.build
services/fxaccounts/tests/xpcshell/test_accounts.js
services/fxaccounts/tests/xpcshell/test_pairing.js
services/fxaccounts/tests/xpcshell/xpcshell.ini
tools/lint/eslint/modules.json
--- a/.eslintignore
+++ b/.eslintignore
@@ -288,16 +288,19 @@ python/**
 # security/ exclusions (pref files).
 security/manager/ssl/security-prefs.js
 
 # NSS / taskcluster only.
 security/nss/**
 
 # services/ exclusions
 
+# Webpack-bundled library
+services/fxaccounts/FxAccountsPairingChannel.js
+
 # Uses `#filter substitution`
 services/sync/modules/constants.js
 services/sync/services-sync.js
 
 # Servo is imported.
 servo/**
 
 # Remote protocol exclusions
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1411,16 +1411,22 @@ pref("identity.fxaccounts.remote.root", 
 pref("identity.fxaccounts.contextParam", "fx_desktop_v3");
 
 // The remote URL of the FxA Profile Server
 pref("identity.fxaccounts.remote.profile.uri", "https://profile.accounts.firefox.com/v1");
 
 // The remote URL of the FxA OAuth Server
 pref("identity.fxaccounts.remote.oauth.uri", "https://oauth.accounts.firefox.com/v1");
 
+// Whether FxA pairing using QR codes is enabled.
+pref("identity.fxaccounts.pairing.enabled", false);
+
+// The remote URI of the FxA pairing server
+pref("identity.fxaccounts.remote.pairing.uri", "wss://channelserver.services.mozilla.com");
+
 // Token server used by the FxA Sync identity.
 pref("identity.sync.tokenserver.uri", "https://token.services.mozilla.com/1.0/sync/1.5");
 
 // Auto-config URL for FxA self-hosters, makes an HTTP request to
 // [identity.fxaccounts.autoconfig.uri]/.well-known/fxa-client-configuration
 // This is now the prefered way of pointing to a custom FxA server, instead
 // of making changes to "identity.fxaccounts.*.uri".
 pref("identity.fxaccounts.autoconfig.uri", "");
new file mode 100644
--- /dev/null
+++ b/browser/components/preferences/in-content/fxaPairDevice.js
@@ -0,0 +1,98 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const {FxAccounts} = ChromeUtils.import("resource://gre/modules/FxAccounts.jsm");
+const {Weave} = ChromeUtils.import("resource://services-sync/main.js");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  EventEmitter: "resource://gre/modules/EventEmitter.jsm",
+  FxAccountsPairingFlow: "resource://gre/modules/FxAccountsPairing.jsm",
+});
+const { require } = ChromeUtils.import("resource://devtools/shared/Loader.jsm", {});
+const QR = require("devtools/shared/qrcode/index");
+
+// This is only for "labor illusion", see
+// https://www.fastcompany.com/3061519/the-ux-secret-that-will-ruin-apps-for-you
+const MIN_PAIRING_LOADING_TIME_MS = 1000;
+
+/**
+ * Communication between FxAccountsPairingFlow and gFxaPairDeviceDialog
+ * is done using an emitter via the following messages:
+ * <- [view:SwitchToWebContent] - Notifies the view to navigate to a specific URL.
+ * <- [view:Error] - Notifies the view something went wrong during the pairing process.
+ * -> [view:Closed] - Notifies the pairing module the view was closed.
+ */
+var gFxaPairDeviceDialog = {
+  init() {
+    this._resetBackgroundQR();
+    FxAccounts.config.promiseConnectDeviceURI("pairing-modal").then(connectURI => {
+      document.getElementById("connect-another-device-link").setAttribute("href", connectURI);
+    });
+    // We let the modal show itself before eventually showing a master-password dialog later.
+    Services.tm.dispatchToMainThread(() => this.startPairingFlow());
+  },
+
+  uninit() {
+    this.teardownListeners();
+    this._emitter.emit("view:Closed");
+  },
+
+  async startPairingFlow() {
+    this._resetBackgroundQR();
+    document.getElementById("qrWrapper").setAttribute("pairing-status", "loading");
+    this._emitter = new EventEmitter();
+    this.setupListeners();
+    try {
+      if (!Weave.Utils.ensureMPUnlocked()) {
+        throw new Error("Master-password locked.");
+      }
+      const [, uri] = await Promise.all([
+        new Promise(res => setTimeout(res, MIN_PAIRING_LOADING_TIME_MS)),
+        FxAccountsPairingFlow.start({emitter: this._emitter}),
+      ]);
+      const imgData = QR.encodeToDataURI(uri, "L");
+      document.getElementById("qrContainer").style.backgroundImage = `url("${imgData.src}")`;
+      document.getElementById("qrWrapper").setAttribute("pairing-status", "ready");
+    } catch (e) {
+      this.onError(e);
+    }
+  },
+
+  _resetBackgroundQR() {
+    // The text we encode doesn't really matter as it is un-scannable (blurry and very transparent).
+    const imgData = QR.encodeToDataURI("https://accounts.firefox.com/pair", "L");
+    document.getElementById("qrContainer").style.backgroundImage = `url("${imgData.src}")`;
+  },
+
+  onError(err) {
+    Cu.reportError(err);
+    this.teardownListeners();
+    document.getElementById("qrWrapper").setAttribute("pairing-status", "error");
+  },
+
+  _switchToUrl(url) {
+    const browser = window.docShell.chromeEventHandler;
+    browser.loadURI(url, {
+      triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({}),
+    });
+  },
+
+  setupListeners() {
+    this._switchToWebContent = (_, url) => this._switchToUrl(url);
+    this._onError = (_, error) => this.onError(error);
+    this._emitter.once("view:SwitchToWebContent", this._switchToWebContent);
+    this._emitter.on("view:Error", this._onError);
+  },
+
+  teardownListeners() {
+    try {
+      this._emitter.off("view:SwitchToWebContent", this._switchToWebContent);
+      this._emitter.off("view:Error", this._onError);
+    } catch (e) {
+      console.warn("Error while tearing down listeners.", e);
+    }
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/components/preferences/in-content/fxaPairDevice.xul
@@ -0,0 +1,46 @@
+<?xml version="1.0"?>
+
+<!-- -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- -->
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/"?>
+<?xml-stylesheet href="chrome://browser/skin/preferences/in-content/fxaPairDevice.css" type="text/css"?>
+
+<window id="fxaPairDeviceDialog" class="windowDialog"
+        xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+        xmlns:html="http://www.w3.org/1999/xhtml"
+        role="dialog"
+        onload="gFxaPairDeviceDialog.init();"
+        onunload="gFxaPairDeviceDialog.uninit()"
+        data-l10n-id="fxa-pair-device-dialog"
+        data-l10n-attrs="title, style">
+
+  <linkset>
+    <link rel="localization" href="browser/branding/sync-brand.ftl"/>
+    <link rel="localization" href="browser/preferences/fxaPairDevice.ftl"/>
+  </linkset>
+  <script src="chrome://browser/content/preferences/in-content/fxaPairDevice.js"/>
+
+  <vbox id="qrCodeDisplay">
+    <description class="pairHeading" data-l10n-id="fxa-qrcode-heading-phase1">
+      <html:a
+        id="connect-another-device-link"
+        data-l10n-name="connect-another-device"
+        class="text-link" target="_blank"/>
+    </description>
+    <description class="pairHeading" data-l10n-id="fxa-qrcode-heading-phase2"></description>
+    <vbox>
+      <vbox align="center" id="qrWrapper" pairing-status="loading">
+        <box id="qrContainer"></box>
+        <box id="qrSpinner"></box>
+        <vbox id="qrError" onclick="gFxaPairDeviceDialog.startPairingFlow();">
+          <image id="refresh-qr" />
+          <label class="qr-error-text" data-l10n-id="fxa-qrcode-error-title"></label>
+          <label class="qr-error-text" data-l10n-id="fxa-qrcode-error-body"></label>
+        </vbox>
+      </vbox>
+    </vbox>
+  </vbox>
+</window>
--- a/browser/components/preferences/in-content/jar.mn
+++ b/browser/components/preferences/in-content/jar.mn
@@ -11,9 +11,11 @@ browser.jar:
    content/browser/preferences/in-content/main.js
    content/browser/preferences/in-content/home.js
    content/browser/preferences/in-content/search.js
    content/browser/preferences/in-content/privacy.js
    content/browser/preferences/in-content/containers.js
    content/browser/preferences/in-content/sync.js
    content/browser/preferences/in-content/syncDisconnect.xul
    content/browser/preferences/in-content/syncDisconnect.js
+   content/browser/preferences/in-content/fxaPairDevice.xul
+   content/browser/preferences/in-content/fxaPairDevice.js
    content/browser/preferences/in-content/findInPage.js
--- a/browser/components/preferences/in-content/sync.js
+++ b/browser/components/preferences/in-content/sync.js
@@ -144,34 +144,40 @@ var gSyncPane = {
     // Links for mobile devices before the user is logged in.
     let url = Services.prefs.getCharPref("identity.mobilepromo.android") + "sync-preferences";
     document.getElementById("fxaMobilePromo-android").setAttribute("href", url);
     url = Services.prefs.getCharPref("identity.mobilepromo.ios") + "sync-preferences";
     document.getElementById("fxaMobilePromo-ios").setAttribute("href", url);
 
     // Links for mobile devices shown after the user is logged in.
     FxAccounts.config.promiseConnectDeviceURI(this._getEntryPoint()).then(connectURI => {
-      document.getElementById("mobilePromo-singledevice").setAttribute("href", connectURI);
+      document.getElementById("connect-another-device").setAttribute("href", connectURI);
     });
 
     FxAccounts.config.promiseManageDevicesURI(this._getEntryPoint()).then(manageURI => {
-      document.getElementById("mobilePromo-multidevice").setAttribute("href", manageURI);
+      document.getElementById("manage-devices").setAttribute("href", manageURI);
     });
 
     document.getElementById("tosPP-small-ToS").setAttribute("href", Weave.Svc.Prefs.get("fxa.termsURL"));
     document.getElementById("tosPP-small-PP").setAttribute("href", Weave.Svc.Prefs.get("fxa.privacyURL"));
 
     FxAccounts.config.promiseSignUpURI(this._getEntryPoint()).then(signUpURI => {
       document.getElementById("noFxaSignUp").setAttribute("href", signUpURI);
     });
 
     this.updateWeavePrefs();
 
     // Notify observers that the UI is now ready
     Services.obs.notifyObservers(window, "sync-pane-loaded");
+
+    // document.location.search is empty, so we simply match on `action=pair`.
+    if (location.href.includes("action=pair") && location.hash == "#sync" &&
+        UIState.get().status == UIState.STATUS_SIGNED_IN) {
+      gSyncPane.pairAnotherDevice();
+    }
   },
 
   _toggleComputerNameControls(editMode) {
     let textbox = document.getElementById("fxaSyncComputerName");
     textbox.disabled = !editMode;
     document.getElementById("fxaChangeDeviceName").hidden = editMode;
     document.getElementById("fxaCancelChangeDeviceName").hidden = !editMode;
     document.getElementById("fxaSaveChangeDeviceName").hidden = !editMode;
@@ -333,18 +339,18 @@ var gSyncPane = {
     // The "manage account" link embeds the uid, so we need to update this
     // if the account state changes.
     FxAccounts.config.promiseManageURI(this._getEntryPoint()).then(accountsManageURI => {
       document.getElementById("verifiedManage").setAttribute("href", accountsManageURI);
     });
     let isUnverified = state.status == UIState.STATUS_NOT_VERIFIED;
     // The mobile promo links - which one is shown depends on the number of devices.
     let isMultiDevice = Weave.Service.clientsEngine.stats.numClients > 1;
-    document.getElementById("mobilePromo-singledevice").hidden = isUnverified || isMultiDevice;
-    document.getElementById("mobilePromo-multidevice").hidden = isUnverified || !isMultiDevice;
+    document.getElementById("connect-another-device").hidden = isUnverified;
+    document.getElementById("manage-devices").hidden = isUnverified || !isMultiDevice;
   },
 
   _getEntryPoint() {
     let params = new URLSearchParams(document.URL.split("#")[0].split("?")[1] || "");
     return params.get("entrypoint") || "preferences";
   },
 
   openContentInBrowser(url, options) {
@@ -463,16 +469,24 @@ var gSyncPane = {
                       });
     } else {
       // no confirmation implies no data removal, so just disconnect - but
       // we still disconnect via the SyncDisconnect module for consistency.
       SyncDisconnect.disconnect().finally(() => this.updateWeavePrefs());
     }
   },
 
+  pairAnotherDevice() {
+    gSubDialog.open("chrome://browser/content/preferences/in-content/fxaPairDevice.xul",
+                    "resizable=no", /* aFeatures */
+                    null, /* aParams */
+                    null /* aClosingCallback */
+                    );
+  },
+
   _populateComputerName(value) {
     let textbox = document.getElementById("fxaSyncComputerName");
     if (!textbox.hasAttribute("placeholder")) {
       textbox.setAttribute("placeholder",
         Weave.Utils.getDefaultDeviceName());
     }
     textbox.value = value;
   },
--- a/browser/components/preferences/in-content/sync.xul
+++ b/browser/components/preferences/in-content/sync.xul
@@ -180,20 +180,20 @@
                 data-l10n-id="sync-device-name-cancel"
                 hidden="true"/>
         <button id="fxaSaveChangeDeviceName"
                 data-l10n-id="sync-device-name-save"
                 hidden="true"/>
       </hbox>
     </groupbox>
     <vbox align="start">
-      <label id="mobilePromo-singledevice" is="text-link"
-             class="fxaMobilePromo" data-l10n-id="sync-mobilepromo-single"/>
-      <label id="mobilePromo-multidevice" is="text-link"
-             class="fxaMobilePromo" data-l10n-id="sync-mobilepromo-multi"/>
+      <label id="connect-another-device" is="text-link"
+             class="fxaMobilePromo" data-l10n-id="sync-connect-another-device"/>
+      <label id="manage-devices" is="text-link"
+             class="fxaMobilePromo" data-l10n-id="sync-manage-devices"/>
     </vbox>
     <vbox id="tosPP-small" align="start">
       <label id="tosPP-small-ToS" is="text-link" data-l10n-id="sync-tos-link"/>
       <label id="tosPP-small-PP" is="text-link" data-l10n-id="sync-fxa-privacy-notice"/>
     </vbox>
   </vbox>
 </deck>
 ]]></box>
--- a/browser/components/preferences/in-content/tests/browser.ini
+++ b/browser/components/preferences/in-content/tests/browser.ini
@@ -83,16 +83,17 @@ skip-if = e10s
 [browser_site_autoplay_media_exceptions.js]
 [browser_permissions_dialog.js]
 [browser_subdialogs.js]
 support-files =
   subdialog.xul
   subdialog2.xul
 [browser_sync_sanitize.js]
 skip-if = os == 'win' && processor == "x86_64" && bits == 64 # bug 1522821
+[browser_sync_pairing.js]
 [browser_telemetry.js]
 # Skip this test on Android as FHR and Telemetry are separate systems there.
 skip-if = !healthreport || !telemetry || (os == 'linux' && debug) || (os == 'android')
 [browser_warning_permanent_private_browsing.js]
 [browser_containers_name_input.js]
 run-if = nightly_build # Containers is enabled only on Nightly
 [browser_fluent.js]
 [browser_hometab_restore_defaults.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/preferences/in-content/tests/browser_sync_pairing.js
@@ -0,0 +1,130 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+/* global sinon */
+
+"use strict";
+
+const {UIState} = ChromeUtils.import("resource://services-sync/UIState.jsm", {});
+const {FxAccountsPairingFlow} = ChromeUtils.import("resource://gre/modules/FxAccountsPairing.jsm", {});
+
+// Use sinon for mocking.
+Services.scriptloader.loadSubScript("resource://testing-common/sinon-2.3.2.js");
+registerCleanupFunction(() => {
+  delete window.sinon; // test fails with this reference left behind.
+});
+
+let flowCounter = 0;
+
+add_task(async function setup() {
+  Services.prefs.setBoolPref("identity.fxaccounts.pairing.enabled", true);
+  // Sync start-up might interfere with our tests, don't let UIState send UI updates.
+  const origNotifyStateUpdated = UIState._internal.notifyStateUpdated;
+  UIState._internal.notifyStateUpdated = () => {};
+
+  const origGet = UIState.get;
+  UIState.get = () => { return { status: UIState.STATUS_SIGNED_IN, email: "foo@bar.com" }; };
+
+  const origStart = FxAccountsPairingFlow.start;
+  FxAccountsPairingFlow.start = ({emitter: e}) => {
+    return `https://foo.bar/${flowCounter++}`;
+  };
+
+  registerCleanupFunction(() => {
+    UIState._internal.notifyStateUpdated = origNotifyStateUpdated;
+    UIState.get = origGet;
+    FxAccountsPairingFlow.start = origStart;
+  });
+});
+
+add_task(async function testShowsQRCode() {
+  await runWithPairingDialog(async (win, sinon) => {
+    let doc = win.document;
+    let qrContainer = doc.getElementById("qrContainer");
+    let qrWrapper = doc.getElementById("qrWrapper");
+
+    await TestUtils.waitForCondition(() => qrWrapper.getAttribute("pairing-status") == "ready");
+
+    // Verify that a QRcode is being shown.
+    Assert.ok(qrContainer.style.backgroundImage.startsWith(`url("data:image/gif;base64,R0lGODdhOgA6AIAAAAAAAP///ywAAAAAOgA6AAAC/4yPqcvtD6OctNqLs968+w+G4gKU5nkiJYO2JuW6KsDGKEw3a7AbPZ+r4Ry7nzFIQkKKN6Avlzowo78`));
+
+    // Close the dialog.
+    let promiseUnloaded = BrowserTestUtils.waitForEvent(win, "unload");
+    gBrowser.contentDocument.querySelector(".dialogClose").click();
+
+    info("waiting for dialog to unload");
+    await promiseUnloaded;
+  });
+});
+
+add_task(async function testCantShowQrCode() {
+  const origStart = FxAccountsPairingFlow.start;
+  FxAccountsPairingFlow.start = async () => { throw new Error("boom"); };
+  await runWithPairingDialog(async (win, sinon) => {
+    let doc = win.document;
+    let qrWrapper = doc.getElementById("qrWrapper");
+
+    await TestUtils.waitForCondition(() => qrWrapper.getAttribute("pairing-status") == "error");
+
+    // Close the dialog.
+    let promiseUnloaded = BrowserTestUtils.waitForEvent(win, "unload");
+    gBrowser.contentDocument.querySelector(".dialogClose").click();
+
+    info("waiting for dialog to unload");
+    await promiseUnloaded;
+  });
+  FxAccountsPairingFlow.start = origStart;
+});
+
+add_task(async function testSwitchToWebContent() {
+  await runWithPairingDialog(async (win, sinon) => {
+    let doc = win.document;
+    let qrWrapper = doc.getElementById("qrWrapper");
+
+    await TestUtils.waitForCondition(() => qrWrapper.getAttribute("pairing-status") == "ready");
+
+    const spySwitchURL = sinon.spy(win.gFxaPairDeviceDialog, "_switchToUrl");
+    const emitter = win.gFxaPairDeviceDialog._emitter;
+    emitter.emit("view:SwitchToWebContent", "about:robots");
+
+    Assert.equal(spySwitchURL.callCount, 1);
+  });
+});
+
+add_task(async function testError() {
+  await runWithPairingDialog(async (win, sinon) => {
+    let doc = win.document;
+    let qrWrapper = doc.getElementById("qrWrapper");
+
+    await TestUtils.waitForCondition(() => qrWrapper.getAttribute("pairing-status") == "ready");
+
+    const emitter = win.gFxaPairDeviceDialog._emitter;
+    emitter.emit("view:Error");
+
+    await TestUtils.waitForCondition(() => qrWrapper.getAttribute("pairing-status") == "error");
+
+    // Close the dialog.
+    let promiseUnloaded = BrowserTestUtils.waitForEvent(win, "unload");
+    gBrowser.contentDocument.querySelector(".dialogClose").click();
+
+    info("waiting for dialog to unload");
+    await promiseUnloaded;
+  });
+});
+
+async function runWithPairingDialog(test) {
+  await openPreferencesViaOpenPreferencesAPI("paneSync", {leaveOpen: true});
+
+  let promiseSubDialogLoaded =
+      promiseLoadSubDialog("chrome://browser/content/preferences/in-content/fxaPairDevice.xul");
+  gBrowser.contentWindow.gSyncPane.pairAnotherDevice();
+
+  let win = await promiseSubDialogLoaded;
+
+  let ss = sinon.sandbox.create();
+
+  await test(win, ss);
+
+  ss.restore();
+
+  BrowserTestUtils.removeTab(gBrowser.selectedTab);
+}
new file mode 100644
--- /dev/null
+++ b/browser/locales/en-US/browser/preferences/fxaPairDevice.ftl
@@ -0,0 +1,15 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+fxa-pair-device-dialog =
+    .title = Connect Another Device
+    .style = width: 26em; min-height: 35em;
+
+fxa-qrcode-heading-phase1 = 1. If you haven’t already, install <a data-l10n-name="connect-another-device">Firefox on your mobile device</a>.
+
+fxa-qrcode-heading-phase2 = 2. Then sign in to { -sync-brand-short-name }, or on Android scan the pairing code from inside the { -sync-brand-short-name } settings.
+
+fxa-qrcode-error-title = Pairing unsuccessful.
+
+fxa-qrcode-error-body = Try again.
--- a/browser/locales/en-US/browser/preferences/preferences.ftl
+++ b/browser/locales/en-US/browser/preferences/preferences.ftl
@@ -659,19 +659,21 @@ sync-device-name-change =
 sync-device-name-cancel =
     .label = Cancel
     .accesskey = n
 
 sync-device-name-save =
     .label = Save
     .accesskey = v
 
-sync-mobilepromo-single = Connect another device
+sync-connect-another-device = Connect another device
 
-sync-mobilepromo-multi = Manage devices
+sync-manage-devices = Manage devices
+
+sync-fxa-begin-pairing = Pair a device
 
 sync-tos-link = Terms of Service
 
 sync-fxa-privacy-notice = Privacy Notice
 
 ## Privacy Section
 
 privacy-header = Browser Privacy
new file mode 100644
--- /dev/null
+++ b/browser/themes/shared/fxa/fxa-spinner.svg
@@ -0,0 +1,28 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<svg width="73px" height="73px" viewBox="0 0 73 73" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 46.2 (44496) - http://www.bohemiancoding.com/sketch -->
+    <defs>
+        <linearGradient x1="93.0928096%" y1="52.7734375%" x2="68.5133398%" y2="119.326007%" id="linearGradient-1">
+            <stop stop-color="#0A84FF" stop-opacity="0" offset="0%"></stop>
+            <stop stop-color="#0A84FF" offset="69.3698182%"></stop>
+            <stop stop-color="#0A84FF" offset="100%"></stop>
+            <stop stop-color="#2484C6" stop-opacity="0.00477766951" offset="100%"></stop>
+            <stop stop-color="#2484C6" stop-opacity="0" offset="100%"></stop>
+            <stop stop-color="#2484C6" stop-opacity="0" offset="100%"></stop>
+        </linearGradient>
+        <rect id="path-2" x="0" y="0" width="48" height="60"></rect>
+    </defs>
+    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Shape" transform="translate(-5.000000, -1.000000)">
+            <path d="M41.8,73.8 C21.9,73.8 5.8,57.7 5.8,37.8 C5.8,18.1 21.6,2.2 41.1,1.8 C41.3,1.8 41.4,1.8 41.4,1.8 C41.5,1.8 41.7,1.8 41.8,1.8 C44.6,2.2 46.8,4.5 46.8,7.3 C46.8,10.1 44.6,12.5 41.8,12.7 C28,12.8 16.8,24 16.8,37.8 C16.8,51.6 28,62.8 41.8,62.8 C55.6,62.8 66.8,51.6 66.8,37.8 L77.8,37.8 C77.8,57.7 61.7,73.8 41.8,73.8 Z" fill="url(#linearGradient-1)"></path>
+            <mask id="mask-3" fill="white">
+                <use xlink:href="#path-2"></use>
+            </mask>
+            <g id="Mask"></g>
+            <path d="M41.8,73.8 C21.9,73.8 5.8,57.7 5.8,37.8 C5.8,18.1 21.6,2.2 41.1,1.8 C41.3,1.8 41.4,1.8 41.4,1.8 C41.5,1.8 41.7,1.8 41.8,1.8 C44.6,2.2 46.8,4.5 46.8,7.3 C46.8,10.1 44.6,12.5 41.8,12.7 C28,12.8 16.8,24 16.8,37.8 C16.8,51.6 28,62.8 41.8,62.8 C55.6,62.8 66.8,51.6 66.8,37.8 L77.8,37.8 C77.8,57.7 61.7,73.8 41.8,73.8 Z" fill="#0A84FF" mask="url(#mask-3)"></path>
+        </g>
+    </g>
+</svg>
new file mode 100644
--- /dev/null
+++ b/browser/themes/shared/incontentprefs/fxaPairDevice.css
@@ -0,0 +1,94 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#fxaPairDeviceDialog {
+  padding: 0.5em;
+}
+
+.pairHeading {
+  padding-bottom: 1em;
+  text-align: center;
+}
+
+#qrWrapper {
+  position: relative;
+}
+
+#qrContainer {
+  height: 300px;
+  width: 300px;
+  background-size: contain;
+  image-rendering: -moz-crisp-edges;
+  transition: filter 250ms cubic-bezier(.07,.95,0,1);
+}
+
+#qrWrapper:not([pairing-status="ready"]) #qrContainer {
+  opacity: 0.05;
+  filter: blur(3px);
+}
+
+#qrWrapper:not([pairing-status="loading"]) #qrSpinner {
+  opacity: 0;
+}
+
+#qrSpinner {
+	background-image: url("chrome://browser/skin/fxa/fxa-spinner.svg");
+	animation: 0.9s spin infinite linear;
+	background-size: 36px;
+	background-repeat: no-repeat;
+	background-position: center;
+	width: 100%;
+	position: absolute;
+	top: 0;
+	bottom: 0;
+	left: 0;
+  transition: opacity 250ms cubic-bezier(.07,.95,0,1);
+}
+
+#qrWrapper:not([pairing-status="error"]) #qrError {
+  display: none;
+}
+
+#qrError {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  flex-direction: column;
+  width: 300px; /* Same as #qrContainer */
+	position: absolute;
+	top: 0;
+	bottom: 0;
+	left: 0;
+  right: 0;
+  margin-left: auto;
+  margin-right: auto;
+  transition: opacity 250ms cubic-bezier(.07,.95,0,1);
+  cursor: pointer;
+}
+
+.qr-error-text {
+  text-align: center;
+  -moz-user-select: none;
+  display: block;
+  color: #2484C6;
+  cursor: pointer;
+}
+
+#refresh-qr {
+  width: 36px;
+  height: 36px;
+  background-image: url("chrome://browser/skin/reload.svg");
+  background-size: contain;
+  -moz-context-properties: fill;
+  fill: #2484C6;
+}
+
+@keyframes spin {
+  0% {
+    transform: rotate(0deg);
+  }
+  100% {
+    transform: rotate(360deg);
+  }
+}
--- a/browser/themes/shared/jar.inc.mn
+++ b/browser/themes/shared/jar.inc.mn
@@ -104,37 +104,39 @@
   skin/classic/browser/panel-icon-cancel.svg                   (../shared/panel-icon-cancel.svg)
 #ifndef XP_MACOSX
   skin/classic/browser/panel-icon-folder.svg                   (../shared/panel-icon-folder.svg)
 #else
   skin/classic/browser/panel-icon-magnifier.svg                (../shared/panel-icon-magnifier.svg)
 #endif
   skin/classic/browser/panel-icon-retry.svg                    (../shared/panel-icon-retry.svg)
   skin/classic/browser/preferences/in-content/critters-postcard.jpg       (../shared/incontentprefs/critters-postcard.jpg)
-  skin/classic/browser/preferences/in-content/face-sad.svg     (../shared/incontentprefs/face-sad.svg)
-  skin/classic/browser/preferences/in-content/face-smile.svg   (../shared/incontentprefs/face-smile.svg)
-  skin/classic/browser/preferences/in-content/fxa-avatar.svg   (../shared/incontentprefs/fxa-avatar.svg)
-  skin/classic/browser/preferences/in-content/general.svg      (../shared/incontentprefs/general.svg)
-  skin/classic/browser/preferences/in-content/logo-android.svg (../shared/incontentprefs/logo-android.svg)
-  skin/classic/browser/preferences/in-content/logo-ios.svg     (../shared/incontentprefs/logo-ios.svg)
+  skin/classic/browser/preferences/in-content/face-sad.svg      (../shared/incontentprefs/face-sad.svg)
+  skin/classic/browser/preferences/in-content/face-smile.svg    (../shared/incontentprefs/face-smile.svg)
+  skin/classic/browser/preferences/in-content/fxa-avatar.svg    (../shared/incontentprefs/fxa-avatar.svg)
+  skin/classic/browser/preferences/in-content/fxaPairDevice.css (../shared/incontentprefs/fxaPairDevice.css)
+  skin/classic/browser/preferences/in-content/general.svg       (../shared/incontentprefs/general.svg)
+  skin/classic/browser/preferences/in-content/logo-android.svg  (../shared/incontentprefs/logo-android.svg)
+  skin/classic/browser/preferences/in-content/logo-ios.svg      (../shared/incontentprefs/logo-ios.svg)
   skin/classic/browser/preferences/in-content/no-search-bar.svg           (../shared/incontentprefs/no-search-bar.svg)
   skin/classic/browser/preferences/in-content/no-search-results.svg       (../shared/incontentprefs/no-search-results.svg)
   skin/classic/browser/preferences/in-content/privacy-security.svg        (../shared/incontentprefs/privacy-security.svg)
   skin/classic/browser/preferences/in-content/privacy.css      (../shared/incontentprefs/privacy.css)
   skin/classic/browser/preferences/in-content/search-arrow-indicator.svg  (../shared/incontentprefs/search-arrow-indicator.svg)
   skin/classic/browser/preferences/in-content/search-bar.svg   (../shared/incontentprefs/search-bar.svg)
   skin/classic/browser/preferences/in-content/search.css       (../shared/incontentprefs/search.css)
   skin/classic/browser/preferences/in-content/search.svg       (../shared/incontentprefs/search.svg)
   skin/classic/browser/preferences/in-content/siteDataSettings.css (../shared/incontentprefs/siteDataSettings.css)
   skin/classic/browser/preferences/in-content/sync-devices.svg (../shared/incontentprefs/sync-devices.svg)
   skin/classic/browser/preferences/in-content/sync.svg         (../shared/incontentprefs/sync.svg)
   skin/classic/browser/preferences/in-content/syncDisconnect.css (../shared/incontentprefs/syncDisconnect.css)
 * skin/classic/browser/preferences/in-content/containers.css   (../shared/incontentprefs/containers.css)
 * skin/classic/browser/preferences/containers.css              (../shared/preferences/containers.css)
   skin/classic/browser/fxa/default-avatar.svg                  (../shared/fxa/default-avatar.svg)
+  skin/classic/browser/fxa/fxa-spinner.svg                     (../shared/fxa/fxa-spinner.svg)
   skin/classic/browser/fxa/sync-illustration.svg               (../shared/fxa/sync-illustration.svg)
   skin/classic/browser/fxa/sync-illustration-issue.svg         (../shared/fxa/sync-illustration-issue.svg)
 
 
   skin/classic/browser/accessibility.svg              (../shared/icons/accessibility.svg)
   skin/classic/browser/accessibility-active.svg       (../shared/icons/accessibility-active.svg)
   skin/classic/browser/add.svg                        (../shared/icons/add.svg)
   skin/classic/browser/arrow-left.svg                 (../shared/icons/arrow-left.svg)
--- a/services/fxaccounts/FxAccounts.jsm
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -5,17 +5,17 @@
 
 const {PromiseUtils} = ChromeUtils.import("resource://gre/modules/PromiseUtils.jsm");
 const {CommonUtils} = ChromeUtils.import("resource://services-common/utils.js");
 const {CryptoUtils} = ChromeUtils.import("resource://services-crypto/utils.js");
 const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
 const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 const {clearTimeout, setTimeout} = ChromeUtils.import("resource://gre/modules/Timer.jsm");
 const {FxAccountsStorageManager} = ChromeUtils.import("resource://gre/modules/FxAccountsStorage.jsm");
-const {ASSERTION_LIFETIME, ASSERTION_USE_PERIOD, CERT_LIFETIME, COMMAND_SENDTAB, DERIVED_KEYS_NAMES, ERRNO_DEVICE_SESSION_CONFLICT, ERRNO_INVALID_AUTH_TOKEN, ERRNO_UNKNOWN_DEVICE, ERROR_AUTH_ERROR, ERROR_INVALID_PARAMETER, ERROR_NO_ACCOUNT, ERROR_OFFLINE, ERROR_TO_GENERAL_ERROR_CLASS, ERROR_UNKNOWN, ERROR_UNVERIFIED_ACCOUNT, FXA_PWDMGR_MEMORY_FIELDS, FXA_PWDMGR_PLAINTEXT_FIELDS, FXA_PWDMGR_REAUTH_WHITELIST, FXA_PWDMGR_SECURE_FIELDS, FX_OAUTH_CLIENT_ID, KEY_LIFETIME, ONLOGIN_NOTIFICATION, ONLOGOUT_NOTIFICATION, ONVERIFIED_NOTIFICATION, ON_DEVICE_DISCONNECTED_NOTIFICATION, ON_NEW_DEVICE_ID, POLL_SESSION, PREF_LAST_FXA_USER, SERVER_ERRNO_TO_ERROR, log, logPII} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
+const {ASSERTION_LIFETIME, ASSERTION_USE_PERIOD, CERT_LIFETIME, COMMAND_SENDTAB, DERIVED_KEYS_NAMES, ERRNO_DEVICE_SESSION_CONFLICT, ERRNO_INVALID_AUTH_TOKEN, ERRNO_UNKNOWN_DEVICE, ERROR_AUTH_ERROR, ERROR_INVALID_PARAMETER, ERROR_NO_ACCOUNT, ERROR_OFFLINE, ERROR_TO_GENERAL_ERROR_CLASS, ERROR_UNKNOWN, ERROR_UNVERIFIED_ACCOUNT, FXA_PWDMGR_MEMORY_FIELDS, FXA_PWDMGR_PLAINTEXT_FIELDS, FXA_PWDMGR_REAUTH_WHITELIST, FXA_PWDMGR_SECURE_FIELDS, FX_OAUTH_CLIENT_ID, KEY_LIFETIME, ONLOGIN_NOTIFICATION, ONLOGOUT_NOTIFICATION, ONVERIFIED_NOTIFICATION, ON_DEVICE_DISCONNECTED_NOTIFICATION, ON_NEW_DEVICE_ID, POLL_SESSION, PREF_LAST_FXA_USER, SERVER_ERRNO_TO_ERROR, SCOPE_OLD_SYNC, log, logPII} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
 
 ChromeUtils.defineModuleGetter(this, "FxAccountsClient",
   "resource://gre/modules/FxAccountsClient.jsm");
 
 ChromeUtils.defineModuleGetter(this, "FxAccountsConfig",
   "resource://gre/modules/FxAccountsConfig.jsm");
 
 ChromeUtils.defineModuleGetter(this, "jwcrypto",
@@ -43,19 +43,21 @@ var publicProperties = [
   "canGetKeys",
   "checkVerificationStatus",
   "commands",
   "getAccountsClient",
   "getAssertion",
   "getDeviceId",
   "getDeviceList",
   "getKeys",
+  "authorizeOAuthCode",
   "getOAuthToken",
   "getProfileCache",
   "getPushSubscription",
+  "getScopedKeys",
   "getSignedInUser",
   "getSignedInUserProfile",
   "handleAccountDestroyed",
   "handleDeviceDisconnection",
   "handleEmailUpdated",
   "hasLocalSession",
   "invalidateCertificate",
   "loadAndPoll",
@@ -413,16 +415,28 @@ FxAccountsInternal.prototype = {
   _commands: null,
   get commands() {
     if (!this._commands) {
       this._commands = new FxAccountsCommands(this);
     }
     return this._commands;
   },
 
+  _oauthClient: null,
+  get oauthClient() {
+    if (!this._oauthClient) {
+      const serverURL = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.oauth.uri");
+      this._oauthClient = new FxAccountsOAuthGrantClient({
+        serverURL,
+        client_id: FX_OAUTH_CLIENT_ID,
+      });
+    }
+    return this._oauthClient;
+  },
+
   // A hook-point for tests who may want a mocked AccountState or mocked storage.
   newAccountState(credentials) {
     let storage = new FxAccountsStorageManager();
     storage.initialize(credentials);
     return new AccountState(storage);
   },
 
   // "Friend" classes of FxAccounts (e.g. FxAccountsCommands) know about the
@@ -1234,16 +1248,51 @@ FxAccountsInternal.prototype = {
       await currentState.updateUserAccountData(toUpdate);
     }
     return {
       keyPair: keyPair.rawKeyPair,
       certificate,
     };
   },
 
+  /**
+   * @param {String} scope Single key bearing scope
+   */
+  async getKeyForScope(scope, {keyRotationTimestamp}) {
+    if (scope !== SCOPE_OLD_SYNC) {
+      throw new Error(`Unavailable key material for ${scope}`);
+    }
+    let {kSync, kXCS} = await this.getKeys();
+    if (!kSync || !kXCS) {
+      throw new Error("Could not find requested key.");
+    }
+    kXCS = ChromeUtils.base64URLEncode(CommonUtils.hexToArrayBuffer(kXCS), {pad: false});
+    kSync = ChromeUtils.base64URLEncode(CommonUtils.hexToArrayBuffer(kSync), {pad: false});
+    const kid = `${keyRotationTimestamp}-${kXCS}`;
+    return {
+      scope,
+      kid,
+      k: kSync,
+      kty: "oct",
+    };
+  },
+
+  /**
+   * @param {String} scopes Space separated requested scopes
+   */
+  async getScopedKeys(scopes, clientId) {
+    const {sessionToken} = await this._getVerifiedAccountOrReject();
+    const keyData = await this.fxAccountsClient.getScopedKeyData(sessionToken, clientId, scopes);
+    const scopedKeys = {};
+    for (const [scope, data] of Object.entries(keyData)) {
+      scopedKeys[scope] = await this.getKeyForScope(scope, data);
+    }
+    return scopedKeys;
+  },
+
   getUserAccountData() {
     return this.currentAccountState.getUserAccountData();
   },
 
   isUserEmailVerified: function isUserEmailVerified(data) {
     return !!(data && data.verified);
   },
 
@@ -1450,29 +1499,17 @@ FxAccountsInternal.prototype = {
     let cached = currentState.getCachedToken(scope);
     if (cached) {
       log.debug("getOAuthToken returning a cached token");
       return cached.token;
     }
 
     // We are going to hit the server - this is the string we pass to it.
     let scopeString = scope.join(" ");
-    let client = options.client;
-
-    if (!client) {
-      try {
-        let defaultURL = Services.urlFormatter.formatURLPref("identity.fxaccounts.remote.oauth.uri");
-        client = new FxAccountsOAuthGrantClient({
-          serverURL: defaultURL,
-          client_id: FX_OAUTH_CLIENT_ID,
-        });
-      } catch (e) {
-        throw this._error(ERROR_INVALID_PARAMETER, e);
-      }
-    }
+    let client = options.client || this.oauthClient;
     let oAuthURL = client.serverURL.href;
 
     try {
       log.debug("getOAuthToken fetching new token from", oAuthURL);
       let assertion = await this.getAssertion(oAuthURL);
       let result = await client.getTokenFromAssertion(assertion, scopeString);
       let token = result.access_token;
       // If we got one, cache it.
@@ -1492,16 +1529,59 @@ FxAccountsInternal.prototype = {
       }
       return token;
     } catch (err) {
       throw this._errorToErrorClass(err);
     }
   },
 
   /**
+   *
+   * @param {String} clientId
+   * @param {String} scope Space separated requested scopes
+   * @param {Object} jwk
+   */
+  async createKeysJWE(clientId, scope, jwk) {
+    let scopedKeys = await this.getScopedKeys(scope, clientId);
+    scopedKeys = new TextEncoder().encode(JSON.stringify(scopedKeys));
+    return jwcrypto.generateJWE(jwk, scopedKeys);
+  },
+
+  /**
+   * Retrieves an OAuth authorization code
+   *
+   * @param {Object} options
+   * @param options.client_id
+   * @param options.state
+   * @param options.scope
+   * @param options.access_type
+   * @param options.code_challenge_method
+   * @param options.code_challenge
+   * @param [options.keys_jwe]
+   * @returns {Promise<Object>} Object containing "code" and "state" properties.
+   */
+  async authorizeOAuthCode(options) {
+    await this._getVerifiedAccountOrReject();
+    const client = this.oauthClient;
+    const oAuthURL = client.serverURL.href;
+    const params = {...options};
+    if (params.keys_jwk) {
+      const jwk = JSON.parse(new TextDecoder().decode(ChromeUtils.base64URLDecode(params.keys_jwk, {padding: "reject"})));
+      params.keys_jwe = await this.createKeysJWE(params.client_id, params.scope, jwk);
+      delete params.keys_jwk;
+    }
+    try {
+      const assertion = await this.getAssertion(oAuthURL);
+      return client.authorizeCodeFromAssertion(assertion, params);
+    } catch (err) {
+      throw this._errorToErrorClass(err);
+    }
+  },
+
+  /**
    * Remove an OAuth token from the token cache.  Callers should call this
    * after they determine a token is invalid, so a new token will be fetched
    * on the next call to getOAuthToken().
    *
    * @param options
    *        {
    *          token: (string) A previously fetched token.
    *        }
--- a/services/fxaccounts/FxAccountsClient.jsm
+++ b/services/fxaccounts/FxAccountsClient.jsm
@@ -196,16 +196,42 @@ this.FxAccountsClient.prototype = {
             return Promise.resolve(false);
           }
           throw error;
         }
       );
   },
 
   /**
+   * Query for the information required to derive
+   * scoped encryption keys requested by the specified OAuth client.
+   *
+   * @param sessionTokenHex
+   *        The session token encoded in hex
+   * @param clientId
+   * @param scope
+   *        Space separated list of scopes
+   * @return Promise
+   */
+  async getScopedKeyData(sessionTokenHex, clientId, scope) {
+    if (!clientId) {
+      throw new Error("Missing 'clientId' parameter");
+    }
+    if (!scope) {
+      throw new Error("Missing 'scope' parameter");
+    }
+    const params = {
+      client_id: clientId,
+      scope,
+    };
+    const credentials = await deriveHawkCredentials(sessionTokenHex, "sessionToken");
+    return this._request("/account/scoped-key-data", "POST", credentials, params);
+  },
+
+  /**
    * Destroy the current session with the Firefox Account API server and its
    * associated device.
    *
    * @param sessionTokenHex
    *        The session token encoded in hex
    * @return Promise
    */
   async signOut(sessionTokenHex, options = {}) {
--- a/services/fxaccounts/FxAccountsCommon.js
+++ b/services/fxaccounts/FxAccountsCommon.js
@@ -73,27 +73,47 @@ exports.ON_COMMAND_RECEIVED_NOTIFICATION
 exports.FXA_PUSH_SCOPE_ACCOUNT_UPDATE = "chrome://fxa-device-update";
 
 exports.ON_PROFILE_CHANGE_NOTIFICATION = "fxaccounts:profilechange"; // WebChannel
 exports.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION = "fxaccounts:statechange";
 exports.ON_NEW_DEVICE_ID = "fxaccounts:new_device_id";
 
 exports.COMMAND_SENDTAB = "https://identity.mozilla.com/cmd/open-uri";
 
+// OAuth
+exports.FX_OAUTH_CLIENT_ID = "5882386c6d801776";
+exports.SCOPE_PROFILE = "profile";
+exports.SCOPE_OLD_SYNC = "https://identity.mozilla.com/apps/oldsync";
+
 // UI Requests.
 exports.UI_REQUEST_SIGN_IN_FLOW = "signInFlow";
 exports.UI_REQUEST_REFRESH_AUTH = "refreshAuthentication";
 
-// The OAuth client ID for Firefox Desktop
-exports.FX_OAUTH_CLIENT_ID = "5882386c6d801776";
-
 // Firefox Accounts WebChannel ID
 exports.WEBCHANNEL_ID = "account_updates";
 
+// WebChannel commands
+exports.COMMAND_PAIR_HEARTBEAT = "fxaccounts:pair_heartbeat";
+exports.COMMAND_PAIR_SUPP_METADATA = "fxaccounts:pair_supplicant_metadata";
+exports.COMMAND_PAIR_AUTHORIZE = "fxaccounts:pair_authorize";
+exports.COMMAND_PAIR_DECLINE = "fxaccounts:pair_decline";
+exports.COMMAND_PAIR_COMPLETE = "fxaccounts:pair_complete";
+
+exports.COMMAND_PROFILE_CHANGE = "profile:change";
+exports.COMMAND_CAN_LINK_ACCOUNT = "fxaccounts:can_link_account";
+exports.COMMAND_LOGIN = "fxaccounts:login";
+exports.COMMAND_LOGOUT = "fxaccounts:logout";
+exports.COMMAND_DELETE = "fxaccounts:delete";
+exports.COMMAND_SYNC_PREFERENCES = "fxaccounts:sync_preferences";
+exports.COMMAND_CHANGE_PASSWORD = "fxaccounts:change_password";
+exports.COMMAND_FXA_STATUS = "fxaccounts:fxa_status";
+exports.COMMAND_PAIR_PREFERENCES = "fxaccounts:pair_preferences";
+
 exports.PREF_LAST_FXA_USER = "identity.fxaccounts.lastSignedInUserHash";
+exports.PREF_REMOTE_PAIRING_URI = "identity.fxaccounts.remote.pairing.uri";
 
 // Server errno.
 // From https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-format
 exports.ERRNO_ACCOUNT_ALREADY_EXISTS         = 101;
 exports.ERRNO_ACCOUNT_DOES_NOT_EXIST         = 102;
 exports.ERRNO_INCORRECT_PASSWORD             = 103;
 exports.ERRNO_UNVERIFIED_ACCOUNT             = 104;
 exports.ERRNO_INVALID_VERIFICATION_CODE      = 105;
--- a/services/fxaccounts/FxAccountsConfig.jsm
+++ b/services/fxaccounts/FxAccountsConfig.jsm
@@ -24,77 +24,111 @@ XPCOMUtils.defineLazyPreferenceGetter(th
                                       "identity.fxaccounts.allowHttp", false,
                                       null, val => !val);
 
 const CONFIG_PREFS = [
   "identity.fxaccounts.remote.root",
   "identity.fxaccounts.auth.uri",
   "identity.fxaccounts.remote.oauth.uri",
   "identity.fxaccounts.remote.profile.uri",
+  "identity.fxaccounts.remote.pairing.uri",
   "identity.sync.tokenserver.uri",
 ];
 
 var FxAccountsConfig = {
   async promiseSignUpURI(entrypoint) {
-    return this._buildURL("signup", {entrypoint});
+    return this._buildURL("signup", {
+      extraParams: {entrypoint},
+    });
   },
 
   async promiseSignInURI(entrypoint) {
-    return this._buildURL("signin", {entrypoint});
+    return this._buildURL("signin", {
+      extraParams: {entrypoint},
+    });
   },
 
   async promiseEmailURI(email, entrypoint) {
-    return this._buildURL("", {entrypoint, email});
+    return this._buildURL("", {
+      extraParams: {entrypoint, email},
+    });
   },
 
   async promiseEmailFirstURI(entrypoint) {
     return this._buildURL("", {entrypoint});
   },
 
   async promiseForceSigninURI(entrypoint) {
-    return this._buildURL("force_auth", {entrypoint}, true);
+    return this._buildURL("force_auth", {
+      extraParams: {entrypoint},
+      addAccountIdentifiers: true,
+    });
   },
 
   async promiseManageURI(entrypoint) {
-    return this._buildURL("settings", {entrypoint}, true);
+    return this._buildURL("settings", {
+      extraParams: {entrypoint},
+      addAccountIdentifiers: true,
+    });
   },
 
   async promiseChangeAvatarURI(entrypoint) {
-    return this._buildURL("settings/avatar/change", {entrypoint}, true);
+    return this._buildURL("settings/avatar/change", {
+      extraParams: {entrypoint},
+      addAccountIdentifiers: true,
+    });
   },
 
   async promiseManageDevicesURI(entrypoint) {
-    return this._buildURL("settings/clients", {entrypoint}, true);
+    return this._buildURL("settings/clients", {
+      extraParams: {entrypoint},
+      addAccountIdentifiers: true,
+    });
   },
 
   async promiseConnectDeviceURI(entrypoint) {
-    return this._buildURL("connect_another_device", {entrypoint}, true);
+    return this._buildURL("connect_another_device", {
+      extraParams: {entrypoint},
+      addAccountIdentifiers: true,
+    });
+  },
+
+  async promisePairingURI() {
+    return this._buildURL("pair", {
+      includeDefaultParams: false,
+    });
+  },
+
+  async promiseOAuthURI() {
+    return this._buildURL("oauth", {
+      includeDefaultParams: false,
+    });
   },
 
   get defaultParams() {
     return {service: "sync", context: CONTEXT_PARAM};
   },
 
   /**
    * @param path should be parsable by the URL constructor first parameter.
-   * @param {Object.<string, string>} [extraParams] Additionnal search params.
-   * @param {bool} [addCredentials] if true we add the current logged-in user
-   *                                uid and email to the search params.
+   * @param {bool} [options.includeDefaultParams] If true include the default search params.
+   * @param {Object.<string, string>} [options.extraParams] Additionnal search params.
+   * @param {bool} [options.addAccountIdentifiers] if true we add the current logged-in user uid and email to the search params.
    */
-  async _buildURL(path, extraParams, addCredentials = false) {
+  async _buildURL(path, {includeDefaultParams = true, extraParams = {}, addAccountIdentifiers = false}) {
     await this.ensureConfigured();
     const url = new URL(path, ROOT_URL);
     if (REQUIRES_HTTPS && url.protocol != "https:") {
       throw new Error("Firefox Accounts server must use HTTPS");
     }
-    const params = {...this.defaultParams, ...extraParams};
+    const params = {...(includeDefaultParams ? this.defaultParams : null), ...extraParams};
     for (let [k, v] of Object.entries(params)) {
       url.searchParams.append(k, v);
     }
-    if (addCredentials) {
+    if (addAccountIdentifiers) {
       const accountData = await this.getSignedInUser();
       if (!accountData) {
         return null;
       }
       url.searchParams.append("uid", accountData.uid);
       url.searchParams.append("email", accountData.email);
     }
     return url.href;
@@ -202,16 +236,21 @@ var FxAccountsConfig = {
       // Update the prefs directly specified by the config.
       let config = JSON.parse(resp.body);
       let authServerBase = config.auth_server_base_url;
       if (!authServerBase.endsWith("/v1")) {
         authServerBase += "/v1";
       }
       Services.prefs.setCharPref("identity.fxaccounts.auth.uri", authServerBase);
       Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", config.oauth_server_base_url + "/v1");
+      // At the time of landing this, our servers didn't yet answer with pairing_server_base_uri.
+      // Remove this condition check once Firefox 68 is stable.
+      if (config.pairing_server_base_uri) {
+        Services.prefs.setCharPref("identity.fxaccounts.remote.pairing.uri", config.pairing_server_base_uri);
+      }
       Services.prefs.setCharPref("identity.fxaccounts.remote.profile.uri", config.profile_server_base_url + "/v1");
       Services.prefs.setCharPref("identity.sync.tokenserver.uri", config.sync_tokenserver_base_url + "/1.0/sync/1.5");
       Services.prefs.setCharPref("identity.fxaccounts.remote.root", rootURL);
 
       // Ensure the webchannel is pointed at the correct uri
       EnsureFxAccountsWebChannel();
     } catch (e) {
       log.error("Failed to initialize configuration preferences from autoconfig object", e);
--- a/services/fxaccounts/FxAccountsOAuthGrantClient.jsm
+++ b/services/fxaccounts/FxAccountsOAuthGrantClient.jsm
@@ -76,16 +76,51 @@ this.FxAccountsOAuthGrantClient.prototyp
       assertion,
       response_type: "token",
     };
 
     return this._createRequest(AUTH_ENDPOINT, "POST", params);
   },
 
   /**
+   * Retrieves an OAuth authorization code using an assertion
+   *
+   * @param {Object} assertion BrowserID assertion
+   * @param {Object} options
+   * @param options.client_id
+   * @param options.state
+   * @param options.scope
+   * @param options.access_type
+   * @param options.code_challenge_method
+   * @param options.code_challenge
+   * @param [options.keys_jwe]
+   * @returns {Promise<Object>} Object containing "code" and "state" properties.
+   */
+  authorizeCodeFromAssertion(assertion, options) {
+    if (!assertion) {
+      throw new Error("Missing 'assertion' parameter");
+    }
+    const {client_id, state, scope, access_type, code_challenge, code_challenge_method, keys_jwe} = options;
+    const params = {
+      assertion,
+      client_id,
+      response_type: "code",
+      state,
+      scope,
+      access_type,
+      code_challenge,
+      code_challenge_method,
+    };
+    if (keys_jwe) {
+      params.keys_jwe = keys_jwe;
+    }
+    return this._createRequest(AUTH_ENDPOINT, "POST", params);
+  },
+
+  /**
    * Destroys a previously fetched OAuth access token.
    *
    * @param {String} token The previously fetched token
    * @return Promise
    *        Resolves: {Object} with the server response, which is typically
    *        ignored.
    */
   destroyToken(token) {
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/FxAccountsPairing.jsm
@@ -0,0 +1,340 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+"use strict";
+
+const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const {log, PREF_REMOTE_PAIRING_URI, COMMAND_PAIR_SUPP_METADATA, COMMAND_PAIR_AUTHORIZE, COMMAND_PAIR_DECLINE, COMMAND_PAIR_HEARTBEAT, COMMAND_PAIR_COMPLETE} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
+const {fxAccounts, FxAccounts} = ChromeUtils.import("resource://gre/modules/FxAccounts.jsm");
+const {setTimeout, clearTimeout} = ChromeUtils.import("resource://gre/modules/Timer.jsm");
+ChromeUtils.import("resource://services-common/utils.js");
+ChromeUtils.defineModuleGetter(this, "Weave", "resource://services-sync/main.js");
+ChromeUtils.defineModuleGetter(this, "FxAccountsPairingChannel", "resource://gre/modules/FxAccountsPairingChannel.js");
+
+const PAIRING_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob:pair-auth-webchannel";
+// A pairing flow is not tied to a specific browser window, can also finish in
+// various ways and subsequently might leak a Web Socket, so just in case we
+// time out and free-up the resources after a specified amount of time.
+const FLOW_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes.
+
+class PairingStateMachine {
+  constructor(emitter) {
+    this._emitter = emitter;
+    this._transition(SuppConnectionPending);
+  }
+
+  get currentState() {
+    return this._currentState;
+  }
+
+  _transition(StateCtor, ...args) {
+    const state = new StateCtor(this, ...args);
+    this._currentState = state;
+  }
+
+  assertState(RequiredStates, messagePrefix = null) {
+    if (!(RequiredStates instanceof Array)) {
+      RequiredStates = [RequiredStates];
+    }
+    if (!RequiredStates.some(RequiredState => this._currentState instanceof RequiredState)) {
+      const msg = `${messagePrefix ? `${messagePrefix}. ` : ""}Valid expected states: ${RequiredStates.map(({name}) => name).join(", ")}. Current state: ${this._currentState.label}.`;
+      throw new Error(msg);
+    }
+  }
+}
+
+/**
+ * The pairing flow can be modeled by a finite state machine:
+ * We start by connecting to a WebSocket channel (SuppConnectionPending).
+ * Then the other party connects and requests some metadata from us (PendingConfirmations).
+ * A confirmation happens locally first (PendingRemoteConfirmation)
+ * or the oppposite (PendingLocalConfirmation).
+ * Any side can decline this confirmation (Aborted).
+ * Once both sides have confirmed, the pairing flow is finished (Completed).
+ * During this flow errors can happen and should be handled (Errored).
+ */
+class State {
+  constructor(stateMachine, ...args) {
+    this._transition = (...args) => stateMachine._transition(...args);
+    this._notify = (...args) => stateMachine._emitter.emit(...args);
+    this.init(...args);
+  }
+
+  init() { /* Does nothing by default but can be re-implemented. */ }
+
+  get label() {
+    return this.constructor.name;
+  }
+
+  hasErrored(error) {
+    this._notify("view:Error", error);
+    this._transition(Errored, error);
+  }
+
+  hasAborted() {
+    this._transition(Aborted);
+  }
+}
+class SuppConnectionPending extends State {
+  suppConnected(sender, oauthOptions) {
+    this._transition(PendingConfirmations, sender, oauthOptions);
+  }
+}
+class PendingConfirmationsState extends State {
+  localConfirmed() { throw new Error("Subclasses must implement this method."); }
+  remoteConfirmed() { throw new Error("Subclasses must implement this method."); }
+}
+class PendingConfirmations extends PendingConfirmationsState {
+  init(sender, oauthOptions) {
+    this.sender = sender;
+    this.oauthOptions = oauthOptions;
+  }
+
+  localConfirmed() {
+    this._transition(PendingRemoteConfirmation);
+  }
+
+  remoteConfirmed() {
+    this._transition(PendingLocalConfirmation, this.sender, this.oauthOptions);
+  }
+}
+class PendingLocalConfirmation extends PendingConfirmationsState {
+  init(sender, oauthOptions) {
+    this.sender = sender;
+    this.oauthOptions = oauthOptions;
+  }
+
+  localConfirmed() {
+    this._transition(Completed);
+  }
+
+  remoteConfirmed() {
+    throw new Error("Insane state! Remote has already been confirmed at this point.");
+  }
+}
+class PendingRemoteConfirmation extends PendingConfirmationsState {
+  localConfirmed() {
+    throw new Error("Insane state! Local has already been confirmed at this point.");
+  }
+
+  remoteConfirmed() {
+    this._transition(Completed);
+  }
+}
+class Completed extends State {}
+class Aborted extends State {}
+class Errored extends State {
+  init(error) {
+    this.error = error;
+  }
+}
+
+const flows = new Map();
+this.FxAccountsPairingFlow = class FxAccountsPairingFlow {
+  static get(channelId) {
+    return flows.get(channelId);
+  }
+
+  static finalizeAll() {
+    for (const flow of flows) {
+      flow.finalize();
+    }
+  }
+
+  static async start(options) {
+    const {emitter} = options;
+    const fxaConfig = options.fxaConfig || FxAccounts.config;
+    const fxa = options.fxAccounts || fxAccounts;
+    const weave = options.weave || Weave;
+    const flowTimeout = options.flowTimeout || FLOW_TIMEOUT_MS;
+
+    const contentPairingURI = await fxaConfig.promisePairingURI();
+    const wsUri = Services.urlFormatter.formatURLPref(PREF_REMOTE_PAIRING_URI);
+    const pairingChannel = options.pairingChannel || (await FxAccountsPairingChannel.create(wsUri));
+    const {channelId, channelKey} = pairingChannel;
+    const channelKeyB64 = ChromeUtils.base64URLEncode(channelKey, {pad: false});
+    const pairingFlow = new FxAccountsPairingFlow({
+      channelId,
+      pairingChannel,
+      emitter,
+      fxa,
+      fxaConfig,
+      flowTimeout,
+      weave,
+    });
+    flows.set(channelId, pairingFlow);
+
+    return `${contentPairingURI}#channel_id=${channelId}&channel_key=${channelKeyB64}`;
+  }
+
+  constructor(options) {
+    this._channelId = options.channelId;
+    this._pairingChannel = options.pairingChannel;
+    this._emitter = options.emitter;
+    this._fxa = options.fxa;
+    this._fxaConfig = options.fxaConfig;
+    this._weave = options.weave;
+    this._stateMachine = new PairingStateMachine(this._emitter);
+    this._setupListeners();
+    this._flowTimeoutId = setTimeout(() => this._onFlowTimeout(), options.flowTimeout);
+  }
+
+  _onFlowTimeout() {
+    log.warn(`The pairing flow ${this._channelId} timed out.`);
+    this._onError(new Error("Timeout"));
+    this.finalize();
+  }
+
+  _closeChannel() {
+    if (!this._closed && !this._pairingChannel.closed) {
+      this._pairingChannel.close();
+      this._closed = true;
+    }
+  }
+
+  finalize() {
+    this._closeChannel();
+    clearTimeout(this._flowTimeoutId);
+    // Free up resources and let the GC do its thing.
+    flows.delete(this._channelId);
+  }
+
+  _setupListeners() {
+    this._pairingChannel.addEventListener("message", ({detail: {sender, data}}) => this.onPairingChannelMessage(sender, data));
+    this._pairingChannel.addEventListener("error", event => this._onPairingChannelError(event.detail.error));
+    this._emitter.on("view:Closed", () => this.onPrefViewClosed());
+  }
+
+  _onAbort() {
+    this._stateMachine.currentState.hasAborted();
+    this.finalize();
+  }
+
+  _onError(error) {
+    this._stateMachine.currentState.hasErrored(error);
+    this._closeChannel();
+  }
+
+  _onPairingChannelError(error) {
+    log.error("Pairing channel error", error);
+    this._onError(error);
+  }
+
+  // Any non-falsy returned value is sent back through WebChannel.
+  async onWebChannelMessage(command) {
+    const stateMachine = this._stateMachine;
+    const curState = stateMachine.currentState;
+    try {
+      switch (command) {
+        case COMMAND_PAIR_SUPP_METADATA:
+          stateMachine.assertState([PendingConfirmations, PendingLocalConfirmation], `Wrong state for ${command}`);
+          const {ua, city, region, country, remote: ipAddress} = curState.sender;
+          return {ua, city, region, country, ipAddress};
+        case COMMAND_PAIR_AUTHORIZE:
+          stateMachine.assertState([PendingConfirmations, PendingLocalConfirmation], `Wrong state for ${command}`);
+          const {client_id, state, scope, code_challenge, code_challenge_method, keys_jwk} = curState.oauthOptions;
+          const authorizeParams = {
+            client_id,
+            access_type: "offline",
+            state,
+            scope,
+            code_challenge,
+            code_challenge_method,
+            keys_jwk,
+          };
+          const codeAndState = await this._fxa.authorizeOAuthCode(authorizeParams);
+          if (codeAndState.state != state) {
+            throw new Error(`OAuth state mismatch`);
+          }
+          await this._pairingChannel.send({
+            message: "pair:auth:authorize",
+            data: {
+              ...codeAndState,
+            },
+          });
+          curState.localConfirmed();
+          break;
+        case COMMAND_PAIR_DECLINE:
+          this._onAbort();
+          break;
+        case COMMAND_PAIR_HEARTBEAT:
+          if (curState instanceof Errored || this._pairingChannel.closed) {
+            return {err: curState.error.message || "Pairing channel closed"};
+          }
+          const suppAuthorized = !(curState instanceof PendingConfirmations || curState instanceof PendingRemoteConfirmation);
+          return {suppAuthorized};
+        case COMMAND_PAIR_COMPLETE:
+          this.finalize();
+          break;
+        default:
+          throw new Error(`Received unknown WebChannel command: ${command}`);
+      }
+    } catch (e) {
+      log.error(e);
+      curState.hasErrored(e);
+    }
+    return {};
+  }
+
+  async onPairingChannelMessage(sender, payload) {
+    const {message} = payload;
+    const stateMachine = this._stateMachine;
+    const curState = stateMachine.currentState;
+    try {
+      switch (message) {
+        case "pair:supp:request":
+          stateMachine.assertState(SuppConnectionPending, `Wrong state for ${message}`);
+          const oauthUri = await this._fxaConfig.promiseOAuthURI();
+          const {uid, email, avatar, displayName} = await this._fxa.getSignedInUserProfile();
+          const deviceName = this._weave.Service.clientsEngine.localName;
+          await this._pairingChannel.send({
+            message: "pair:auth:metadata",
+            data: {
+              email,
+              avatar,
+              displayName,
+              deviceName,
+            },
+          });
+          const {client_id, state, scope, code_challenge, code_challenge_method, keys_jwk} = payload.data;
+          const url = new URL(oauthUri);
+          url.searchParams.append("client_id", client_id);
+          url.searchParams.append("scope", scope);
+          url.searchParams.append("email", email);
+          url.searchParams.append("uid", uid);
+          url.searchParams.append("channel_id", this._channelId);
+          url.searchParams.append("redirect_uri", PAIRING_REDIRECT_URI);
+          this._emitter.emit("view:SwitchToWebContent", url.href);
+          curState.suppConnected(sender, {
+            client_id,
+            state,
+            scope,
+            code_challenge,
+            code_challenge_method,
+            keys_jwk,
+          });
+          break;
+        case "pair:supp:authorize":
+          stateMachine.assertState([PendingConfirmations, PendingRemoteConfirmation], `Wrong state for ${message}`);
+          curState.remoteConfirmed();
+          break;
+        default:
+          throw new Error(`Received unknown Pairing Channel message: ${message}`);
+      }
+    } catch (e) {
+      log.error(e);
+      curState.hasErrored(e);
+    }
+  }
+
+  onPrefViewClosed() {
+    const curState = this._stateMachine.currentState;
+    // We don't want to stop the pairing process in the later stages.
+    if (curState instanceof SuppConnectionPending || curState instanceof Aborted || curState instanceof Errored) {
+      this.finalize();
+    }
+  }
+};
+
+const EXPORTED_SYMBOLS = ["FxAccountsPairingFlow"];
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/FxAccountsPairingChannel.js
@@ -0,0 +1,3658 @@
+/*!
+ * 
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * 
+ * The following bundle is from an external repository at github.com/mozilla/fxa-pairing-channel,
+ * it implements a shared library for two javascript environments to create an encrypted and authenticated
+ * communication channel by sharing a secret key and by relaying messages through a websocket server.
+ * 
+ * It is used by the Firefox Accounts pairing flow, with one side of the channel being web
+ * content from https://accounts.firefox.com and the other side of the channel being chrome native code.
+ * 
+ * This uses the event-target-shim node library published under the MIT license:
+ * https://github.com/mysticatea/event-target-shim/blob/master/LICENSE
+ * 
+ * Bundle generated from https://github.com/mozilla/fxa-pairing-channel.git. Hash:348f3cf3e80cf7f54f9e, Chunkhash:d34c4d4ec81a46304a5d.
+ * 
+ */
+
+const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const {setTimeout} = ChromeUtils.import("resource://gre/modules/Timer.jsm");
+// We cannot use WebSocket from chrome code without a window,
+// see https://bugzilla.mozilla.org/show_bug.cgi?id=784686
+const browser = Services.appShell.createWindowlessBrowser(true);
+const {WebSocket} = browser.document.ownerGlobal;
+
+const EXPORTED_SYMBOLS = ["FxAccountsPairingChannel"];
+
+var FxAccountsPairingChannel =
+/******/ (function(modules) { // webpackBootstrap
+/******/ 	// The module cache
+/******/ 	var installedModules = {};
+/******/
+/******/ 	// The require function
+/******/ 	function __webpack_require__(moduleId) {
+/******/
+/******/ 		// Check if module is in cache
+/******/ 		if(installedModules[moduleId]) {
+/******/ 			return installedModules[moduleId].exports;
+/******/ 		}
+/******/ 		// Create a new module (and put it into the cache)
+/******/ 		var module = installedModules[moduleId] = {
+/******/ 			i: moduleId,
+/******/ 			l: false,
+/******/ 			exports: {}
+/******/ 		};
+/******/
+/******/ 		// Execute the module function
+/******/ 		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
+/******/
+/******/ 		// Flag the module as loaded
+/******/ 		module.l = true;
+/******/
+/******/ 		// Return the exports of the module
+/******/ 		return module.exports;
+/******/ 	}
+/******/
+/******/
+/******/ 	// expose the modules object (__webpack_modules__)
+/******/ 	__webpack_require__.m = modules;
+/******/
+/******/ 	// expose the module cache
+/******/ 	__webpack_require__.c = installedModules;
+/******/
+/******/ 	// define getter function for harmony exports
+/******/ 	__webpack_require__.d = function(exports, name, getter) {
+/******/ 		if(!__webpack_require__.o(exports, name)) {
+/******/ 			Object.defineProperty(exports, name, { enumerable: true, get: getter });
+/******/ 		}
+/******/ 	};
+/******/
+/******/ 	// define __esModule on exports
+/******/ 	__webpack_require__.r = function(exports) {
+/******/ 		if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
+/******/ 			Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
+/******/ 		}
+/******/ 		Object.defineProperty(exports, '__esModule', { value: true });
+/******/ 	};
+/******/
+/******/ 	// create a fake namespace object
+/******/ 	// mode & 1: value is a module id, require it
+/******/ 	// mode & 2: merge all properties of value into the ns
+/******/ 	// mode & 4: return value when already ns object
+/******/ 	// mode & 8|1: behave like require
+/******/ 	__webpack_require__.t = function(value, mode) {
+/******/ 		if(mode & 1) value = __webpack_require__(value);
+/******/ 		if(mode & 8) return value;
+/******/ 		if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
+/******/ 		var ns = Object.create(null);
+/******/ 		__webpack_require__.r(ns);
+/******/ 		Object.defineProperty(ns, 'default', { enumerable: true, value: value });
+/******/ 		if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
+/******/ 		return ns;
+/******/ 	};
+/******/
+/******/ 	// getDefaultExport function for compatibility with non-harmony modules
+/******/ 	__webpack_require__.n = function(module) {
+/******/ 		var getter = module && module.__esModule ?
+/******/ 			function getDefault() { return module['default']; } :
+/******/ 			function getModuleExports() { return module; };
+/******/ 		__webpack_require__.d(getter, 'a', getter);
+/******/ 		return getter;
+/******/ 	};
+/******/
+/******/ 	// Object.prototype.hasOwnProperty.call
+/******/ 	__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
+/******/
+/******/ 	// __webpack_public_path__
+/******/ 	__webpack_require__.p = "";
+/******/
+/******/
+/******/ 	// Load entry module and return exports
+/******/ 	return __webpack_require__(__webpack_require__.s = 0);
+/******/ })
+/************************************************************************/
+/******/ ([
+/* 0 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+
+// CONCATENATED MODULE: ./src/alerts.js
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-disable sorting/sort-object-props */
+const ALERT_LEVEL = {
+  WARNING: 1,
+  FATAL: 2
+};
+
+const ALERT_DESCRIPTION = {
+  CLOSE_NOTIFY: 0,
+  UNEXPECTED_MESSAGE: 10,
+  BAD_RECORD_MAC: 20,
+  RECORD_OVERFLOW: 22,
+  HANDSHAKE_FAILURE: 40,
+  ILLEGAL_PARAMETER: 47,
+  DECODE_ERROR: 50,
+  DECRYPT_ERROR: 51,
+  PROTOCOL_VERSION: 70,
+  INTERNAL_ERROR: 80,
+  MISSING_EXTENSION: 109,
+  UNSUPPORTED_EXTENSION: 110,
+  UNKNOWN_PSK_IDENTITY: 115,
+  NO_APPLICATION_PROTOCOL: 120,
+};
+/* eslint-enable sorting/sort-object-props */
+
+function alertTypeToName(type) {
+  for (const name in ALERT_DESCRIPTION) {
+    if (ALERT_DESCRIPTION[name] === type) {
+      return `${name} (${type})`;
+    }
+  }
+  return `UNKNOWN (${type})`;
+}
+
+class TLSAlert extends Error {
+  constructor(description, level) {
+    super(`TLS Alert: ${alertTypeToName(description)}`);
+    this.description = description;
+    this.level = level;
+  }
+
+  static fromBytes(bytes) {
+    if (bytes.byteLength !== 2) {
+      throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+    }
+    switch (bytes[1]) {
+      case ALERT_DESCRIPTION.CLOSE_NOTIFY:
+        if (bytes[0] !== ALERT_LEVEL.WARNING) {
+          // Close notifications should be fatal.
+          throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+        }
+        return new TLSCloseNotify();
+      default:
+        return new TLSError(bytes[1]);
+    }
+  }
+
+  toBytes() {
+    return new Uint8Array([this.level, this.description]);
+  }
+}
+
+class TLSCloseNotify extends TLSAlert {
+  constructor() {
+    super(ALERT_DESCRIPTION.CLOSE_NOTIFY, ALERT_LEVEL.WARNING);
+  }
+}
+
+class TLSError extends TLSAlert {
+  constructor(description = ALERT_DESCRIPTION.INTERNAL_ERROR) {
+    super(description, ALERT_LEVEL.FATAL);
+  }
+}
+
+// CONCATENATED MODULE: ./src/utils.js
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+
+
+//
+// Various low-level utility functions.
+//
+// These are mostly conveniences for working with Uint8Arrays as
+// the primitive "bytes" type.
+//
+
+const UTF8_ENCODER = new TextEncoder();
+const UTF8_DECODER = new TextDecoder();
+
+function noop() {}
+
+function assert(cond, msg) {
+  if (! cond) {
+    throw new Error('assert failed: ' + msg);
+  }
+}
+
+function assertIsBytes(value, msg = 'value must be a Uint8Array') {
+  // Using `value instanceof Uint8Array` seems to fail in Firefox chrome code
+  // for inscrutable reasons, so we do a less direct check.
+  assert(ArrayBuffer.isView(value), msg);
+  assert(value.BYTES_PER_ELEMENT === 1, msg);
+  return value;
+}
+
+const EMPTY = new Uint8Array(0);
+
+function zeros(n) {
+  return new Uint8Array(n);
+}
+
+function arrayToBytes(value) {
+  return new Uint8Array(value);
+}
+
+function bytesToHex(bytes) {
+  return Array.prototype.map.call(bytes, byte => {
+    let s = byte.toString(16);
+    if (s.length === 1) {
+      s = '0' + s;
+    }
+    return s;
+  }).join('');
+}
+
+function hexToBytes(hexstr) {
+  assert(hexstr.length % 2 === 0, 'hexstr.length must be even');
+  return new Uint8Array(Array.prototype.map.call(hexstr, (c, n) => {
+    if (n % 2 === 1) {
+      return hexstr[n - 1] + c;
+    } else {
+      return '';
+    }
+  }).filter(s => {
+    return !! s;
+  }).map(s => {
+    return parseInt(s, 16);
+  }));
+}
+
+function bytesToUtf8(bytes) {
+  return UTF8_DECODER.decode(bytes);
+}
+
+function utf8ToBytes(str) {
+  return UTF8_ENCODER.encode(str);
+}
+
+function bytesToBase64url(bytes) {
+  // XXX TODO: try to use something constant-time, in case calling code
+  // uses it to encode secrets?
+  const charCodes = String.fromCharCode.apply(String, bytes);
+  return btoa(charCodes).replace(/\+/g, '-').replace(/\//g, '_');
+}
+
+function base64urlToBytes(str) {
+  // XXX TODO: try to use something constant-time, in case calling code
+  // uses it to decode secrets?
+  str = atob(str.replace(/-/g, '+').replace(/_/g, '/'));
+  const bytes = new Uint8Array(str.length);
+  for (let i = 0; i < str.length; i++) {
+    bytes[i] = str.charCodeAt(i);
+  }
+  return bytes;
+}
+
+function bytesAreEqual(v1, v2) {
+  assertIsBytes(v1);
+  assertIsBytes(v2);
+  if (v1.length !== v2.length) {
+    return false;
+  }
+  for (let i = 0; i < v1.length; i++) {
+    if (v1[i] !== v2[i]) {
+      return false;
+    }
+  }
+  return true;
+}
+
+// The `BufferReader` and `BufferWriter` classes are helpers for dealing with the
+// binary struct format that's used for various TLS message.  Think of them as a
+// buffer with a pointer to the "current position" and a bunch of helper methods
+// to read/write structured data and advance said pointer.
+
+class utils_BufferWithPointer {
+  constructor(buf) {
+    this._buffer = buf;
+    this._dataview = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
+    this._pos = 0;
+  }
+
+  length() {
+    return this._buffer.byteLength;
+  }
+
+  tell() {
+    return this._pos;
+  }
+
+  seek(pos) {
+    if (pos < 0) {
+      throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+    }
+    if (pos > this.length()) {
+      throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+    }
+    this._pos = pos;
+  }
+
+  incr(offset) {
+    this.seek(this._pos + offset);
+  }
+}
+
+// The `BufferReader` class helps you read structured data from a byte array.
+// It offers methods for reading both primitive values, and the variable-length
+// vector structures defined in https://tools.ietf.org/html/rfc8446#section-3.4.
+//
+// Such vectors are represented as a length followed by the concatenated
+// bytes of each item, and the size of the length field is determined by
+// the maximum allowed number of bytes in the vector.  For example
+// to read a vector that may contain up to 65535 bytes, use `readVector16`.
+//
+// To read a variable-length vector of between 1 and 100 uint16 values,
+// defined in the RFC like this:
+//
+//    uint16 items<2..200>;
+//
+// You would do something like this:
+//
+//    const items = []
+//    buf.readVector8(buf => {
+//      items.push(buf.readUint16())
+//    })
+//
+// The various `read` will throw `DECODE_ERROR` if you attempt to read path
+// the end of the buffer, or past the end of a variable-length list.
+//
+class utils_BufferReader extends utils_BufferWithPointer {
+
+  hasMoreBytes() {
+    return this.tell() < this.length();
+  }
+
+  readBytes(length) {
+    // This avoids copies by returning a view onto the existing buffer.
+    const start = this._buffer.byteOffset + this.tell();
+    this.incr(length);
+    return new Uint8Array(this._buffer.buffer, start, length);
+  }
+
+  _rangeErrorToAlert(cb) {
+    try {
+      return cb(this);
+    } catch (err) {
+      if (err instanceof RangeError) {
+        throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+      }
+      throw err;
+    }
+  }
+
+  readUint8() {
+    return this._rangeErrorToAlert(() => {
+      const n = this._dataview.getUint8(this._pos);
+      this.incr(1);
+      return n;
+    });
+  }
+
+  readUint16() {
+    return this._rangeErrorToAlert(() => {
+      const n = this._dataview.getUint16(this._pos);
+      this.incr(2);
+      return n;
+    });
+  }
+
+  readUint24() {
+    return this._rangeErrorToAlert(() => {
+      let n = this._dataview.getUint16(this._pos);
+      n = (n << 8) | this._dataview.getUint8(this._pos + 2);
+      this.incr(3);
+      return n;
+    });
+  }
+
+  readUint32() {
+    return this._rangeErrorToAlert(() => {
+      const n = this._dataview.getUint32(this._pos);
+      this.incr(4);
+      return n;
+    });
+  }
+
+  _readVector(length, cb) {
+    const contentsBuf = new utils_BufferReader(this.readBytes(length));
+    const expectedEnd = this.tell();
+    // Keep calling the callback until we've consumed the expected number of bytes.
+    let n = 0;
+    while (contentsBuf.hasMoreBytes()) {
+      const prevPos = contentsBuf.tell();
+      cb(contentsBuf, n);
+      // Check that the callback made forward progress, otherwise we'll infinite loop.
+      if (contentsBuf.tell() <= prevPos) {
+        throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+      }
+      n += 1;
+    }
+    // Check that the callback correctly consumed the vector's entire contents.
+    if (this.tell() !== expectedEnd) {
+      throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+    }
+  }
+
+  readVector8(cb) {
+    const length = this.readUint8();
+    return this._readVector(length, cb);
+  }
+
+  readVector16(cb) {
+    const length = this.readUint16();
+    return this._readVector(length, cb);
+  }
+
+  readVector24(cb) {
+    const length = this.readUint24();
+    return this._readVector(length, cb);
+  }
+
+  readVectorBytes8() {
+    return this.readBytes(this.readUint8());
+  }
+
+  readVectorBytes16() {
+    return this.readBytes(this.readUint16());
+  }
+
+  readVectorBytes24() {
+    return this.readBytes(this.readUint24());
+  }
+}
+
+
+class utils_BufferWriter extends utils_BufferWithPointer {
+  constructor(size = 1024) {
+    super(new Uint8Array(size));
+  }
+
+  _maybeGrow(n) {
+    const curSize = this._buffer.byteLength;
+    const newPos = this._pos + n;
+    const shortfall = newPos - curSize;
+    if (shortfall > 0) {
+      // Classic grow-by-doubling, up to 4kB max increment.
+      // This formula was not arrived at by any particular science.
+      const incr = Math.min(curSize, 4 * 1024);
+      const newbuf = new Uint8Array(curSize + Math.ceil(shortfall / incr) * incr);
+      newbuf.set(this._buffer, 0);
+      this._buffer = newbuf;
+      this._dataview = new DataView(newbuf.buffer, newbuf.byteOffset, newbuf.byteLength);
+    }
+  }
+
+  slice(start = 0, end = this.tell()) {
+    if (end < 0) {
+      end = this.tell() + end;
+    }
+    if (start < 0) {
+      throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+    }
+    if (end < 0) {
+      throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+    }
+    if (end > this.length()) {
+      throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+    }
+    return this._buffer.slice(start, end);
+  }
+
+  flush() {
+    const slice = this.slice();
+    this.seek(0);
+    return slice;
+  }
+
+  writeBytes(data) {
+    this._maybeGrow(data.byteLength);
+    this._buffer.set(data, this.tell());
+    this.incr(data.byteLength);
+  }
+
+  writeUint8(n) {
+    this._maybeGrow(1);
+    this._dataview.setUint8(this._pos, n);
+    this.incr(1);
+  }
+
+  writeUint16(n) {
+    this._maybeGrow(2);
+    this._dataview.setUint16(this._pos, n);
+    this.incr(2);
+  }
+
+  writeUint24(n) {
+    this._maybeGrow(3);
+    this._dataview.setUint16(this._pos, n >> 8);
+    this._dataview.setUint8(this._pos + 2, n & 0xFF);
+    this.incr(3);
+  }
+
+  writeUint32(n) {
+    this._maybeGrow(4);
+    this._dataview.setUint32(this._pos, n);
+    this.incr(4);
+  }
+
+  // These are helpers for writing the variable-length vector structure
+  // defined in https://tools.ietf.org/html/rfc8446#section-3.4.
+  //
+  // Such vectors are represented as a length followed by the concatenated
+  // bytes of each item, and the size of the length field is determined by
+  // the maximum allowed size of the vector.  For example to write a vector
+  // that may contain up to 65535 bytes, use `writeVector16`.
+  //
+  // To write a variable-length vector of between 1 and 100 uint16 values,
+  // defined in the RFC like this:
+  //
+  //    uint16 items<2..200>;
+  //
+  // You would do something like this:
+  //
+  //    buf.writeVector8(buf => {
+  //      for (let item of items) {
+  //          buf.writeUint16(item)
+  //      }
+  //    })
+  //
+  // The helper will automatically take care of writing the appropriate
+  // length field once the callback completes.
+
+  _writeVector(maxLength, writeLength, cb) {
+    // Initially, write the length field as zero.
+    const lengthPos = this.tell();
+    writeLength(0);
+    // Call the callback to write the vector items.
+    const bodyPos = this.tell();
+    cb(this);
+    const length = this.tell() - bodyPos;
+    if (length >= maxLength) {
+      throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+    }
+    // Backfill the actual length field.
+    this.seek(lengthPos);
+    writeLength(length);
+    this.incr(length);
+    return length;
+  }
+
+  writeVector8(cb) {
+    return this._writeVector(Math.pow(2, 8), len => this.writeUint8(len), cb);
+  }
+
+  writeVector16(cb) {
+    return this._writeVector(Math.pow(2, 16), len => this.writeUint16(len), cb);
+  }
+
+  writeVector24(cb) {
+    return this._writeVector(Math.pow(2, 24), len => this.writeUint24(len), cb);
+  }
+
+  writeVectorBytes8(bytes) {
+    return this.writeVector8(buf => {
+      buf.writeBytes(bytes);
+    });
+  }
+
+  writeVectorBytes16(bytes) {
+    return this.writeVector16(buf => {
+      buf.writeBytes(bytes);
+    });
+  }
+
+  writeVectorBytes24(bytes) {
+    return this.writeVector24(buf => {
+      buf.writeBytes(bytes);
+    });
+  }
+}
+
+// CONCATENATED MODULE: ./src/crypto.js
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+//
+// Low-level crypto primitives.
+//
+// This file implements the AEAD encrypt/decrypt and hashing routines
+// for the TLS_AES_128_GCM_SHA256 ciphersuite.
+//
+
+
+
+
+const AEAD_SIZE_INFLATION = 16;
+const KEY_LENGTH = 16;
+const IV_LENGTH = 12;
+const HASH_LENGTH = 32;
+
+async function prepareKey(key, mode) {
+  return crypto.subtle.importKey('raw', key, { name: 'AES-GCM' }, false, [mode]);
+}
+
+async function encrypt(key, iv, plaintext, additionalData) {
+  const ciphertext = await crypto.subtle.encrypt({
+    additionalData,
+    iv,
+    name: 'AES-GCM',
+    tagLength: AEAD_SIZE_INFLATION * 8
+  }, key, plaintext);
+  return new Uint8Array(ciphertext);
+}
+
+async function decrypt(key, iv, ciphertext, additionalData) {
+  try {
+    const plaintext = await crypto.subtle.decrypt({
+      additionalData,
+      iv,
+      name: 'AES-GCM',
+      tagLength: AEAD_SIZE_INFLATION * 8
+    }, key, ciphertext);
+    return new Uint8Array(plaintext);
+  } catch (err) {
+    // Yes, we really do throw 'decrypt_error' when failing to verify a HMAC,
+    // and a 'bad_record_mac' error when failing to decrypt.
+    throw new TLSError(ALERT_DESCRIPTION.BAD_RECORD_MAC);
+  }
+}
+
+async function hash(message) {
+  return new Uint8Array(await crypto.subtle.digest({ name: 'SHA-256' }, message));
+}
+
+async function hmac(keyBytes, message) {
+  const key = await crypto.subtle.importKey('raw', keyBytes, {
+    hash: { name: 'SHA-256' },
+    name: 'HMAC',
+  }, false, ['sign']);
+  const sig = await crypto.subtle.sign({ name: 'HMAC' }, key, message);
+  return new Uint8Array(sig);
+}
+
+async function verifyHmac(keyBytes, signature, message) {
+  const key = await crypto.subtle.importKey('raw', keyBytes, {
+    hash: { name: 'SHA-256' },
+    name: 'HMAC',
+  }, false, ['verify']);
+  if (! await crypto.subtle.verify({ name: 'HMAC' }, key, signature, message)) {
+    // Yes, we really do throw 'decrypt_error' when failing to verify a HMAC,
+    // and a 'bad_record_mac' error when failing to decrypt.
+    throw new TLSError(ALERT_DESCRIPTION.DECRYPT_ERROR);
+  }
+}
+
+async function hkdfExtract(salt, ikm) {
+  // Ref https://tools.ietf.org/html/rfc5869#section-2.2
+  return await hmac(salt, ikm);
+}
+
+async function hkdfExpand(prk, info, length) {
+  // Ref https://tools.ietf.org/html/rfc5869#section-2.3
+  const N = Math.ceil(length / HASH_LENGTH);
+  if (N <= 0) {
+    throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+  }
+  if (N >= 255) {
+    throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+  }
+  const input = new utils_BufferWriter();
+  const output = new utils_BufferWriter();
+  let T = new Uint8Array(0);
+  for (let i = 1; i <= N; i++) {
+    input.writeBytes(T);
+    input.writeBytes(info);
+    input.writeUint8(i);
+    T = await hmac(prk, input.flush());
+    output.writeBytes(T);
+  }
+  return output.slice(0, length);
+}
+
+async function hkdfExpandLabel(secret, label, context, length) {
+  //  struct {
+  //    uint16 length = Length;
+  //    opaque label < 7..255 > = "tls13 " + Label;
+  //    opaque context < 0..255 > = Context;
+  //  } HkdfLabel;
+  const hkdfLabel = new utils_BufferWriter();
+  hkdfLabel.writeUint16(length);
+  hkdfLabel.writeVectorBytes8(utf8ToBytes('tls13 ' + label));
+  hkdfLabel.writeVectorBytes8(context);
+  return hkdfExpand(secret, hkdfLabel.flush(), length);
+}
+
+async function getRandomBytes(size) {
+  const bytes = new Uint8Array(size);
+  crypto.getRandomValues(bytes);
+  return bytes;
+}
+
+// CONCATENATED MODULE: ./src/extensions.js
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+//
+// Extension parsing.
+//
+// This file contains some helpers for reading/writing the various kinds
+// of Extension that might appear in a HandshakeMessage.
+//
+
+
+
+
+
+/* eslint-disable sorting/sort-object-props */
+const EXTENSION_TYPE = {
+  PRE_SHARED_KEY: 41,
+  SUPPORTED_VERSIONS: 43,
+  PSK_KEY_EXCHANGE_MODES: 45,
+};
+/* eslint-enable sorting/sort-object-props */
+
+// Base class for generic reading/writing of extensions,
+// which are all uniformly formatted as:
+//
+//   struct {
+//     ExtensionType extension_type;
+//     opaque extension_data<0..2^16-1>;
+//   } Extension;
+//
+// Extensions always appear inside of a handshake message,
+// and their internal structure may differ based on the
+// type of that message.
+
+class extensions_Extension {
+
+  get TYPE_TAG() {
+    throw new Error('not implemented');
+  }
+
+  static read(messageType, buf) {
+    const type = buf.readUint16();
+    let ext = {
+      TYPE_TAG: type,
+    };
+    buf.readVector16(buf => {
+      switch (type) {
+        case EXTENSION_TYPE.PRE_SHARED_KEY:
+          ext = extensions_PreSharedKeyExtension._read(messageType, buf);
+          break;
+        case EXTENSION_TYPE.SUPPORTED_VERSIONS:
+          ext = extensions_SupportedVersionsExtension._read(messageType, buf);
+          break;
+        case EXTENSION_TYPE.PSK_KEY_EXCHANGE_MODES:
+          ext = extensions_PskKeyExchangeModesExtension._read(messageType, buf);
+          break;
+        default:
+          // Skip over unrecognised extensions.
+          buf.incr(buf.length());
+      }
+      if (buf.hasMoreBytes()) {
+        throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+      }
+    });
+    return ext;
+  }
+
+  write(messageType, buf) {
+    buf.writeUint16(this.TYPE_TAG);
+    buf.writeVector16(buf => {
+      this._write(messageType, buf);
+    });
+  }
+
+  static _read(messageType, buf) {
+    throw new Error('not implemented');
+  }
+
+  static _write(messageType, buf) {
+    throw new Error('not implemented');
+  }
+}
+
+// The PreSharedKey extension:
+//
+//  struct {
+//    opaque identity<1..2^16-1>;
+//    uint32 obfuscated_ticket_age;
+//  } PskIdentity;
+//  opaque PskBinderEntry<32..255>;
+//  struct {
+//    PskIdentity identities<7..2^16-1>;
+//    PskBinderEntry binders<33..2^16-1>;
+//  } OfferedPsks;
+//  struct {
+//    select(Handshake.msg_type) {
+//      case client_hello: OfferedPsks;
+//      case server_hello: uint16 selected_identity;
+//    };
+//  } PreSharedKeyExtension;
+
+class extensions_PreSharedKeyExtension extends extensions_Extension {
+  constructor(identities, binders, selectedIdentity) {
+    super();
+    this.identities = identities;
+    this.binders = binders;
+    this.selectedIdentity = selectedIdentity;
+  }
+
+  get TYPE_TAG() {
+    return EXTENSION_TYPE.PRE_SHARED_KEY;
+  }
+
+  static _read(messageType, buf) {
+    let identities = null, binders = null, selectedIdentity = null;
+    switch (messageType) {
+      case HANDSHAKE_TYPE.CLIENT_HELLO:
+        identities = []; binders = [];
+        buf.readVector16(buf => {
+          const identity = buf.readVectorBytes16();
+          buf.readBytes(4); // Skip over the ticket age.
+          identities.push(identity);
+        });
+        buf.readVector16(buf => {
+          const binder = buf.readVectorBytes8();
+          if (binder.byteLength < HASH_LENGTH) {
+            throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+          }
+          binders.push(binder);
+        });
+        if (identities.length !== binders.length) {
+          throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+        }
+        break;
+      case HANDSHAKE_TYPE.SERVER_HELLO:
+        selectedIdentity = buf.readUint16();
+        break;
+      default:
+        throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+    }
+    return new this(identities, binders, selectedIdentity);
+  }
+
+  _write(messageType, buf) {
+    switch (messageType) {
+      case HANDSHAKE_TYPE.CLIENT_HELLO:
+        buf.writeVector16(buf => {
+          this.identities.forEach(pskId => {
+            buf.writeVectorBytes16(pskId);
+            buf.writeUint32(0); // Zero for "tag age" field.
+          });
+        });
+        buf.writeVector16(buf => {
+          this.binders.forEach(pskBinder => {
+            buf.writeVectorBytes8(pskBinder);
+          });
+        });
+        break;
+      case HANDSHAKE_TYPE.SERVER_HELLO:
+        buf.writeUint16(this.selectedIdentity);
+        break;
+      default:
+        throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+    }
+  }
+}
+
+
+// The SupportedVersions extension:
+//
+//  struct {
+//    select(Handshake.msg_type) {
+//      case client_hello:
+//        ProtocolVersion versions < 2..254 >;
+//      case server_hello:
+//        ProtocolVersion selected_version;
+//    };
+//  } SupportedVersions;
+
+class extensions_SupportedVersionsExtension extends extensions_Extension {
+  constructor(versions, selectedVersion) {
+    super();
+    this.versions = versions;
+    this.selectedVersion = selectedVersion;
+  }
+
+  get TYPE_TAG() {
+    return EXTENSION_TYPE.SUPPORTED_VERSIONS;
+  }
+
+  static _read(messageType, buf) {
+    let versions = null, selectedVersion = null;
+    switch (messageType) {
+      case HANDSHAKE_TYPE.CLIENT_HELLO:
+        versions = [];
+        buf.readVector8(buf => {
+          versions.push(buf.readUint16());
+        });
+        break;
+      case HANDSHAKE_TYPE.SERVER_HELLO:
+        selectedVersion = buf.readUint16();
+        break;
+      default:
+        throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+    }
+    return new this(versions, selectedVersion);
+  }
+
+  _write(messageType, buf) {
+    switch (messageType) {
+      case HANDSHAKE_TYPE.CLIENT_HELLO:
+        buf.writeVector8(buf => {
+          this.versions.forEach(version => {
+            buf.writeUint16(version);
+          });
+        });
+        break;
+      case HANDSHAKE_TYPE.SERVER_HELLO:
+        buf.writeUint16(this.selectedVersion);
+        break;
+      default:
+        throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+    }
+  }
+}
+
+
+class extensions_PskKeyExchangeModesExtension extends extensions_Extension {
+  constructor(modes) {
+    super();
+    this.modes = modes;
+  }
+
+  get TYPE_TAG() {
+    return EXTENSION_TYPE.PSK_KEY_EXCHANGE_MODES;
+  }
+
+  static _read(messageType, buf) {
+    const modes = [];
+    switch (messageType) {
+      case HANDSHAKE_TYPE.CLIENT_HELLO:
+        buf.readVector8(buf => {
+          modes.push(buf.readUint8());
+        });
+        break;
+      default:
+        throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+    }
+    return new this(modes);
+  }
+
+  _write(messageType, buf) {
+    switch (messageType) {
+      case HANDSHAKE_TYPE.CLIENT_HELLO:
+        buf.writeVector8(buf => {
+          this.modes.forEach(mode => {
+            buf.writeUint8(mode);
+          });
+        });
+        break;
+      default:
+        throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+    }
+  }
+}
+
+// CONCATENATED MODULE: ./src/constants.js
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const VERSION_TLS_1_0 = 0x0301;
+const VERSION_TLS_1_2 = 0x0303;
+const VERSION_TLS_1_3 = 0x0304;
+const TLS_AES_128_GCM_SHA256 = 0x1301;
+const PSK_MODE_KE = 0;
+
+// CONCATENATED MODULE: ./src/messages.js
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+//
+// Message parsing.
+//
+// Herein we need code for reading and writing the various Handshake
+// messages involved in the protocol.
+//
+
+
+
+
+
+
+
+/* eslint-disable sorting/sort-object-props */
+const HANDSHAKE_TYPE = {
+  CLIENT_HELLO: 1,
+  SERVER_HELLO: 2,
+  NEW_SESSION_TICKET: 4,
+  ENCRYPTED_EXTENSIONS: 8,
+  FINISHED: 20,
+};
+/* eslint-enable sorting/sort-object-props */
+
+// Base class for generic reading/writing of handshake messages,
+// which are all uniformly formatted as:
+//
+//  struct {
+//    HandshakeType msg_type;    /* handshake type */
+//    uint24 length;             /* bytes in message */
+//    select(Handshake.msg_type) {
+//        ... type specific cases here ...
+//    };
+//  } Handshake;
+
+class messages_HandshakeMessage {
+
+  get TYPE_TAG() {
+    throw new Error('not implemented');
+  }
+
+  static fromBytes(bytes) {
+    // Each handshake message has a type and length prefix, per
+    // https://tools.ietf.org/html/rfc8446#appendix-B.3
+    const buf = new utils_BufferReader(bytes);
+    const msg = this.read(buf);
+    if (buf.hasMoreBytes()) {
+      throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+    }
+    return msg;
+  }
+
+  toBytes() {
+    const buf = new utils_BufferWriter();
+    this.write(buf);
+    return buf.flush();
+  }
+
+  static read(buf) {
+    const type = buf.readUint8();
+    let msg = null;
+    buf.readVector24(buf => {
+      switch (type) {
+        case HANDSHAKE_TYPE.CLIENT_HELLO:
+          msg = messages_ClientHello._read(buf);
+          break;
+        case HANDSHAKE_TYPE.SERVER_HELLO:
+          msg = messages_ServerHello._read(buf);
+          break;
+        case HANDSHAKE_TYPE.NEW_SESSION_TICKET:
+          msg = messages_NewSessionTicket._read(buf);
+          break;
+        case HANDSHAKE_TYPE.ENCRYPTED_EXTENSIONS:
+          msg = EncryptedExtensions._read(buf);
+          break;
+        case HANDSHAKE_TYPE.FINISHED:
+          msg = messages_Finished._read(buf);
+          break;
+      }
+      if (buf.hasMoreBytes()) {
+        throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+      }
+    });
+    if (msg === null) {
+      throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+    }
+    return msg;
+  }
+
+  write(buf) {
+    buf.writeUint8(this.TYPE_TAG);
+    buf.writeVector24(buf => {
+      this._write(buf);
+    });
+  }
+
+  static _read(buf) {
+    throw new Error('not implemented');
+  }
+
+  _write(buf) {
+    throw new Error('not implemented');
+  }
+
+  // Some little helpers for reading a list of extensions,
+  // which is uniformly represented as:
+  //
+  //   Extension extensions<8..2^16-1>;
+  //
+  // Recognized extensions are returned as a Map from extension type
+  // to extension data object, with a special `lastSeenExtension`
+  // property to make it easy to check which one came last.
+
+  static _readExtensions(messageType, buf) {
+    const extensions = new Map();
+    buf.readVector16(buf => {
+      const ext = extensions_Extension.read(messageType, buf);
+      if (extensions.has(ext.TYPE_TAG)) {
+        throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+      }
+      extensions.set(ext.TYPE_TAG, ext);
+      extensions.lastSeenExtension = ext.TYPE_TAG;
+    });
+    return extensions;
+  }
+
+  _writeExtensions(buf, extensions) {
+    buf.writeVector16(buf => {
+      extensions.forEach(ext => {
+        ext.write(this.TYPE_TAG, buf);
+      });
+    });
+  }
+}
+
+
+// The ClientHello message:
+//
+// struct {
+//   ProtocolVersion legacy_version = 0x0303;
+//   Random random;
+//   opaque legacy_session_id<0..32>;
+//   CipherSuite cipher_suites<2..2^16-2>;
+//   opaque legacy_compression_methods<1..2^8-1>;
+//   Extension extensions<8..2^16-1>;
+// } ClientHello;
+
+class messages_ClientHello extends messages_HandshakeMessage {
+
+  constructor(random, sessionId, extensions) {
+    super();
+    this.random = random;
+    this.sessionId = sessionId;
+    this.extensions = extensions;
+  }
+
+  get TYPE_TAG() {
+    return HANDSHAKE_TYPE.CLIENT_HELLO;
+  }
+
+  static _read(buf) {
+    // The legacy_version field may indicate an earlier version of TLS
+    // for backwards compatibility, but must not predate TLS 1.0!
+    if (buf.readUint16() < VERSION_TLS_1_0) {
+      throw new TLSError(ALERT_DESCRIPTION.PROTOCOL_VERSION);
+    }
+    // The random bytes provided by the peer.
+    const random = buf.readBytes(32);
+    // Read legacy_session_id, so the server can echo it.
+    const sessionId = buf.readVectorBytes8();
+    // We only support a single ciphersuite, but the peer may offer several.
+    // Scan the list to confirm that the one we want is present.
+    let found = false;
+    buf.readVector16(buf => {
+      const cipherSuite = buf.readUint16();
+      if (cipherSuite === TLS_AES_128_GCM_SHA256) {
+        found = true;
+      }
+    });
+    if (! found) {
+      throw new TLSError(ALERT_DESCRIPTION.HANDSHAKE_FAILURE);
+    }
+    // legacy_compression_methods must be a single zero byte for TLS1.3 ClientHellos.
+    // It can be non-zero in previous versions of TLS, but we're not going to
+    // make a successful handshake with such versions, so better to just bail out now.
+    const legacyCompressionMethods = buf.readVectorBytes8();
+    if (legacyCompressionMethods.byteLength !== 1) {
+      throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+    }
+    if (legacyCompressionMethods[0] !== 0x00) {
+      throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+    }
+    // Read and check the extensions.
+    const extensions = this._readExtensions(HANDSHAKE_TYPE.CLIENT_HELLO, buf);
+    if (! extensions.has(EXTENSION_TYPE.SUPPORTED_VERSIONS)) {
+      throw new TLSError(ALERT_DESCRIPTION.MISSING_EXTENSION);
+    }
+    if (extensions.get(EXTENSION_TYPE.SUPPORTED_VERSIONS).versions.indexOf(VERSION_TLS_1_3) === -1) {
+      throw new TLSError(ALERT_DESCRIPTION.PROTOCOL_VERSION);
+    }
+    // Was the PreSharedKey extension the last one?
+    if (extensions.has(EXTENSION_TYPE.PRE_SHARED_KEY)) {
+      if (extensions.lastSeenExtension !== EXTENSION_TYPE.PRE_SHARED_KEY) {
+        throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+      }
+    }
+    return new this(random, sessionId, extensions);
+  }
+
+  _write(buf) {
+    buf.writeUint16(VERSION_TLS_1_2);
+    buf.writeBytes(this.random);
+    buf.writeVectorBytes8(this.sessionId);
+    // Our single supported ciphersuite
+    buf.writeVector16(buf => {
+      buf.writeUint16(TLS_AES_128_GCM_SHA256);
+    });
+    // A single zero byte for legacy_compression_methods
+    buf.writeVectorBytes8(new Uint8Array(1));
+    this._writeExtensions(buf, this.extensions);
+  }
+}
+
+
+// The ServerHello message:
+//
+//  struct {
+//      ProtocolVersion legacy_version = 0x0303;    /* TLS v1.2 */
+//      Random random;
+//      opaque legacy_session_id_echo<0..32>;
+//      CipherSuite cipher_suite;
+//      uint8 legacy_compression_method = 0;
+//      Extension extensions < 6..2 ^ 16 - 1 >;
+//  } ServerHello;
+
+class messages_ServerHello extends messages_HandshakeMessage {
+
+  constructor(random, sessionId, extensions) {
+    super();
+    this.random = random;
+    this.sessionId = sessionId;
+    this.extensions = extensions;
+  }
+
+  get TYPE_TAG() {
+    return HANDSHAKE_TYPE.SERVER_HELLO;
+  }
+
+  static _read(buf) {
+    // Fixed value for legacy_version.
+    if (buf.readUint16() !== VERSION_TLS_1_2) {
+      throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+    }
+    // Random bytes from the server.
+    const random = buf.readBytes(32);
+    // It should have echoed our vector for legacy_session_id.
+    const sessionId = buf.readVectorBytes8();
+    // It should have selected our single offered ciphersuite.
+    if (buf.readUint16() !== TLS_AES_128_GCM_SHA256) {
+      throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+    }
+    // legacy_compression_method must be zero.
+    if (buf.readUint8() !== 0) {
+      throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+    }
+    const extensions = this._readExtensions(HANDSHAKE_TYPE.SERVER_HELLO, buf);
+    if (! extensions.has(EXTENSION_TYPE.SUPPORTED_VERSIONS)) {
+      throw new TLSError(ALERT_DESCRIPTION.MISSING_EXTENSION);
+    }
+    if (extensions.get(EXTENSION_TYPE.SUPPORTED_VERSIONS).selectedVersion !== VERSION_TLS_1_3) {
+      throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+    }
+    return new this(random, sessionId, extensions);
+  }
+
+  _write(buf) {
+    buf.writeUint16(VERSION_TLS_1_2);
+    buf.writeBytes(this.random);
+    buf.writeVectorBytes8(this.sessionId);
+    // Our single supported ciphersuite
+    buf.writeUint16(TLS_AES_128_GCM_SHA256);
+    // A single zero byte for legacy_compression_method
+    buf.writeUint8(0);
+    this._writeExtensions(buf, this.extensions);
+  }
+}
+
+
+// The EncryptedExtensions message:
+//
+//  struct {
+//    Extension extensions < 0..2 ^ 16 - 1 >;
+//  } EncryptedExtensions;
+//
+// We don't actually send any EncryptedExtensions,
+// but still have to send an empty message.
+
+class EncryptedExtensions extends messages_HandshakeMessage {
+  constructor(extensions) {
+    super();
+    this.extensions = extensions;
+  }
+
+  get TYPE_TAG() {
+    return HANDSHAKE_TYPE.ENCRYPTED_EXTENSIONS;
+  }
+
+  static _read(buf) {
+    const extensions = this._readExtensions(HANDSHAKE_TYPE.ENCRYPTED_EXTENSIONS, buf);
+    return new this(extensions);
+  }
+
+  _write(buf) {
+    this._writeExtensions(buf, this.extensions);
+  }
+}
+
+
+// The Finished message:
+//
+// struct {
+//   opaque verify_data[Hash.length];
+// } Finished;
+
+class messages_Finished extends messages_HandshakeMessage {
+
+  constructor(verifyData) {
+    super();
+    this.verifyData = verifyData;
+  }
+
+  get TYPE_TAG() {
+    return HANDSHAKE_TYPE.FINISHED;
+  }
+
+  static _read(buf) {
+    const verifyData = buf.readBytes(HASH_LENGTH);
+    return new this(verifyData);
+  }
+
+  _write(buf) {
+    buf.writeBytes(this.verifyData);
+  }
+}
+
+
+// The NewSessionTicket message:
+//
+//   struct {
+//    uint32 ticket_lifetime;
+//    uint32 ticket_age_add;
+//    opaque ticket_nonce < 0..255 >;
+//    opaque ticket < 1..2 ^ 16 - 1 >;
+//    Extension extensions < 0..2 ^ 16 - 2 >;
+//  } NewSessionTicket;
+//
+// We don't actually make use of these, but we need to be able
+// to accept them and do basic validation.
+
+class messages_NewSessionTicket extends messages_HandshakeMessage {
+  constructor(ticketLifetime, ticketAgeAdd, ticketNonce, ticket, extensions) {
+    super();
+    this.ticketLifetime = ticketLifetime;
+    this.ticketAgeAdd = ticketAgeAdd;
+    this.ticketNonce = ticketNonce;
+    this.ticket = ticket;
+    this.extensions = extensions;
+  }
+
+  get TYPE_TAG() {
+    return HANDSHAKE_TYPE.NEW_SESSION_TICKET;
+  }
+
+  static _read(buf) {
+    const ticketLifetime = buf.readUint32();
+    const ticketAgeAdd = buf.readUint32();
+    const ticketNonce = buf.readVectorBytes8();
+    const ticket = buf.readVectorBytes16();
+    if (ticket.byteLength < 1) {
+      throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+    }
+    const extensions = this._readExtensions(HANDSHAKE_TYPE.NEW_SESSION_TICKET, buf);
+    return new this(ticketLifetime, ticketAgeAdd, ticketNonce, ticket, extensions);
+  }
+
+  _write(buf) {
+    buf.writeUint32(this.ticketLifetime);
+    buf.writeUint32(this.ticketAgeAdd);
+    buf.writeVectorBytes8(this.ticketNonce);
+    buf.writeVectorBytes16(this.ticket);
+    this._writeExtensions(buf, this.extensions);
+  }
+}
+
+// CONCATENATED MODULE: ./src/states.js
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+
+
+
+
+
+
+
+//
+// State-machine for TLS Handshake Management.
+//
+// Internally, we manage the TLS connection by explicitly modelling the
+// client and server state-machines from RFC8446.  You can think of
+// these `State` objects as little plugins for the `Connection` class
+// that provide different behaviours of `send` and `receive` depending
+// on the state of the connection.
+//
+
+class states_State {
+
+  constructor(conn) {
+    this.conn = conn;
+  }
+
+  async initialize() {
+    // By default, nothing to do when entering the state.
+  }
+
+  async sendApplicationData(bytes) {
+    // By default, assume we're not ready to send yet and the caller
+    // should be blocking on the connection promise before reaching here.
+    throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+  }
+
+  async recvApplicationData(bytes) {
+    throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+  }
+
+  async recvHandshakeMessage(msg) {
+    throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+  }
+
+  async recvAlertMessage(alert) {
+    switch (alert.description) {
+      case ALERT_DESCRIPTION.CLOSE_NOTIFY:
+        this.conn._closeForRecv(alert);
+        throw alert;
+      default:
+        return await this.handleErrorAndRethrow(alert);
+    }
+  }
+
+  async recvChangeCipherSpec(bytes) {
+    throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+  }
+
+  async handleErrorAndRethrow(err) {
+    let alert = err;
+    if (! (alert instanceof TLSAlert)) {
+      alert = new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+    }
+    // Try to send error alert to the peer, but we may not
+    // be able to if the outgoing connection was already closed.
+    try {
+      await this.conn._sendAlertMessage(alert);
+    } catch (_) { }
+    await this.conn._transition(ERROR, err);
+    throw err;
+  }
+
+  async close() {
+    const alert = new TLSCloseNotify();
+    await this.conn._sendAlertMessage(alert);
+    this.conn._closeForSend(alert);
+  }
+
+}
+
+// A special "guard" state to prevent us from using
+// an improperly-initialized Connection.
+
+class UNINITIALIZED extends states_State {
+  async initialize() {
+    throw new Error('uninitialized state');
+  }
+  async sendApplicationData(bytes) {
+    throw new Error('uninitialized state');
+  }
+  async recvApplicationData(bytes) {
+    throw new Error('uninitialized state');
+  }
+  async recvHandshakeMessage(msg) {
+    throw new Error('uninitialized state');
+  }
+  async recvChangeCipherSpec(bytes) {
+    throw new Error('uninitialized state');
+  }
+  async handleErrorAndRethrow(err) {
+    throw err;
+  }
+  async close() {
+    throw new Error('uninitialized state');
+  }
+}
+
+// A special "error" state for when something goes wrong.
+// This state never transitions to another state, effectively
+// terminating the connection.
+
+class ERROR extends states_State {
+  async initialize(err) {
+    this.error = err;
+    this.conn._setConnectionFailure(err);
+    // Unceremoniously shut down the record layer on error.
+    this.conn._recordlayer.setSendError(err);
+    this.conn._recordlayer.setRecvError(err);
+  }
+  async sendApplicationData(bytes) {
+    throw this.error;
+  }
+  async recvApplicationData(bytes) {
+    throw this.error;
+  }
+  async recvHandshakeMessage(msg) {
+    throw this.error;
+  }
+  async recvAlertMessage(err) {
+    throw this.error;
+  }
+  async recvChangeCipherSpec(bytes) {
+    throw this.error;
+  }
+  async handleErrorAndRethrow(err) {
+    throw err;
+  }
+  async close() {
+    throw this.error;
+  }
+}
+
+// The "connected" state, for when the handshake is complete
+// and we're ready to send application-level data.
+// The logic for this is largely symmetric between client and server.
+
+class states_CONNECTED extends states_State {
+  async initialize() {
+    this.conn._setConnectionSuccess();
+  }
+  async sendApplicationData(bytes) {
+    await this.conn._sendApplicationData(bytes);
+  }
+  async recvApplicationData(bytes) {
+    return bytes;
+  }
+  async recvChangeCipherSpec(bytes) {
+    throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+  }
+}
+
+// A base class for states that occur in the middle of the handshake
+// (that is, between ClientHello and Finished).  These states may receive
+// CHANGE_CIPHER_SPEC records for b/w compat reasons, which must contain
+// exactly a single 0x01 byte and must otherwise be ignored.
+
+class states_MidHandshakeState extends states_State {
+  async recvChangeCipherSpec(bytes) {
+    if (this.conn._hasSeenChangeCipherSpec) {
+      throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+    }
+    if (bytes.byteLength !== 1 || bytes[0] !== 1) {
+      throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+    }
+    this.conn._hasSeenChangeCipherSpec = true;
+  }
+}
+
+// These states implement (part of) the client state-machine from
+// https://tools.ietf.org/html/rfc8446#appendix-A.1
+//
+// Since we're only implementing a small subset of TLS1.3,
+// we only need a small subset of the handshake.  It basically goes:
+//
+//   * send ClientHello
+//   * receive ServerHello
+//   * receive EncryptedExtensions
+//   * receive server Finished
+//   * send client Finished
+//
+// We include some unused states for completeness, so that it's easier
+// to check the implementation against the diagrams in the RFC.
+
+class states_CLIENT_START extends states_State {
+  async initialize() {
+    const keyschedule = this.conn._keyschedule;
+    await keyschedule.addPSK(this.conn.psk);
+    // Construct a ClientHello message with our single PSK.
+    // We can't know the PSK binder value yet, so we initially write zeros.
+    const clientHello = new messages_ClientHello(
+      // Client random salt.
+      await getRandomBytes(32),
+      // Random legacy_session_id; we *could* send an empty string here,
+      // but sending a random one makes it easier to be compatible with
+      // the data emitted by tlslite-ng for test-case generation.
+      await getRandomBytes(32),
+      [
+        new extensions_SupportedVersionsExtension([VERSION_TLS_1_3]),
+        new extensions_PskKeyExchangeModesExtension([PSK_MODE_KE]),
+        new extensions_PreSharedKeyExtension([this.conn.pskId], [zeros(HASH_LENGTH)]),
+      ],
+    );
+    const buf = new utils_BufferWriter();
+    clientHello.write(buf);
+    // Now that we know what the ClientHello looks like,
+    // go back and calculate the appropriate PSK binder value.
+    // We only support a single PSK, so the length of the binders field is the
+    // length of the hash plus one for rendering it as a variable-length byte array,
+    // plus two for rendering the variable-length list of PSK binders.
+    const PSK_BINDERS_SIZE = HASH_LENGTH + 1 + 2;
+    const truncatedTranscript = buf.slice(0, buf.tell() - PSK_BINDERS_SIZE);
+    const pskBinder = await keyschedule.calculateFinishedMAC(keyschedule.extBinderKey, truncatedTranscript);
+    buf.incr(-HASH_LENGTH);
+    buf.writeBytes(pskBinder);
+    await this.conn._sendHandshakeMessageBytes(buf.flush());
+    await this.conn._transition(states_CLIENT_WAIT_SH, clientHello.sessionId);
+  }
+}
+
+class states_CLIENT_WAIT_SH extends states_State {
+  async initialize(sessionId) {
+    this._sessionId = sessionId;
+  }
+  async recvHandshakeMessage(msg) {
+    if (! (msg instanceof messages_ServerHello)) {
+      throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+    }
+    if (! bytesAreEqual(msg.sessionId, this._sessionId)) {
+      throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+    }
+    const pskExt = msg.extensions.get(EXTENSION_TYPE.PRE_SHARED_KEY);
+    if (! pskExt) {
+      throw new TLSError(ALERT_DESCRIPTION.MISSING_EXTENSION);
+    }
+    // We expect only the SUPPORTED_VERSIONS and PRE_SHARED_KEY extensions.
+    if (msg.extensions.size !== 2) {
+      throw new TLSError(ALERT_DESCRIPTION.UNSUPPORTED_EXTENSION);
+    }
+    if (pskExt.selectedIdentity !== 0) {
+      throw new TLSError(ALERT_DESCRIPTION.ILLEGAL_PARAMETER);
+    }
+    await this.conn._keyschedule.addECDHE(null);
+    await this.conn._setSendKey(this.conn._keyschedule.clientHandshakeTrafficSecret);
+    await this.conn._setRecvKey(this.conn._keyschedule.serverHandshakeTrafficSecret);
+    await this.conn._transition(states_CLIENT_WAIT_EE);
+  }
+}
+
+class states_CLIENT_WAIT_EE extends states_MidHandshakeState {
+  async recvHandshakeMessage(msg) {
+    // We don't make use of any encrypted extensions, but we still
+    // have to wait for the server to send the (empty) list of them.
+    if (! (msg instanceof EncryptedExtensions)) {
+      throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+    }
+    // We do not support any EncryptedExtensions.
+    if (msg.extensions.size !== 0) {
+      throw new TLSError(ALERT_DESCRIPTION.UNSUPPORTED_EXTENSION);
+    }
+    const keyschedule = this.conn._keyschedule;
+    const serverFinishedTranscript = keyschedule.getTranscript();
+    await this.conn._transition(states_CLIENT_WAIT_FINISHED, serverFinishedTranscript);
+  }
+}
+
+class states_CLIENT_WAIT_FINISHED extends states_State {
+  async initialize(serverFinishedTranscript) {
+    this._serverFinishedTranscript = serverFinishedTranscript;
+  }
+  async recvHandshakeMessage(msg) {
+    if (! (msg instanceof messages_Finished)) {
+      throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+    }
+    // Verify server Finished MAC.
+    const keyschedule = this.conn._keyschedule;
+    await keyschedule.verifyFinishedMAC(keyschedule.serverHandshakeTrafficSecret, msg.verifyData, this._serverFinishedTranscript);
+    // Send our own Finished message in return.
+    // This must be encrypted with the handshake traffic key,
+    // but must not appear in the transcript used to calculate the application keys.
+    const clientFinishedMAC = await keyschedule.calculateFinishedMAC(keyschedule.clientHandshakeTrafficSecret);
+    await keyschedule.finalize();
+    await this.conn._sendHandshakeMessage(new messages_Finished(clientFinishedMAC));
+    await this.conn._setSendKey(keyschedule.clientApplicationTrafficSecret);
+    await this.conn._setRecvKey(keyschedule.serverApplicationTrafficSecret);
+    await this.conn._transition(states_CLIENT_CONNECTED);
+  }
+}
+
+class states_CLIENT_CONNECTED extends states_CONNECTED {
+  async recvHandshakeMessage(msg) {
+    // A connected client must be prepared to accept NewSessionTicket
+    // messages.  We never use them, but other server implementations
+    // might send them.
+    if (! (msg instanceof messages_NewSessionTicket)) {
+      throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+    }
+  }
+}
+
+// These states implement (part of) the server state-machine from
+// https://tools.ietf.org/html/rfc8446#appendix-A.2
+//
+// Since we're only implementing a small subset of TLS1.3,
+// we only need a small subset of the handshake.  It basically goes:
+//
+//   * receive ClientHello
+//   * send ServerHello
+//   * send empty EncryptedExtensions
+//   * send server Finished
+//   * receive client Finished
+//
+// We include some unused states for completeness, so that it's easier
+// to check the implementation against the diagrams in the RFC.
+
+class states_SERVER_START extends states_State {
+  async recvHandshakeMessage(msg) {
+    if (! (msg instanceof messages_ClientHello)) {
+      throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+    }
+    // In the spec, this is where we select connection parameters, and maybe
+    // tell the client to try again if we can't find a compatible set.
+    // Since we only support a fixed cipherset, the only thing to "negotiate"
+    // is whether they provided an acceptable PSK.
+    const pskExt = msg.extensions.get(EXTENSION_TYPE.PRE_SHARED_KEY);
+    const pskModesExt = msg.extensions.get(EXTENSION_TYPE.PSK_KEY_EXCHANGE_MODES);
+    if (! pskExt || ! pskModesExt) {
+      throw new TLSError(ALERT_DESCRIPTION.MISSING_EXTENSION);
+    }
+    if (pskModesExt.modes.indexOf(PSK_MODE_KE) === -1) {
+      throw new TLSError(ALERT_DESCRIPTION.HANDSHAKE_FAILURE);
+    }
+    const pskIndex = pskExt.identities.findIndex(pskId => bytesAreEqual(pskId, this.conn.pskId));
+    if (pskIndex === -1) {
+      throw new TLSError(ALERT_DESCRIPTION.UNKNOWN_PSK_IDENTITY);
+    }
+    await this.conn._keyschedule.addPSK(this.conn.psk);
+    // Validate the PSK binder.
+    const keyschedule = this.conn._keyschedule;
+    const transcript = keyschedule.getTranscript();
+    // Calculate size occupied by the PSK binders.
+    let pskBindersSize = 2; // Vector16 representation overhead.
+    for (const binder of pskExt.binders) {
+      pskBindersSize += binder.byteLength + 1; // Vector8 representation overhead.
+    }
+    await keyschedule.verifyFinishedMAC(keyschedule.extBinderKey, pskExt.binders[pskIndex], transcript.slice(0, -pskBindersSize));
+    await this.conn._transition(states_SERVER_NEGOTIATED, msg.sessionId, pskIndex);
+  }
+}
+
+class states_SERVER_NEGOTIATED extends states_MidHandshakeState {
+  async initialize(sessionId, pskIndex) {
+    await this.conn._sendHandshakeMessage(new messages_ServerHello(
+      // Server random
+      await getRandomBytes(32),
+      sessionId,
+      [
+        new extensions_SupportedVersionsExtension(null, VERSION_TLS_1_3),
+        new extensions_PreSharedKeyExtension(null, null, pskIndex),
+      ]
+    ));
+    // If the client sent a non-empty sessionId, the server *must* send a change-cipher-spec for b/w compat.
+    if (sessionId.byteLength > 0) {
+      await this.conn._sendChangeCipherSpec();
+    }
+    // We can now transition to the encrypted part of the handshake.
+    const keyschedule = this.conn._keyschedule;
+    await keyschedule.addECDHE(null);
+    await this.conn._setSendKey(keyschedule.serverHandshakeTrafficSecret);
+    await this.conn._setRecvKey(keyschedule.clientHandshakeTrafficSecret);
+    // Send an empty EncryptedExtensions message.
+    await this.conn._sendHandshakeMessage(new EncryptedExtensions([]));
+    // Send the Finished message.
+    const serverFinishedMAC = await keyschedule.calculateFinishedMAC(keyschedule.serverHandshakeTrafficSecret);
+    await this.conn._sendHandshakeMessage(new messages_Finished(serverFinishedMAC));
+    // We can now *send* using the application traffic key,
+    // but have to wait to receive the client Finished before receiving under that key.
+    // We need to remember the handshake state from before the client Finished
+    // in order to successfully verify the client Finished.
+    const clientFinishedTranscript = await keyschedule.getTranscript();
+    const clientHandshakeTrafficSecret = keyschedule.clientHandshakeTrafficSecret;
+    await keyschedule.finalize();
+    await this.conn._setSendKey(keyschedule.serverApplicationTrafficSecret);
+    await this.conn._transition(states_SERVER_WAIT_FINISHED, clientHandshakeTrafficSecret, clientFinishedTranscript);
+  }
+}
+
+class states_SERVER_WAIT_FINISHED extends states_MidHandshakeState {
+  async initialize(clientHandshakeTrafficSecret, clientFinishedTranscript) {
+    this._clientHandshakeTrafficSecret = clientHandshakeTrafficSecret;
+    this._clientFinishedTranscript = clientFinishedTranscript;
+  }
+  async recvHandshakeMessage(msg) {
+    if (! (msg instanceof messages_Finished)) {
+      throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+    }
+    const keyschedule = this.conn._keyschedule;
+    await keyschedule.verifyFinishedMAC(this._clientHandshakeTrafficSecret, msg.verifyData, this._clientFinishedTranscript);
+    this._clientHandshakeTrafficSecret = this._clientFinishedTranscript = null;
+    await this.conn._setRecvKey(keyschedule.clientApplicationTrafficSecret);
+    await this.conn._transition(states_CONNECTED);
+  }
+}
+
+// CONCATENATED MODULE: ./src/keyschedule.js
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// TLS1.3 Key Schedule.
+//
+// In this file we implement the "key schedule" from
+// https://tools.ietf.org/html/rfc8446#section-7.1, which
+// defines how to calculate various keys as the handshake
+// state progresses.
+
+
+
+
+
+
+
+// The `KeySchedule` class progresses through three stages corresponding
+// to the three phases of the TLS1.3 key schedule:
+//
+//   UNINITIALIZED
+//       |
+//       | addPSK()
+//       v
+//   EARLY_SECRET
+//       |
+//       | addECDHE()
+//       v
+//   HANDSHAKE_SECRET
+//       |
+//       | finalize()
+//       v
+//   MASTER_SECRET
+//
+// It will error out if the calling code attempts to add key material
+// in the wrong order.
+
+const STAGE_UNINITIALIZED = 0;
+const STAGE_EARLY_SECRET = 1;
+const STAGE_HANDSHAKE_SECRET = 2;
+const STAGE_MASTER_SECRET = 3;
+
+class keyschedule_KeySchedule {
+  constructor() {
+    this.stage = STAGE_UNINITIALIZED;
+    // WebCrypto doesn't support a rolling hash construct, so we have to
+    // keep the entire message transcript in memory.
+    this.transcript = new utils_BufferWriter();
+    // This tracks the main secret from with other keys are derived at each stage.
+    this.secret = null;
+    // And these are all the various keys we'll derive as the handshake progresses.
+    this.extBinderKey = null;
+    this.clientHandshakeTrafficSecret = null;
+    this.serverHandshakeTrafficSecret = null;
+    this.clientApplicationTrafficSecret = null;
+    this.serverApplicationTrafficSecret = null;
+  }
+
+  async addPSK(psk) {
+    // Use the selected PSK (if any) to calculate the "early secret".
+    if (psk === null) {
+      psk = zeros(HASH_LENGTH);
+    }
+    if (this.stage !== STAGE_UNINITIALIZED) {
+      throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+    }
+    this.stage = STAGE_EARLY_SECRET;
+    this.secret = await hkdfExtract(zeros(HASH_LENGTH), psk);
+    this.extBinderKey = await this.deriveSecret('ext binder', EMPTY);
+    this.secret = await this.deriveSecret('derived', EMPTY);
+  }
+
+  async addECDHE(ecdhe) {
+    // Mix in the ECDHE output (if any) to calculate the "handshake secret".
+    if (ecdhe === null) {
+      ecdhe = zeros(HASH_LENGTH);
+    }
+    if (this.stage !== STAGE_EARLY_SECRET) {
+      throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+    }
+    this.stage = STAGE_HANDSHAKE_SECRET;
+    this.extBinderKey = null;
+    this.secret = await hkdfExtract(this.secret, ecdhe);
+    this.clientHandshakeTrafficSecret = await this.deriveSecret('c hs traffic');
+    this.serverHandshakeTrafficSecret = await this.deriveSecret('s hs traffic');
+    this.secret = await this.deriveSecret('derived', EMPTY);
+  }
+
+  async finalize() {
+    if (this.stage !== STAGE_HANDSHAKE_SECRET) {
+      throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+    }
+    this.stage = STAGE_MASTER_SECRET;
+    this.clientHandshakeTrafficSecret = null;
+    this.serverHandshakeTrafficSecret = null;
+    this.secret = await hkdfExtract(this.secret, zeros(HASH_LENGTH));
+    this.clientApplicationTrafficSecret = await this.deriveSecret('c ap traffic');
+    this.serverApplicationTrafficSecret = await this.deriveSecret('s ap traffic');
+    this.secret = null;
+  }
+
+  addToTranscript(bytes) {
+    this.transcript.writeBytes(bytes);
+  }
+
+  getTranscript() {
+    return this.transcript.slice();
+  }
+
+  async deriveSecret(label, transcript = undefined) {
+    transcript = transcript || this.getTranscript();
+    return await hkdfExpandLabel(this.secret, label, await hash(transcript), HASH_LENGTH);
+  }
+
+  async calculateFinishedMAC(baseKey, transcript = undefined) {
+    transcript = transcript || this.getTranscript();
+    const finishedKey = await hkdfExpandLabel(baseKey, 'finished', EMPTY, HASH_LENGTH);
+    return await hmac(finishedKey, await hash(transcript));
+  }
+
+  async verifyFinishedMAC(baseKey, mac, transcript = undefined) {
+    transcript = transcript || this.getTranscript();
+    const finishedKey = await hkdfExpandLabel(baseKey, 'finished', EMPTY, HASH_LENGTH);
+    await verifyHmac(finishedKey, mac, await hash(transcript));
+  }
+}
+
+// CONCATENATED MODULE: ./src/recordlayer.js
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+//
+// This file implements the "record layer" for TLS1.3, as defined in
+// https://tools.ietf.org/html/rfc8446#section-5.
+//
+// The record layer is responsible for encrypting/decrypting bytes to be
+// sent over the wire, including stateful management of sequence numbers
+// for the incoming and outgoing stream.
+//
+// The main interface is the RecordLayer class, which takes a callback function
+// sending data and can be used like so:
+//
+//    rl = new RecordLayer(async function send_encrypted_data(data) {
+//      // application-specific sending logic here.
+//    });
+//
+//    // Records are sent and received in plaintext by default,
+//    // until you specify the key to use.
+//    await rl.setSendKey(key)
+//
+//    // Send some data by specifying the record type and the bytes.
+//    // Where allowed by the record type, it will be buffered until
+//    // explicitly flushed, and then sent by calling the callback.
+//    await rl.send(RECORD_TYPE.HANDSHAKE, <bytes for a handshake message>)
+//    await rl.send(RECORD_TYPE.HANDSHAKE, <bytes for another handshake message>)
+//    await rl.flush()
+//
+//    // Separate keys are used for sending and receiving.
+//    rl.setRecvKey(key);
+//
+//    // When data is received, push it into the RecordLayer
+//    // and pass a callback that will be called with a [type, bytes]
+//    // pair for each message parsed from the data.
+//    rl.recv(dataReceivedFromPeer, async (type, bytes) => {
+//      switch (type) {
+//        case RECORD_TYPE.APPLICATION_DATA:
+//          // do something with application data
+//        case RECORD_TYPE.HANDSHAKE:
+//          // do something with a handshake message
+//        default:
+//          // etc...
+//      }
+//    });
+//
+
+
+
+
+
+
+
+/* eslint-disable sorting/sort-object-props */
+const RECORD_TYPE = {
+  CHANGE_CIPHER_SPEC: 20,
+  ALERT: 21,
+  HANDSHAKE: 22,
+  APPLICATION_DATA: 23,
+};
+/* eslint-enable sorting/sort-object-props */
+
+// Encrypting at most 2^24 records will force us to stay
+// below data limits on AES-GCM encryption key use, and also
+// means we can accurately represent the sequence number as
+// a javascript double.
+const MAX_SEQUENCE_NUMBER = Math.pow(2, 24);
+const MAX_RECORD_SIZE = Math.pow(2, 14);
+const MAX_ENCRYPTED_RECORD_SIZE = MAX_RECORD_SIZE + 256;
+const RECORD_HEADER_SIZE = 5;
+
+// These are some helper classes to manage the encryption/decryption state
+// for a particular key.
+
+class recordlayer_CipherState {
+  constructor(key, iv) {
+    this.key = key;
+    this.iv = iv;
+    this.seqnum = 0;
+  }
+
+  static async create(baseKey, mode) {
+    // Derive key and iv per https://tools.ietf.org/html/rfc8446#section-7.3
+    const key = await prepareKey(await hkdfExpandLabel(baseKey, 'key', EMPTY, KEY_LENGTH), mode);
+    const iv = await hkdfExpandLabel(baseKey, 'iv', EMPTY, IV_LENGTH);
+    return new this(key, iv);
+  }
+
+  nonce() {
+    // Ref https://tools.ietf.org/html/rfc8446#section-5.3:
+    // * left-pad the sequence number with zeros to IV_LENGTH
+    // * xor with the provided iv
+    // Our sequence numbers are always less than 2^24, so fit in a Uint32
+    // in the last 4 bytes of the nonce.
+    const nonce = this.iv.slice();
+    const dv = new DataView(nonce.buffer, nonce.byteLength - 4, 4);
+    dv.setUint32(0, dv.getUint32(0) ^ this.seqnum);
+    this.seqnum += 1;
+    if (this.seqnum > MAX_SEQUENCE_NUMBER) {
+      throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+    }
+    return nonce;
+  }
+}
+
+class recordlayer_EncryptionState extends recordlayer_CipherState {
+  static async create(key) {
+    return super.create(key, 'encrypt');
+  }
+
+  async encrypt(plaintext, additionalData) {
+    return await encrypt(this.key, this.nonce(), plaintext, additionalData);
+  }
+}
+
+class recordlayer_DecryptionState extends recordlayer_CipherState {
+  static async create(key) {
+    return super.create(key, 'decrypt');
+  }
+
+  async decrypt(ciphertext, additionalData) {
+    return await decrypt(this.key, this.nonce(), ciphertext, additionalData);
+  }
+}
+
+// The main RecordLayer class.
+
+class recordlayer_RecordLayer {
+  constructor(sendCallback) {
+    this.sendCallback = sendCallback;
+    this._sendEncryptState = null;
+    this._sendError = null;
+    this._recvDecryptState = null;
+    this._recvError = null;
+    this._pendingRecordType = 0;
+    this._pendingRecordBuf = null;
+  }
+
+  async setSendKey(key) {
+    await this.flush();
+    this._sendEncryptState = await recordlayer_EncryptionState.create(key);
+  }
+
+  async setRecvKey(key) {
+    this._recvDecryptState = await recordlayer_DecryptionState.create(key);
+  }
+
+  async setSendError(err) {
+    this._sendError = err;
+  }
+
+  async setRecvError(err) {
+    this._recvError = err;
+  }
+
+  async send(type, data) {
+    if (this._sendError !== null) {
+      throw this._sendError;
+    }
+    // Forbid sending data that doesn't fit into a single record.
+    // We do not support fragmentation over multiple records.
+    if (data.byteLength > MAX_RECORD_SIZE) {
+      throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+    }
+    // Flush if we're switching to a different record type.
+    if (this._pendingRecordType && this._pendingRecordType !== type) {
+      await this.flush();
+    }
+    // Flush if we would overflow the max size of a record.
+    if (this._pendingRecordBuf !== null) {
+      if (this._pendingRecordBuf.tell() + data.byteLength > MAX_RECORD_SIZE) {
+        await this.flush();
+      }
+    }
+    // Start a new pending record if necessary.
+    // We reserve space at the start of the buffer for the record header,
+    // which is conveniently always a fixed size.
+    if (this._pendingRecordBuf === null) {
+      this._pendingRecordType = type;
+      this._pendingRecordBuf = new utils_BufferWriter();
+      this._pendingRecordBuf.incr(RECORD_HEADER_SIZE);
+    }
+    this._pendingRecordBuf.writeBytes(data);
+  }
+
+  async flush() {
+    // If there's nothing to flush, bail out early.
+    // Don't throw `_sendError` if we're not sending anything, because `flush()`
+    // can be called when we're trying to transition into an error state.
+    const buf = this._pendingRecordBuf;
+    let type = this._pendingRecordType;
+    if (! type) {
+      if (buf !== null) {
+        throw new TLSError(ALERT_DESCRIPTION.INTERNAL_ERROR);
+      }
+      return;
+    }
+    if (this._sendError !== null) {
+      throw this._sendError;
+    }
+    // If we're encrypting, turn the existing buffer contents into a `TLSInnerPlaintext` by
+    // appending the type. We don't do any zero-padding, although the spec allows it.
+    let inflation = 0, innerPlaintext = null;
+    if (this._sendEncryptState !== null) {
+      buf.writeUint8(type);
+      innerPlaintext = buf.slice(RECORD_HEADER_SIZE);
+      inflation = AEAD_SIZE_INFLATION;
+      type = RECORD_TYPE.APPLICATION_DATA;
+    }
+    // Write the common header for either `TLSPlaintext` or `TLSCiphertext` record.
+    const length = buf.tell() - RECORD_HEADER_SIZE + inflation;
+    buf.seek(0);
+    buf.writeUint8(type);
+    buf.writeUint16(VERSION_TLS_1_2);
+    buf.writeUint16(length);
+    // Followed by different payload depending on encryption status.
+    if (this._sendEncryptState !== null) {
+      const additionalData = buf.slice(0, RECORD_HEADER_SIZE);
+      const ciphertext = await this._sendEncryptState.encrypt(innerPlaintext, additionalData);
+      buf.writeBytes(ciphertext);
+    } else {
+      buf.incr(length);
+    }
+    this._pendingRecordBuf = null;
+    this._pendingRecordType = 0;
+    await this.sendCallback(buf.flush());
+  }
+
+  async recv(data) {
+    if (this._recvError !== null) {
+      throw this._recvError;
+    }
+    // For simplicity, we assume that the given data contains exactly one record.
+    // Peers using this library will send one record at a time over the websocket
+    // connection, and we can assume that the server-side websocket bridge will split
+    // up any traffic into individual records if we ever start interoperating with
+    // peers using a different TLS implementation.
+    // Similarly, we assume that handshake messages will not be fragmented across
+    // multiple records. This should be trivially true for the PSK-only mode used
+    // by this library, but we may want to relax it in future for interoperability
+    // with e.g. large ClientHello messages that contain lots of different options.
+    const buf = new utils_BufferReader(data);
+    // The data to read is either a TLSPlaintext or TLSCiphertext struct,
+    // depending on whether record protection has been enabled yet:
+    //
+    //    struct {
+    //        ContentType type;
+    //        ProtocolVersion legacy_record_version;
+    //        uint16 length;
+    //        opaque fragment[TLSPlaintext.length];
+    //    } TLSPlaintext;
+    //
+    //    struct {
+    //        ContentType opaque_type = application_data; /* 23 */
+    //        ProtocolVersion legacy_record_version = 0x0303; /* TLS v1.2 */
+    //        uint16 length;
+    //        opaque encrypted_record[TLSCiphertext.length];
+    //    } TLSCiphertext;
+    //
+    let type = buf.readUint8();
+    // The spec says legacy_record_version "MUST be ignored for all purposes",
+    // but we know TLS1.3 implementations will only ever emit two possible values,
+    // so it seems useful to bail out early if we receive anything else.
+    const version = buf.readUint16();
+    if (version !== VERSION_TLS_1_2) {
+      // TLS1.0 is only acceptable on initial plaintext records.
+      if (this._recvDecryptState !== null || version !== VERSION_TLS_1_0) {
+        throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+      }
+    }
+    const length = buf.readUint16();
+    let plaintext;
+    if (this._recvDecryptState === null || type === RECORD_TYPE.CHANGE_CIPHER_SPEC) {
+      [type, plaintext] = await this._readPlaintextRecord(type, length, buf);
+    } else {
+      [type, plaintext] = await this._readEncryptedRecord(type, length, buf);
+    }
+    // Sanity-check that we received exactly one record.
+    if (buf.hasMoreBytes()) {
+      throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+    }
+    return [type, plaintext];
+  }
+
+  // Helper to read an unencrypted `TLSPlaintext` struct
+
+  async _readPlaintextRecord(type, length, buf) {
+    if (length > MAX_RECORD_SIZE) {
+      throw new TLSError(ALERT_DESCRIPTION.RECORD_OVERFLOW);
+    }
+    return [type, buf.readBytes(length)];
+  }
+
+  // Helper to read an encrypted `TLSCiphertext` struct,
+  // decrypting it into plaintext.
+
+  async _readEncryptedRecord(type, length, buf) {
+    if (length > MAX_ENCRYPTED_RECORD_SIZE) {
+      throw new TLSError(ALERT_DESCRIPTION.RECORD_OVERFLOW);
+    }
+    // The outer type for encrypted records is always APPLICATION_DATA.
+    if (type !== RECORD_TYPE.APPLICATION_DATA) {
+      throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+    }
+    // Decrypt and decode the contained `TLSInnerPlaintext` struct:
+    //
+    //    struct {
+    //        opaque content[TLSPlaintext.length];
+    //        ContentType type;
+    //        uint8 zeros[length_of_padding];
+    //    } TLSInnerPlaintext;
+    //
+    // The additional data for the decryption is the `TLSCiphertext` record
+    // header, which is a fixed size and immediately prior to current buffer position.
+    buf.incr(-RECORD_HEADER_SIZE);
+    const additionalData = buf.readBytes(RECORD_HEADER_SIZE);
+    const ciphertext = buf.readBytes(length);
+    const paddedPlaintext = await this._recvDecryptState.decrypt(ciphertext, additionalData);
+    // We have to scan backwards over the zero padding at the end of the struct
+    // in order to find the non-zero `type` byte.
+    let i;
+    for (i = paddedPlaintext.byteLength - 1; i >= 0; i--) {
+      if (paddedPlaintext[i] !== 0) {
+        break;
+      }
+    }
+    if (i < 0) {
+      throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+    }
+    type = paddedPlaintext[i];
+    // `change_cipher_spec` records must always be plaintext.
+    if (type === RECORD_TYPE.CHANGE_CIPHER_SPEC) {
+      throw new TLSError(ALERT_DESCRIPTION.DECODE_ERROR);
+    }
+    return [type, paddedPlaintext.slice(0, i)];
+  }
+}
+
+// CONCATENATED MODULE: ./src/tlsconnection.js
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// The top-level APIs offered by this module are `ClientConnection` and
+// `ServerConnection` classes, which provide authenticated and encrypted
+// communication via the "externally-provisioned PSK" mode of TLS1.3.
+// They each take a callback to be used for sending data to the remote peer,
+// and operate like this:
+//
+//    conn = await ClientConnection.create(psk, pskId, async function send_data_to_server(data) {
+//      // application-specific sending logic here.
+//    })
+//
+//    // Send data to the server by calling `send`,
+//    // which will use the callback provided in the constructor.
+//    // A single `send()` by the application may result in multiple
+//    // invokations of the callback.
+//
+//    await conn.send('application-level data')
+//
+//    // When data is received from the server, push it into
+//    // the connection and let it return any decrypted app-level data.
+//    // There might not be any app-level data if it was a protocol control
+//    //  message, and the receipt of the data might trigger additional calls
+//    // to the send callback for protocol control purposes.
+//
+//    serverSocket.on('data', async encrypted_data => {
+//      const plaintext = await conn.recv(data)
+//      if (plaintext !== null) {
+//        do_something_with_app_level_data(plaintext)
+//      }
+//    })
+//
+//    // It's good practice to explicitly close the connection
+//    // when finished.  This will send a "closed" notification
+//    // to the server.
+//
+//    await conn.close()
+//
+//    // When the peer sends a "closed" notification it will show up
+//    // as a `TLSCloseNotify` exception from recv:
+//
+//    try {
+//      data = await conn.recv(data);
+//    } catch (err) {
+//      if (! (err instanceof TLSCloseNotify) { throw err }
+//      do_something_to_cleanly_close_data_connection();
+//    }
+//
+// The `ServerConnection` API operates similarly; the distinction is mainly
+// in which side is expected to send vs receieve during the protocol handshake.
+
+
+
+
+
+
+
+
+
+
+class tlsconnection_Connection {
+  constructor(psk, pskId, sendCallback) {
+    this.psk = assertIsBytes(psk);
+    this.pskId = assertIsBytes(pskId);
+    this.connected = new Promise((resolve, reject) => {
+      this._onConnectionSuccess = resolve;
+      this._onConnectionFailure = reject;
+    });
+    this._state = new UNINITIALIZED(this);
+    this._handshakeRecvBuffer = null;
+    this._hasSeenChangeCipherSpec = false;
+    this._recordlayer = new recordlayer_RecordLayer(sendCallback);
+    this._keyschedule = new keyschedule_KeySchedule();
+    this._lastPromise = Promise.resolve();
+  }
+
+  // Subclasses will override this with some async initialization logic.
+  static async create(psk, pskId, sendCallback) {
+    return new this(psk, pskId, sendCallback);
+  }
+
+  // These are the three public API methods that consumers can use
+  // to send and receive data encrypted with TLS1.3.
+
+  async send(data) {
+    assertIsBytes(data);
+    await this.connected;
+    await this._synchronized(async () => {
+      await this._state.sendApplicationData(data);
+    });
+  }
+
+  async recv(data) {
+    assertIsBytes(data);
+    return await this._synchronized(async () => {
+      // Decrypt the data using the record layer.
+      // We expect to receive precisely one record at a time.
+      const [type, bytes] = await this._recordlayer.recv(data);
+      // Dispatch based on the type of the record.
+      switch (type) {
+        case RECORD_TYPE.CHANGE_CIPHER_SPEC:
+          await this._state.recvChangeCipherSpec(bytes);
+          return null;
+        case RECORD_TYPE.ALERT:
+          await this._state.recvAlertMessage(TLSAlert.fromBytes(bytes));
+          return null;
+        case RECORD_TYPE.APPLICATION_DATA:
+          return await this._state.recvApplicationData(bytes);
+        case RECORD_TYPE.HANDSHAKE:
+          // Multiple handshake messages may be coalesced into a single record.
+          // Store the in-progress record buffer on `this` so that we can guard
+          // against handshake messages that span a change in keys.
+          this._handshakeRecvBuffer = new utils_BufferReader(bytes);
+          if (! this._handshakeRecvBuffer.hasMoreBytes()) {
+            throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+          }
+          do {
+            // Each handshake messages has a type and length prefix, per
+            // https://tools.ietf.org/html/rfc8446#appendix-B.3
+            this._handshakeRecvBuffer.incr(1);
+            const mlength = this._handshakeRecvBuffer.readUint24();
+            this._handshakeRecvBuffer.incr(-4);
+            const messageBytes = this._handshakeRecvBuffer.readBytes(mlength + 4);
+            this._keyschedule.addToTranscript(messageBytes);
+            await this._state.recvHandshakeMessage(messages_HandshakeMessage.fromBytes(messageBytes));
+          } while (this._handshakeRecvBuffer.hasMoreBytes());
+          this._handshakeRecvBuffer = null;
+          return null;
+        default:
+          throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+      }
+    });
+  }
+
+  async close() {
+    await this._synchronized(async () => {
+      await this._state.close();
+    });
+  }
+
+  // Ensure that async functions execute one at a time,
+  // by waiting for the previous call to `_synchronized()` to complete
+  // before starting a new one.  This helps ensure that we complete
+  // one state-machine transition before starting to do the next.
+  // It's also a convenient place to catch and alert on errors.
+
+  _synchronized(cb) {
+    const nextPromise = this._lastPromise.then(() => {
+      return cb();
+    }).catch(async err => {
+      if (err instanceof TLSCloseNotify) {
+        throw err;
+      }
+      await this._state.handleErrorAndRethrow(err);
+    });
+    // We don't want to hold on to the return value or error,
+    // just synchronize on the fact that it completed.
+    this._lastPromise = nextPromise.then(noop, noop);
+    return nextPromise;
+  }
+
+  // This drives internal transition of the state-machine,
+  // ensuring that the new state is properly initialized.
+
+  async _transition(State, ...args) {
+    this._state = new State(this);
+    await this._state.initialize(...args);
+    await this._recordlayer.flush();
+  }
+
+  // These are helpers to allow the State to manipulate the recordlayer
+  // and send out various types of data.
+
+  async _sendApplicationData(bytes) {
+    await this._recordlayer.send(RECORD_TYPE.APPLICATION_DATA, bytes);
+    await this._recordlayer.flush();
+  }
+
+  async _sendHandshakeMessage(msg) {
+    await this._sendHandshakeMessageBytes(msg.toBytes());
+  }
+
+  async _sendHandshakeMessageBytes(bytes) {
+    this._keyschedule.addToTranscript(bytes);
+    await this._recordlayer.send(RECORD_TYPE.HANDSHAKE, bytes);
+    // Don't flush after each handshake message, since we can probably
+    // coalesce multiple messages into a single record.
+  }
+
+  async _sendAlertMessage(err) {
+    await this._recordlayer.send(RECORD_TYPE.ALERT, err.toBytes());
+    await this._recordlayer.flush();
+  }
+
+  async _sendChangeCipherSpec() {
+    await this._recordlayer.send(RECORD_TYPE.CHANGE_CIPHER_SPEC, new Uint8Array([0x01]));
+    await this._recordlayer.flush();
+  }
+
+  async _setSendKey(key) {
+    return await this._recordlayer.setSendKey(key);
+  }
+
+  async _setRecvKey(key) {
+    // Handshake messages that change keys must be on a record boundary.
+    if (this._handshakeRecvBuffer && this._handshakeRecvBuffer.hasMoreBytes()) {
+      throw new TLSError(ALERT_DESCRIPTION.UNEXPECTED_MESSAGE);
+    }
+    return await this._recordlayer.setRecvKey(key);
+  }
+
+  _setConnectionSuccess() {
+    if (this._onConnectionSuccess !== null) {
+      this._onConnectionSuccess();
+      this._onConnectionSuccess = null;
+      this._onConnectionFailure = null;
+    }
+  }
+
+  _setConnectionFailure(err) {
+    if (this._onConnectionFailure !== null) {
+      this._onConnectionFailure(err);
+      this._onConnectionSuccess = null;
+      this._onConnectionFailure = null;
+    }
+  }
+
+  _closeForSend(alert) {
+    this._recordlayer.setSendError(alert);
+  }
+
+  _closeForRecv(alert) {
+    this._recordlayer.setRecvError(alert);
+  }
+}
+
+class tlsconnection_ClientConnection extends tlsconnection_Connection {
+  static async create(psk, pskId, sendCallback) {
+    const instance = await super.create(psk, pskId, sendCallback);
+    await instance._transition(states_CLIENT_START);
+    return instance;
+  }
+}
+
+class tlsconnection_ServerConnection extends tlsconnection_Connection {
+  static async create(psk, pskId, sendCallback) {
+    const instance = await super.create(psk, pskId, sendCallback);
+    await instance._transition(states_SERVER_START);
+    return instance;
+  }
+}
+
+// CONCATENATED MODULE: ./node_modules/event-target-shim/dist/event-target-shim.mjs
+/**
+ * @author Toru Nagashima <https://github.com/mysticatea>
+ * @copyright 2015 Toru Nagashima. All rights reserved.
+ * See LICENSE file in root directory for full license.
+ */
+/**
+ * @typedef {object} PrivateData
+ * @property {EventTarget} eventTarget The event target.
+ * @property {{type:string}} event The original event object.
+ * @property {number} eventPhase The current event phase.
+ * @property {EventTarget|null} currentTarget The current event target.
+ * @property {boolean} canceled The flag to prevent default.
+ * @property {boolean} stopped The flag to stop propagation.
+ * @property {boolean} immediateStopped The flag to stop propagation immediately.
+ * @property {Function|null} passiveListener The listener if the current listener is passive. Otherwise this is null.
+ * @property {number} timeStamp The unix time.
+ * @private
+ */
+
+/**
+ * Private data for event wrappers.
+ * @type {WeakMap<Event, PrivateData>}
+ * @private
+ */
+const privateData = new WeakMap();
+
+/**
+ * Cache for wrapper classes.
+ * @type {WeakMap<Object, Function>}
+ * @private
+ */
+const wrappers = new WeakMap();
+
+/**
+ * Get private data.
+ * @param {Event} event The event object to get private data.
+ * @returns {PrivateData} The private data of the event.
+ * @private
+ */
+function pd(event) {
+    const retv = privateData.get(event);
+    console.assert(
+        retv != null,
+        "'this' is expected an Event object, but got",
+        event
+    );
+    return retv
+}
+
+/**
+ * https://dom.spec.whatwg.org/#set-the-canceled-flag
+ * @param data {PrivateData} private data.
+ */
+function setCancelFlag(data) {
+    if (data.passiveListener != null) {
+        if (
+            typeof console !== "undefined" &&
+            typeof console.error === "function"
+        ) {
+            console.error(
+                "Unable to preventDefault inside passive event listener invocation.",
+                data.passiveListener
+            );
+        }
+        return
+    }
+    if (!data.event.cancelable) {
+        return
+    }
+
+    data.canceled = true;
+    if (typeof data.event.preventDefault === "function") {
+        data.event.preventDefault();
+    }
+}
+
+/**
+ * @see https://dom.spec.whatwg.org/#interface-event
+ * @private
+ */
+/**
+ * The event wrapper.
+ * @constructor
+ * @param {EventTarget} eventTarget The event target of this dispatching.
+ * @param {Event|{type:string}} event The original event to wrap.
+ */
+function Event(eventTarget, event) {
+    privateData.set(this, {
+        eventTarget,
+        event,
+        eventPhase: 2,
+        currentTarget: eventTarget,
+        canceled: false,
+        stopped: false,
+        immediateStopped: false,
+        passiveListener: null,
+        timeStamp: event.timeStamp || Date.now(),
+    });
+
+    // https://heycam.github.io/webidl/#Unforgeable
+    Object.defineProperty(this, "isTrusted", { value: false, enumerable: true });
+
+    // Define accessors
+    const keys = Object.keys(event);
+    for (let i = 0; i < keys.length; ++i) {
+        const key = keys[i];
+        if (!(key in this)) {
+            Object.defineProperty(this, key, defineRedirectDescriptor(key));
+        }
+    }
+}
+
+// Should be enumerable, but class methods are not enumerable.
+Event.prototype = {
+    /**
+     * The type of this event.
+     * @type {string}
+     */
+    get type() {
+        return pd(this).event.type
+    },
+
+    /**
+     * The target of this event.
+     * @type {EventTarget}
+     */
+    get target() {
+        return pd(this).eventTarget
+    },
+
+    /**
+     * The target of this event.
+     * @type {EventTarget}
+     */
+    get currentTarget() {
+        return pd(this).currentTarget
+    },
+
+    /**
+     * @returns {EventTarget[]} The composed path of this event.
+     */
+    composedPath() {
+        const currentTarget = pd(this).currentTarget;
+        if (currentTarget == null) {
+            return []
+        }
+        return [currentTarget]
+    },
+
+    /**
+     * Constant of NONE.
+     * @type {number}
+     */
+    get NONE() {
+        return 0
+    },
+
+    /**
+     * Constant of CAPTURING_PHASE.
+     * @type {number}
+     */
+    get CAPTURING_PHASE() {
+        return 1
+    },
+
+    /**
+     * Constant of AT_TARGET.
+     * @type {number}
+     */
+    get AT_TARGET() {
+        return 2
+    },
+
+    /**
+     * Constant of BUBBLING_PHASE.
+     * @type {number}
+     */
+    get BUBBLING_PHASE() {
+        return 3
+    },
+
+    /**
+     * The target of this event.
+     * @type {number}
+     */
+    get eventPhase() {
+        return pd(this).eventPhase
+    },
+
+    /**
+     * Stop event bubbling.
+     * @returns {void}
+     */
+    stopPropagation() {
+        const data = pd(this);
+
+        data.stopped = true;
+        if (typeof data.event.stopPropagation === "function") {
+            data.event.stopPropagation();
+        }
+    },
+
+    /**
+     * Stop event bubbling.
+     * @returns {void}
+     */
+    stopImmediatePropagation() {
+        const data = pd(this);
+
+        data.stopped = true;
+        data.immediateStopped = true;
+        if (typeof data.event.stopImmediatePropagation === "function") {
+            data.event.stopImmediatePropagation();
+        }
+    },
+
+    /**
+     * The flag to be bubbling.
+     * @type {boolean}
+     */
+    get bubbles() {
+        return Boolean(pd(this).event.bubbles)
+    },
+
+    /**
+     * The flag to be cancelable.
+     * @type {boolean}
+     */
+    get cancelable() {
+        return Boolean(pd(this).event.cancelable)
+    },
+
+    /**
+     * Cancel this event.
+     * @returns {void}
+     */
+    preventDefault() {
+        setCancelFlag(pd(this));
+    },
+
+    /**
+     * The flag to indicate cancellation state.
+     * @type {boolean}
+     */
+    get defaultPrevented() {
+        return pd(this).canceled
+    },
+
+    /**
+     * The flag to be composed.
+     * @type {boolean}
+     */
+    get composed() {
+        return Boolean(pd(this).event.composed)
+    },
+
+    /**
+     * The unix time of this event.
+     * @type {number}
+     */
+    get timeStamp() {
+        return pd(this).timeStamp
+    },
+
+    /**
+     * The target of this event.
+     * @type {EventTarget}
+     * @deprecated
+     */
+    get srcElement() {
+        return pd(this).eventTarget
+    },
+
+    /**
+     * The flag to stop event bubbling.
+     * @type {boolean}
+     * @deprecated
+     */
+    get cancelBubble() {
+        return pd(this).stopped
+    },
+    set cancelBubble(value) {
+        if (!value) {
+            return
+        }
+        const data = pd(this);
+
+        data.stopped = true;
+        if (typeof data.event.cancelBubble === "boolean") {
+            data.event.cancelBubble = true;
+        }
+    },
+
+    /**
+     * The flag to indicate cancellation state.
+     * @type {boolean}
+     * @deprecated
+     */
+    get returnValue() {
+        return !pd(this).canceled
+    },
+    set returnValue(value) {
+        if (!value) {
+            setCancelFlag(pd(this));
+        }
+    },
+
+    /**
+     * Initialize this event object. But do nothing under event dispatching.
+     * @param {string} type The event type.
+     * @param {boolean} [bubbles=false] The flag to be possible to bubble up.
+     * @param {boolean} [cancelable=false] The flag to be possible to cancel.
+     * @deprecated
+     */
+    initEvent() {
+        // Do nothing.
+    },
+};
+
+// `constructor` is not enumerable.
+Object.defineProperty(Event.prototype, "constructor", {
+    value: Event,
+    configurable: true,
+    writable: true,
+});
+
+// Ensure `event instanceof window.Event` is `true`.
+if (typeof window !== "undefined" && typeof window.Event !== "undefined") {
+    Object.setPrototypeOf(Event.prototype, window.Event.prototype);
+
+    // Make association for wrappers.
+    wrappers.set(window.Event.prototype, Event);
+}
+
+/**
+ * Get the property descriptor to redirect a given property.
+ * @param {string} key Property name to define property descriptor.
+ * @returns {PropertyDescriptor} The property descriptor to redirect the property.
+ * @private
+ */
+function defineRedirectDescriptor(key) {
+    return {
+        get() {
+            return pd(this).event[key]
+        },
+        set(value) {
+            pd(this).event[key] = value;
+        },
+        configurable: true,
+        enumerable: true,
+    }
+}
+
+/**
+ * Get the property descriptor to call a given method property.
+ * @param {string} key Property name to define property descriptor.
+ * @returns {PropertyDescriptor} The property descriptor to call the method property.
+ * @private
+ */
+function defineCallDescriptor(key) {
+    return {
+        value() {
+            const event = pd(this).event;
+            return event[key].apply(event, arguments)
+        },
+        configurable: true,
+        enumerable: true,
+    }
+}
+
+/**
+ * Define new wrapper class.
+ * @param {Function} BaseEvent The base wrapper class.
+ * @param {Object} proto The prototype of the original event.
+ * @returns {Function} The defined wrapper class.
+ * @private
+ */
+function defineWrapper(BaseEvent, proto) {
+    const keys = Object.keys(proto);
+    if (keys.length === 0) {
+        return BaseEvent
+    }
+
+    /** CustomEvent */
+    function CustomEvent(eventTarget, event) {
+        BaseEvent.call(this, eventTarget, event);
+    }
+
+    CustomEvent.prototype = Object.create(BaseEvent.prototype, {
+        constructor: { value: CustomEvent, configurable: true, writable: true },
+    });
+
+    // Define accessors.
+    for (let i = 0; i < keys.length; ++i) {
+        const key = keys[i];
+        if (!(key in BaseEvent.prototype)) {
+            const descriptor = Object.getOwnPropertyDescriptor(proto, key);
+            const isFunc = typeof descriptor.value === "function";
+            Object.defineProperty(
+                CustomEvent.prototype,
+                key,
+                isFunc
+                    ? defineCallDescriptor(key)
+                    : defineRedirectDescriptor(key)
+            );
+        }
+    }
+
+    return CustomEvent
+}
+
+/**
+ * Get the wrapper class of a given prototype.
+ * @param {Object} proto The prototype of the original event to get its wrapper.
+ * @returns {Function} The wrapper class.
+ * @private
+ */
+function getWrapper(proto) {
+    if (proto == null || proto === Object.prototype) {
+        return Event
+    }
+
+    let wrapper = wrappers.get(proto);
+    if (wrapper == null) {
+        wrapper = defineWrapper(getWrapper(Object.getPrototypeOf(proto)), proto);
+        wrappers.set(proto, wrapper);
+    }
+    return wrapper
+}
+
+/**
+ * Wrap a given event to management a dispatching.
+ * @param {EventTarget} eventTarget The event target of this dispatching.
+ * @param {Object} event The event to wrap.
+ * @returns {Event} The wrapper instance.
+ * @private
+ */
+function wrapEvent(eventTarget, event) {
+    const Wrapper = getWrapper(Object.getPrototypeOf(event));
+    return new Wrapper(eventTarget, event)
+}
+
+/**
+ * Get the immediateStopped flag of a given event.
+ * @param {Event} event The event to get.
+ * @returns {boolean} The flag to stop propagation immediately.
+ * @private
+ */
+function isStopped(event) {
+    return pd(event).immediateStopped
+}
+
+/**
+ * Set the current event phase of a given event.
+ * @param {Event} event The event to set current target.
+ * @param {number} eventPhase New event phase.
+ * @returns {void}
+ * @private
+ */
+function setEventPhase(event, eventPhase) {
+    pd(event).eventPhase = eventPhase;
+}
+
+/**
+ * Set the current target of a given event.
+ * @param {Event} event The event to set current target.
+ * @param {EventTarget|null} currentTarget New current target.
+ * @returns {void}
+ * @private
+ */
+function setCurrentTarget(event, currentTarget) {
+    pd(event).currentTarget = currentTarget;
+}
+
+/**
+ * Set a passive listener of a given event.
+ * @param {Event} event The event to set current target.
+ * @param {Function|null} passiveListener New passive listener.
+ * @returns {void}
+ * @private
+ */
+function setPassiveListener(event, passiveListener) {
+    pd(event).passiveListener = passiveListener;
+}
+
+/**
+ * @typedef {object} ListenerNode
+ * @property {Function} listener
+ * @property {1|2|3} listenerType
+ * @property {boolean} passive
+ * @property {boolean} once
+ * @property {ListenerNode|null} next
+ * @private
+ */
+
+/**
+ * @type {WeakMap<object, Map<string, ListenerNode>>}
+ * @private
+ */
+const listenersMap = new WeakMap();
+
+// Listener types
+const CAPTURE = 1;
+const BUBBLE = 2;
+const ATTRIBUTE = 3;
+
+/**
+ * Check whether a given value is an object or not.
+ * @param {any} x The value to check.
+ * @returns {boolean} `true` if the value is an object.
+ */
+function isObject(x) {
+    return x !== null && typeof x === "object" //eslint-disable-line no-restricted-syntax
+}
+
+/**
+ * Get listeners.
+ * @param {EventTarget} eventTarget The event target to get.
+ * @returns {Map<string, ListenerNode>} The listeners.
+ * @private
+ */
+function getListeners(eventTarget) {
+    const listeners = listenersMap.get(eventTarget);
+    if (listeners == null) {
+        throw new TypeError(
+            "'this' is expected an EventTarget object, but got another value."
+        )
+    }
+    return listeners
+}
+
+/**
+ * Get the property descriptor for the event attribute of a given event.
+ * @param {string} eventName The event name to get property descriptor.
+ * @returns {PropertyDescriptor} The property descriptor.
+ * @private
+ */
+function defineEventAttributeDescriptor(eventName) {
+    return {
+        get() {
+            const listeners = getListeners(this);
+            let node = listeners.get(eventName);
+            while (node != null) {
+                if (node.listenerType === ATTRIBUTE) {
+                    return node.listener
+                }
+                node = node.next;
+            }
+            return null
+        },
+
+        set(listener) {
+            if (typeof listener !== "function" && !isObject(listener)) {
+                listener = null; // eslint-disable-line no-param-reassign
+            }
+            const listeners = getListeners(this);
+
+            // Traverse to the tail while removing old value.
+            let prev = null;
+            let node = listeners.get(eventName);
+            while (node != null) {
+                if (node.listenerType === ATTRIBUTE) {
+                    // Remove old value.
+                    if (prev !== null) {
+                        prev.next = node.next;
+                    } else if (node.next !== null) {
+                        listeners.set(eventName, node.next);
+                    } else {
+                        listeners.delete(eventName);
+                    }
+                } else {
+                    prev = node;
+                }
+
+                node = node.next;
+            }
+
+            // Add new value.
+            if (listener !== null) {
+                const newNode = {
+                    listener,
+                    listenerType: ATTRIBUTE,
+                    passive: false,
+                    once: false,
+                    next: null,
+                };
+                if (prev === null) {
+                    listeners.set(eventName, newNode);
+                } else {
+                    prev.next = newNode;
+                }
+            }
+        },
+        configurable: true,
+        enumerable: true,
+    }
+}
+
+/**
+ * Define an event attribute (e.g. `eventTarget.onclick`).
+ * @param {Object} eventTargetPrototype The event target prototype to define an event attrbite.
+ * @param {string} eventName The event name to define.
+ * @returns {void}
+ */
+function defineEventAttribute(eventTargetPrototype, eventName) {
+    Object.defineProperty(
+        eventTargetPrototype,
+        `on${eventName}`,
+        defineEventAttributeDescriptor(eventName)
+    );
+}
+
+/**
+ * Define a custom EventTarget with event attributes.
+ * @param {string[]} eventNames Event names for event attributes.
+ * @returns {EventTarget} The custom EventTarget.
+ * @private
+ */
+function defineCustomEventTarget(eventNames) {
+    /** CustomEventTarget */
+    function CustomEventTarget() {
+        EventTarget.call(this);
+    }
+
+    CustomEventTarget.prototype = Object.create(EventTarget.prototype, {
+        constructor: {
+            value: CustomEventTarget,
+            configurable: true,
+            writable: true,
+        },
+    });
+
+    for (let i = 0; i < eventNames.length; ++i) {
+        defineEventAttribute(CustomEventTarget.prototype, eventNames[i]);
+    }
+
+    return CustomEventTarget
+}
+
+/**
+ * EventTarget.
+ *
+ * - This is constructor if no arguments.
+ * - This is a function which returns a CustomEventTarget constructor if there are arguments.
+ *
+ * For example:
+ *
+ *     class A extends EventTarget {}
+ *     class B extends EventTarget("message") {}
+ *     class C extends EventTarget("message", "error") {}
+ *     class D extends EventTarget(["message", "error"]) {}
+ */
+function EventTarget() {
+    /*eslint-disable consistent-return */
+    if (this instanceof EventTarget) {
+        listenersMap.set(this, new Map());
+        return
+    }
+    if (arguments.length === 1 && Array.isArray(arguments[0])) {
+        return defineCustomEventTarget(arguments[0])
+    }
+    if (arguments.length > 0) {
+        const types = new Array(arguments.length);
+        for (let i = 0; i < arguments.length; ++i) {
+            types[i] = arguments[i];
+        }
+        return defineCustomEventTarget(types)
+    }
+    throw new TypeError("Cannot call a class as a function")
+    /*eslint-enable consistent-return */
+}
+
+// Should be enumerable, but class methods are not enumerable.
+EventTarget.prototype = {
+    /**
+     * Add a given listener to this event target.
+     * @param {string} eventName The event name to add.
+     * @param {Function} listener The listener to add.
+     * @param {boolean|{capture?:boolean,passive?:boolean,once?:boolean}} [options] The options for this listener.
+     * @returns {void}
+     */
+    addEventListener(eventName, listener, options) {
+        if (listener == null) {
+            return
+        }
+        if (typeof listener !== "function" && !isObject(listener)) {
+            throw new TypeError("'listener' should be a function or an object.")
+        }
+
+        const listeners = getListeners(this);
+        const optionsIsObj = isObject(options);
+        const capture = optionsIsObj
+            ? Boolean(options.capture)
+            : Boolean(options);
+        const listenerType = capture ? CAPTURE : BUBBLE;
+        const newNode = {
+            listener,
+            listenerType,
+            passive: optionsIsObj && Boolean(options.passive),
+            once: optionsIsObj && Boolean(options.once),
+            next: null,
+        };
+
+        // Set it as the first node if the first node is null.
+        let node = listeners.get(eventName);
+        if (node === undefined) {
+            listeners.set(eventName, newNode);
+            return
+        }
+
+        // Traverse to the tail while checking duplication..
+        let prev = null;
+        while (node != null) {
+            if (
+                node.listener === listener &&
+                node.listenerType === listenerType
+            ) {
+                // Should ignore duplication.
+                return
+            }
+            prev = node;
+            node = node.next;
+        }
+
+        // Add it.
+        prev.next = newNode;
+    },
+
+    /**
+     * Remove a given listener from this event target.
+     * @param {string} eventName The event name to remove.
+     * @param {Function} listener The listener to remove.
+     * @param {boolean|{capture?:boolean,passive?:boolean,once?:boolean}} [options] The options for this listener.
+     * @returns {void}
+     */
+    removeEventListener(eventName, listener, options) {
+        if (listener == null) {
+            return
+        }
+
+        const listeners = getListeners(this);
+        const capture = isObject(options)
+            ? Boolean(options.capture)
+            : Boolean(options);
+        const listenerType = capture ? CAPTURE : BUBBLE;
+
+        let prev = null;
+        let node = listeners.get(eventName);
+        while (node != null) {
+            if (
+                node.listener === listener &&
+                node.listenerType === listenerType
+            ) {
+                if (prev !== null) {
+                    prev.next = node.next;
+                } else if (node.next !== null) {
+                    listeners.set(eventName, node.next);
+                } else {
+                    listeners.delete(eventName);
+                }
+                return
+            }
+
+            prev = node;
+            node = node.next;
+        }
+    },
+
+    /**
+     * Dispatch a given event.
+     * @param {Event|{type:string}} event The event to dispatch.
+     * @returns {boolean} `false` if canceled.
+     */
+    dispatchEvent(event) {
+        if (event == null || typeof event.type !== "string") {
+            throw new TypeError('"event.type" should be a string.')
+        }
+
+        // If listeners aren't registered, terminate.
+        const listeners = getListeners(this);
+        const eventName = event.type;
+        let node = listeners.get(eventName);
+        if (node == null) {
+            return true
+        }
+
+        // Since we cannot rewrite several properties, so wrap object.
+        const wrappedEvent = wrapEvent(this, event);
+
+        // This doesn't process capturing phase and bubbling phase.
+        // This isn't participating in a tree.
+        let prev = null;
+        while (node != null) {
+            // Remove this listener if it's once
+            if (node.once) {
+                if (prev !== null) {
+                    prev.next = node.next;
+                } else if (node.next !== null) {
+                    listeners.set(eventName, node.next);
+                } else {
+                    listeners.delete(eventName);
+                }
+            } else {
+                prev = node;
+            }
+
+            // Call this listener
+            setPassiveListener(
+                wrappedEvent,
+                node.passive ? node.listener : null
+            );
+            if (typeof node.listener === "function") {
+                try {
+                    node.listener.call(this, wrappedEvent);
+                } catch (err) {
+                    if (
+                        typeof console !== "undefined" &&
+                        typeof console.error === "function"
+                    ) {
+                        console.error(err);
+                    }
+                }
+            } else if (
+                node.listenerType !== ATTRIBUTE &&
+                typeof node.listener.handleEvent === "function"
+            ) {
+                node.listener.handleEvent(wrappedEvent);
+            }
+
+            // Break if `event.stopImmediatePropagation` was called.
+            if (isStopped(wrappedEvent)) {
+                break
+            }
+
+            node = node.next;
+        }
+        setPassiveListener(wrappedEvent, null);
+        setEventPhase(wrappedEvent, 0);
+        setCurrentTarget(wrappedEvent, null);
+
+        return !wrappedEvent.defaultPrevented
+    },
+};
+
+// `constructor` is not enumerable.
+Object.defineProperty(EventTarget.prototype, "constructor", {
+    value: EventTarget,
+    configurable: true,
+    writable: true,
+});
+
+// Ensure `eventTarget instanceof window.EventTarget` is `true`.
+if (
+    typeof window !== "undefined" &&
+    typeof window.EventTarget !== "undefined"
+) {
+    Object.setPrototypeOf(EventTarget.prototype, window.EventTarget.prototype);
+}
+
+/* harmony default export */ var event_target_shim = (EventTarget);
+
+
+// CONCATENATED MODULE: ./src/index.js
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "PairingChannel", function() { return src_PairingChannel; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_internals", function() { return _internals; });
+/* concated harmony reexport base64urlToBytes */__webpack_require__.d(__webpack_exports__, "base64urlToBytes", function() { return base64urlToBytes; });
+/* concated harmony reexport bytesToBase64url */__webpack_require__.d(__webpack_exports__, "bytesToBase64url", function() { return bytesToBase64url; });
+/* concated harmony reexport bytesToHex */__webpack_require__.d(__webpack_exports__, "bytesToHex", function() { return bytesToHex; });
+/* concated harmony reexport bytesToUtf8 */__webpack_require__.d(__webpack_exports__, "bytesToUtf8", function() { return bytesToUtf8; });
+/* concated harmony reexport hexToBytes */__webpack_require__.d(__webpack_exports__, "hexToBytes", function() { return hexToBytes; });
+/* concated harmony reexport TLSCloseNotify */__webpack_require__.d(__webpack_exports__, "TLSCloseNotify", function() { return TLSCloseNotify; });
+/* concated harmony reexport TLSError */__webpack_require__.d(__webpack_exports__, "TLSError", function() { return TLSError; });
+/* concated harmony reexport utf8ToBytes */__webpack_require__.d(__webpack_exports__, "utf8ToBytes", function() { return utf8ToBytes; });
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// A wrapper that combines a WebSocket to the channelserver
+// with some client-side encryption for securing the channel.
+// We'll improve the encryption before initial release...
+
+
+
+
+
+
+
+const CLOSE_FLUSH_BUFFER_INTERVAL_MS = 200;
+const CLOSE_FLUSH_BUFFER_MAX_TRIES = 5;
+
+class src_PairingChannel extends EventTarget {
+  constructor(channelId, channelKey, socket, connection) {
+    super();
+    this._channelId = channelId;
+    this._channelKey = channelKey;
+    this._socket = socket;
+    this._connection = connection;
+    this._selfClosed = false;
+    this._peerClosed = false;
+    this._setupListeners();
+  }
+
+  /**
+   * Create a new pairing channel.
+   *
+   * @returns Promise<PairingChannel>
+   */
+  static create(channelServerURI) {
+    const wsURI = new URL('/v1/ws/', channelServerURI).href;
+    const channelKey = crypto.getRandomValues(new Uint8Array(32));
+    return this._makePairingChannel(wsURI, tlsconnection_ServerConnection, channelKey);
+  }
+
+  /**
+   * Connect to an existing pairing channel.
+   *
+   * @returns Promise<PairingChannel>
+   */
+  static connect(channelServerURI, channelId, channelKey) {
+    const wsURI = new URL(`/v1/ws/${channelId}`, channelServerURI).href;
+    return this._makePairingChannel(wsURI, tlsconnection_ClientConnection, channelKey);
+  }
+
+  static _makePairingChannel(wsUri, ConnectionClass, psk) {
+    const socket = new WebSocket(wsUri);
+    return new Promise((resolve, reject) => {
+      // eslint-disable-next-line prefer-const
+      let stopListening;
+      const onConnectionError = async () => {
+        stopListening();
+        reject(new Error('Error while creating the pairing channel'));
+      };
+      const onFirstMessage = async event => {
+        stopListening();
+        try {
+          const {channelid: channelId} = JSON.parse(event.data);
+          const pskId = utf8ToBytes(channelId);
+          const connection = await ConnectionClass.create(psk, pskId, data => {
+            // The channelserver websocket handler epxects b64urlsafe strings
+            // rather than raw bytes, because it wraps them in a JSON object envelope.
+            socket.send(bytesToBase64url(data));
+          });
+          const instance = new this(channelId, psk, socket, connection);
+          resolve(instance);
+        } catch (err) {
+          reject(err);
+        }
+      };
+      stopListening = () => {
+        socket.removeEventListener('error', onConnectionError);
+        socket.removeEventListener('message', onFirstMessage);
+      };
+      socket.addEventListener('error', onConnectionError);
+      socket.addEventListener('message', onFirstMessage);
+    });
+  }
+
+  _setupListeners() {
+    this._socket.addEventListener('message', async event => {
+      try {
+        const channelServerEnvelope = JSON.parse(event.data);
+        const payload = await this._connection.recv(base64urlToBytes(channelServerEnvelope.message));
+        if (payload !== null) {
+          const data = JSON.parse(bytesToUtf8(payload));
+          this.dispatchEvent(new CustomEvent('message', {
+            detail: {
+              data,
+              sender: channelServerEnvelope.sender,
+            },
+          }));
+        }
+      } catch (error) {
+        let event;
+        if (error instanceof TLSCloseNotify) {
+          this._peerClosed = true;
+          if (this._selfClosed) {
+            this._shutdown();
+          }
+          event = new CustomEvent('close');
+        } else {
+          event = new CustomEvent('error', {
+            detail: {
+              error,
+            }
+          });
+        }
+        this.dispatchEvent(event);
+      }
+    });
+    // Relay the WebSocket events.
+    this._socket.addEventListener('error', () => {
+      this._shutdown();
+      // The dispatched event that we receive has no useful information.
+      this.dispatchEvent(new CustomEvent('error', {
+        detail: {
+          error: new Error('WebSocket error.'),
+        },
+      }));
+    });
+    // In TLS, the peer has to explicitly send a close notification,
+    // which we dispatch above.  Unexpected socket close is an error.
+    this._socket.addEventListener('close', () => {
+      this._shutdown();
+      if (! this._peerClosed) {
+        this.dispatchEvent(new CustomEvent('error', {
+          detail: {
+            error: new Error('WebSocket unexpectedly closed'),
+          }
+        }));
+      }
+    });
+  }
+
+  /**
+   * @param {Object} data
+   */
+  async send(data) {
+    const payload = utf8ToBytes(JSON.stringify(data));
+    await this._connection.send(payload);
+  }
+
+  async close() {
+    this._selfClosed = true;
+    await this._connection.close();
+    try {
+      // Ensure all queued bytes have been sent before closing the connection.
+      let tries = 0;
+      while (this._socket.bufferedAmount > 0) {
+        if (++tries > CLOSE_FLUSH_BUFFER_MAX_TRIES) {
+          throw new Error('Could not flush the outgoing buffer in time.');
+        }
+        await new Promise(res => setTimeout(res, CLOSE_FLUSH_BUFFER_INTERVAL_MS));
+      }
+    } finally {
+      // If the peer hasn't closed, we might still receive some data.
+      if (this._peerClosed) {
+        this._shutdown();
+      }
+    }
+  }
+
+  _shutdown() {
+    if (this._socket) {
+      this._socket.close();
+      this._socket = null;
+      this._connection = null;
+    }
+  }
+
+  get closed() {
+    return (! this._socket) || (this._socket.readyState === 3);
+  }
+
+  get channelId() {
+    return this._channelId;
+  }
+
+  get channelKey() {
+    return this._channelKey;
+  }
+}
+
+// Re-export helpful utilities for calling code to use.
+
+
+// For running tests using the built bundle,
+// expose a bunch of implementation details.
+
+
+
+
+
+
+
+const _internals = {
+  arrayToBytes: arrayToBytes,
+  BufferReader: utils_BufferReader,
+  BufferWriter: utils_BufferWriter,
+  bytesAreEqual: bytesAreEqual,
+  bytesToHex: bytesToHex,
+  bytesToUtf8: bytesToUtf8,
+  ClientConnection: tlsconnection_ClientConnection,
+  Connection: tlsconnection_Connection,
+  DecryptionState: recordlayer_DecryptionState,
+  EncryptedExtensions: EncryptedExtensions,
+  EncryptionState: recordlayer_EncryptionState,
+  Finished: messages_Finished,
+  HASH_LENGTH: HASH_LENGTH,
+  hexToBytes: hexToBytes,
+  hkdfExpand: hkdfExpand,
+  KeySchedule: keyschedule_KeySchedule,
+  NewSessionTicket: messages_NewSessionTicket,
+  RecordLayer: recordlayer_RecordLayer,
+  ServerConnection: tlsconnection_ServerConnection,
+  utf8ToBytes: utf8ToBytes,
+  zeros: zeros,
+};
+
+
+/***/ })
+/******/ ])["PairingChannel"];
\ No newline at end of file
--- a/services/fxaccounts/FxAccountsProfileClient.jsm
+++ b/services/fxaccounts/FxAccountsProfileClient.jsm
@@ -4,17 +4,17 @@
 
 /**
  * A client to fetch profile information for a Firefox Account.
  */
  "use strict;";
 
 var EXPORTED_SYMBOLS = ["FxAccountsProfileClient", "FxAccountsProfileClientError"];
 
-const {ERRNO_NETWORK, ERRNO_PARSE, ERRNO_UNKNOWN_ERROR, ERROR_CODE_METHOD_NOT_ALLOWED, ERROR_MSG_METHOD_NOT_ALLOWED, ERROR_NETWORK, ERROR_PARSE, ERROR_UNKNOWN, log} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
+const {ERRNO_NETWORK, ERRNO_PARSE, ERRNO_UNKNOWN_ERROR, ERROR_CODE_METHOD_NOT_ALLOWED, ERROR_MSG_METHOD_NOT_ALLOWED, ERROR_NETWORK, ERROR_PARSE, ERROR_UNKNOWN, log, SCOPE_PROFILE} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
 const {fxAccounts} = ChromeUtils.import("resource://gre/modules/FxAccounts.jsm");
 const {RESTRequest} = ChromeUtils.import("resource://services-common/rest.js");
 const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
 
 /**
  * Create a new FxAccountsProfileClient to be able to fetch Firefox Account profile information.
@@ -42,17 +42,17 @@ var FxAccountsProfileClient = function(o
   this.token = options.token;
 
   try {
     this.serverURL = new URL(options.serverURL);
   } catch (e) {
     throw new Error("Invalid 'serverURL'");
   }
   this.oauthOptions = {
-    scope: "profile",
+    scope: SCOPE_PROFILE,
   };
   log.debug("FxAccountsProfileClient: Initialized");
 };
 
 this.FxAccountsProfileClient.prototype = {
   /**
    * {nsIURI}
    * The server to fetch profile information from.
--- a/services/fxaccounts/FxAccountsWebChannel.jsm
+++ b/services/fxaccounts/FxAccountsWebChannel.jsm
@@ -8,41 +8,36 @@
  *
  * Uses the WebChannel component to receive messages
  * about account state changes.
  */
 
 var EXPORTED_SYMBOLS = ["EnsureFxAccountsWebChannel"];
 
 const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
-const {ON_PROFILE_CHANGE_NOTIFICATION, PREF_LAST_FXA_USER, WEBCHANNEL_ID, log, logPII} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
+const {COMMAND_PROFILE_CHANGE, COMMAND_LOGIN, COMMAND_LOGOUT, COMMAND_DELETE, COMMAND_CAN_LINK_ACCOUNT, COMMAND_SYNC_PREFERENCES, COMMAND_CHANGE_PASSWORD, COMMAND_FXA_STATUS, COMMAND_PAIR_HEARTBEAT, COMMAND_PAIR_SUPP_METADATA, COMMAND_PAIR_AUTHORIZE, COMMAND_PAIR_DECLINE, COMMAND_PAIR_COMPLETE, COMMAND_PAIR_PREFERENCES, ON_PROFILE_CHANGE_NOTIFICATION, PREF_LAST_FXA_USER, WEBCHANNEL_ID, log, logPII} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
 
 ChromeUtils.defineModuleGetter(this, "Services",
                                "resource://gre/modules/Services.jsm");
 ChromeUtils.defineModuleGetter(this, "WebChannel",
                                "resource://gre/modules/WebChannel.jsm");
 ChromeUtils.defineModuleGetter(this, "fxAccounts",
                                "resource://gre/modules/FxAccounts.jsm");
 ChromeUtils.defineModuleGetter(this, "FxAccountsStorageManagerCanStoreField",
                                "resource://gre/modules/FxAccountsStorage.jsm");
 ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
                                "resource://gre/modules/PrivateBrowsingUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "Weave",
                                "resource://services-sync/main.js");
 ChromeUtils.defineModuleGetter(this, "CryptoUtils",
                                "resource://services-crypto/utils.js");
-
-const COMMAND_PROFILE_CHANGE       = "profile:change";
-const COMMAND_CAN_LINK_ACCOUNT     = "fxaccounts:can_link_account";
-const COMMAND_LOGIN                = "fxaccounts:login";
-const COMMAND_LOGOUT               = "fxaccounts:logout";
-const COMMAND_DELETE               = "fxaccounts:delete";
-const COMMAND_SYNC_PREFERENCES     = "fxaccounts:sync_preferences";
-const COMMAND_CHANGE_PASSWORD      = "fxaccounts:change_password";
-const COMMAND_FXA_STATUS           = "fxaccounts:fxa_status";
+ChromeUtils.defineModuleGetter(this, "FxAccountsPairingFlow",
+  "resource://gre/modules/FxAccountsPairing.jsm");
+XPCOMUtils.defineLazyPreferenceGetter(this, "pairingEnabled",
+  "identity.fxaccounts.pairing.enabled");
 
 // These engines were added years after Sync had been introduced, they need
 // special handling since they are system add-ons and are un-available on
 // older versions of Firefox.
 const EXTRA_ENGINES = ["addresses", "creditcards"];
 
 /**
  * A helper function that extracts the message and stack from an error object.
@@ -140,18 +135,17 @@ this.FxAccountsWebChannel.prototype = {
       this._registerChannel();
     } catch (e) {
       log.error(e);
       throw e;
     }
   },
 
   _receiveMessage(message, sendingContext) {
-    let command = message.command;
-    let data = message.data;
+    const {command, data} = message;
 
     switch (command) {
       case COMMAND_PROFILE_CHANGE:
         Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, data.uid);
         break;
       case COMMAND_LOGIN:
         this._helpers.login(data).catch(error =>
           this._sendError(error, message, sendingContext));
@@ -171,16 +165,23 @@ this.FxAccountsWebChannel.prototype = {
         };
 
         log.debug("FxAccountsWebChannel response", response);
         this._channel.send(response, sendingContext);
         break;
       case COMMAND_SYNC_PREFERENCES:
         this._helpers.openSyncPreferences(sendingContext.browser, data.entryPoint);
         break;
+      case COMMAND_PAIR_PREFERENCES:
+        if (pairingEnabled) {
+          sendingContext.browser.loadURI("about:preferences?action=pair#sync", {
+            triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+          });
+        }
+        break;
       case COMMAND_CHANGE_PASSWORD:
         this._helpers.changePassword(data).catch(error =>
           this._sendError(error, message, sendingContext));
         break;
       case COMMAND_FXA_STATUS:
         log.debug("fxa_status received");
 
         const service = data && data.service;
@@ -191,18 +192,41 @@ this.FxAccountsWebChannel.prototype = {
               messageId: message.messageId,
               data: fxaStatus,
             };
             this._channel.send(response, sendingContext);
           }).catch(error =>
             this._sendError(error, message, sendingContext)
           );
         break;
+      case COMMAND_PAIR_HEARTBEAT:
+      case COMMAND_PAIR_SUPP_METADATA:
+      case COMMAND_PAIR_AUTHORIZE:
+      case COMMAND_PAIR_DECLINE:
+      case COMMAND_PAIR_COMPLETE:
+        log.debug(`Pairing command ${command} received`);
+        const {channel_id: channelId} = data;
+        delete data.channel_id;
+        const flow = FxAccountsPairingFlow.get(channelId);
+        if (!flow) {
+          log.warn(`Could not find a pairing flow for ${channelId}`);
+          return;
+        }
+        flow.onWebChannelMessage(command, data).then(replyData => {
+          this._channel.send({
+            command,
+            messageId: message.messageId,
+            data: replyData,
+          }, sendingContext);
+        });
+        break;
       default:
         log.warn("Unrecognized FxAccountsWebChannel command", command);
+        // As a safety measure we also terminate any pending FxA pairing flow.
+        FxAccountsPairingFlow.finalizeAll();
         break;
     }
   },
 
   _sendError(error, incomingMessage, sendingContext) {
     log.error("Failed to handle FxAccountsWebChannel message", error);
     this._channel.send({
       command: incomingMessage.command,
@@ -395,16 +419,17 @@ this.FxAccountsWebChannelHelpers.prototy
           verified: userData.verified,
         };
       }
     }
 
     return {
       signedInUser,
       capabilities: {
+        pairing: pairingEnabled,
         engines: this._getAvailableExtraEngines(),
       },
     };
   },
 
   _getAvailableExtraEngines() {
     return EXTRA_ENGINES.filter(engineName => {
       try {
@@ -474,17 +499,19 @@ this.FxAccountsWebChannelHelpers.prototy
    */
   openSyncPreferences(browser, entryPoint) {
     let uri = "about:preferences";
     if (entryPoint) {
       uri += "?entrypoint=" + encodeURIComponent(entryPoint);
     }
     uri += "#sync";
 
-    browser.loadURI(uri);
+    browser.loadURI(uri, {
+      triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+    });
   },
 
   /**
    * If a user signs in using a different account, the data from the
    * previous account and the new account will be merged. Ask the user
    * if they want to continue.
    *
    * @private
--- a/services/fxaccounts/moz.build
+++ b/services/fxaccounts/moz.build
@@ -18,16 +18,18 @@ XPCSHELL_TESTS_MANIFESTS += ['tests/xpcs
 EXTRA_JS_MODULES += [
   'Credentials.jsm',
   'FxAccounts.jsm',
   'FxAccountsClient.jsm',
   'FxAccountsCommands.js',
   'FxAccountsCommon.js',
   'FxAccountsConfig.jsm',
   'FxAccountsOAuthGrantClient.jsm',
+  'FxAccountsPairing.jsm',
+  'FxAccountsPairingChannel.js',
   'FxAccountsProfile.jsm',
   'FxAccountsProfileClient.jsm',
   'FxAccountsPush.jsm',
   'FxAccountsStorage.jsm',
   'FxAccountsWebChannel.jsm',
 ]
 
 XPCOM_MANIFESTS += [
--- a/services/fxaccounts/tests/xpcshell/test_accounts.js
+++ b/services/fxaccounts/tests/xpcshell/test_accounts.js
@@ -1,16 +1,16 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 const {FxAccounts} = ChromeUtils.import("resource://gre/modules/FxAccounts.jsm");
 const {FxAccountsClient} = ChromeUtils.import("resource://gre/modules/FxAccountsClient.jsm");
-const {ASSERTION_LIFETIME, CERT_LIFETIME, ERRNO_INVALID_AUTH_TOKEN, ERRNO_INVALID_FXA_ASSERTION, ERRNO_NETWORK, ERROR_INVALID_FXA_ASSERTION, ERROR_NETWORK, KEY_LIFETIME, ONLOGIN_NOTIFICATION, ONLOGOUT_NOTIFICATION, ONVERIFIED_NOTIFICATION} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
+const {ASSERTION_LIFETIME, CERT_LIFETIME, ERRNO_INVALID_AUTH_TOKEN, ERRNO_INVALID_FXA_ASSERTION, ERRNO_NETWORK, ERROR_INVALID_FXA_ASSERTION, ERROR_NETWORK, KEY_LIFETIME, ONLOGIN_NOTIFICATION, ONLOGOUT_NOTIFICATION, ONVERIFIED_NOTIFICATION, SCOPE_OLD_SYNC} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
 const {FxAccountsOAuthGrantClient, FxAccountsOAuthGrantClientError} = ChromeUtils.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
 const {PromiseUtils} = ChromeUtils.import("resource://gre/modules/PromiseUtils.jsm");
 
 // We grab some additional stuff via backstage passes.
 var {AccountState} = ChromeUtils.import("resource://gre/modules/FxAccounts.jsm", null);
 
 const ONE_HOUR_MS = 1000 * 60 * 60;
 const ONE_DAY_MS = ONE_HOUR_MS * 24;
@@ -734,16 +734,79 @@ add_task(async function test_getKeys_inv
     Assert.equal(err.errno, ERRNO_INVALID_AUTH_TOKEN);
   }
 
   let user = await fxa.internal.getUserAccountData();
   Assert.equal(user.email, yusuf.email);
   Assert.equal(user.keyFetchToken, null);
   await fxa.internal.abortExistingFlow();
 });
+
+// This is the exact same test vectors as
+// https://github.com/mozilla/fxa-crypto-relier/blob/f94f441159029a645a474d4b6439c38308da0bb0/test/deriver/ScopedKeys.js#L58
+add_task(async function test_getScopedKeys_oldsync() {
+  let fxa = new MockFxAccounts();
+  let client = fxa.internal.fxAccountsClient;
+  client.getScopedKeyData = () => Promise.resolve({
+    "https://identity.mozilla.com/apps/oldsync": {
+      "identifier": "https://identity.mozilla.com/apps/oldsync",
+      "keyRotationSecret": "0000000000000000000000000000000000000000000000000000000000000000",
+      "keyRotationTimestamp": 1510726317123,
+    },
+  });
+  let user = {
+    ...getTestUser("eusebius"),
+    uid: "aeaa1725c7a24ff983c6295725d5fc9b",
+    verified: true,
+    kSync: "0d6fe59791b05fa489e463ea25502e3143f6b7a903aa152e95cd9c6eddbac5b4dc68a19097ef65dbd147010ee45222444e66b8b3d7c8a441ebb7dd3dce015a9e",
+    kXCS: "22a42fe289dced5715135913424cb23b",
+    kExtSync: "baded53eb3587d7900e604e8a68d860abf9de30b5c955d3c4d5dba63f26fd88265cd85923f6e9dcd16aef3b82bc88039a89c59ecd9e88de09a7418c7d94f90c9",
+    kExtKbHash: "b776a89db29f22daedd154b44ff88397d0b210223fb956f5a749521dd8de8ddf",
+  };
+  await fxa.setSignedInUser(user);
+  const keys = await fxa.internal.getScopedKeys(`${SCOPE_OLD_SYNC} profile`, "123456789a");
+  Assert.deepEqual(keys, {
+    [SCOPE_OLD_SYNC]: {
+      "scope": SCOPE_OLD_SYNC,
+      "kid": "1510726317123-IqQv4onc7VcVE1kTQkyyOw",
+      "k": "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang",
+      "kty": "oct",
+    },
+  });
+});
+
+add_task(async function test_getScopedKeys_unavailable_key() {
+  let fxa = new MockFxAccounts();
+  let client = fxa.internal.fxAccountsClient;
+  client.getScopedKeyData = () => Promise.resolve({
+    "https://identity.mozilla.com/apps/oldsync": {
+      "identifier": "https://identity.mozilla.com/apps/oldsync",
+      "keyRotationSecret": "0000000000000000000000000000000000000000000000000000000000000000",
+      "keyRotationTimestamp": 1510726317123,
+    },
+    "otherkeybearingscope": {
+      "identifier": "otherkeybearingscope",
+      "keyRotationSecret": "0000000000000000000000000000000000000000000000000000000000000000",
+      "keyRotationTimestamp": 1510726331712,
+    },
+  });
+  let user = {
+    ...getTestUser("eusebius"),
+    uid: "aeaa1725c7a24ff983c6295725d5fc9b",
+    verified: true,
+    kSync: "0d6fe59791b05fa489e463ea25502e3143f6b7a903aa152e95cd9c6eddbac5b4dc68a19097ef65dbd147010ee45222444e66b8b3d7c8a441ebb7dd3dce015a9e",
+    kXCS: "22a42fe289dced5715135913424cb23b",
+    kExtSync: "baded53eb3587d7900e604e8a68d860abf9de30b5c955d3c4d5dba63f26fd88265cd85923f6e9dcd16aef3b82bc88039a89c59ecd9e88de09a7418c7d94f90c9",
+    kExtKbHash: "b776a89db29f22daedd154b44ff88397d0b210223fb956f5a749521dd8de8ddf",
+  };
+  await fxa.setSignedInUser(user);
+  await Assert.rejects(fxa.internal.getScopedKeys(`${SCOPE_OLD_SYNC} otherkeybearingscope profile`, "123456789a"),
+    /Unavailable key material for otherkeybearingscope/);
+});
+
 //  fetchAndUnwrapKeys with no keyFetchToken should trigger signOut
 add_test(function test_fetchAndUnwrapKeys_no_token() {
   let fxa = new MockFxAccounts();
   let user = getTestUser("lettuce.protheroe");
   delete user.keyFetchToken;
 
   makeObserver(ONLOGOUT_NOTIFICATION, function() {
     log.debug("test_fetchAndUnwrapKeys_no_token observed logout");
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_pairing.js
@@ -0,0 +1,244 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {FxAccountsPairingFlow} = ChromeUtils.import("resource://gre/modules/FxAccountsPairing.jsm", {});
+const {EventEmitter} = ChromeUtils.import("resource://gre/modules/EventEmitter.jsm", {});
+const {PromiseUtils} = ChromeUtils.import("resource://gre/modules/PromiseUtils.jsm", {});
+const {CryptoUtils} = ChromeUtils.import("resource://services-crypto/utils.js", {});
+XPCOMUtils.defineLazyModuleGetters(this, {
+  jwcrypto: "resource://services-crypto/jwcrypto.jsm",
+});
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL", "crypto"]);
+
+const CHANNEL_ID = "sW-UA97Q6Dljqen7XRlYPw";
+const CHANNEL_KEY = crypto.getRandomValues(new Uint8Array(32));
+
+const SENDER_SUPP = {
+  ua: "Firefox Supp",
+  city: "Nice",
+  region: "PACA",
+  country: "France",
+  remote: "127.0.0.1",
+};
+const UID = "abcd";
+const EMAIL = "foo@bar.com";
+const AVATAR = "https://foo.bar/avatar";
+const DISPLAY_NAME = "Foo bar";
+const DEVICE_NAME = "Foo's computer";
+
+const PAIR_URI = "https://foo.bar/pair";
+const OAUTH_URI = "https://foo.bar/oauth";
+const KSYNC = "myksync";
+const fxaConfig = {
+  promisePairingURI() { return PAIR_URI; },
+  promiseOAuthURI() { return OAUTH_URI; },
+};
+const fxAccounts = {
+  getScopedKeys(scope) {
+    return {
+      [scope]: {
+        kid: "123456",
+        k: KSYNC,
+        kty: "oct",
+      },
+    };
+  },
+  authorizeOAuthCode() {
+    return {code: "mycode", state: "mystate"};
+  },
+  getSignedInUserProfile() {
+    return {
+      uid: UID,
+      email: EMAIL,
+      avatar: AVATAR,
+      displayName: DISPLAY_NAME,
+    };
+  },
+};
+const weave = {
+  Service: { clientsEngine: { localName: DEVICE_NAME } },
+};
+
+class MockPairingChannel extends EventTarget {
+  get channelId() {
+    return CHANNEL_ID;
+  }
+
+  get channelKey() {
+    return CHANNEL_KEY;
+  }
+
+  send(data) {
+    this.dispatchEvent(new CustomEvent("send", {
+      detail: { data },
+    }));
+  }
+
+  simulateIncoming(data) {
+    this.dispatchEvent(new CustomEvent("message", {
+      detail: { data, sender: SENDER_SUPP },
+    }));
+  }
+
+  close() {
+    this.closed = true;
+  }
+}
+
+add_task(async function testFullFlow() {
+  const emitter = new EventEmitter();
+  const pairingChannel = new MockPairingChannel();
+  const pairingUri = await FxAccountsPairingFlow.start({emitter, pairingChannel, fxAccounts, fxaConfig, weave});
+  Assert.equal(pairingUri, `${PAIR_URI}#channel_id=${CHANNEL_ID}&channel_key=${ChromeUtils.base64URLEncode(CHANNEL_KEY, {pad: false})}`);
+
+  const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
+
+  const promiseSwitchToWebContent = emitter.once("view:SwitchToWebContent");
+  const promiseMetadataSent = promiseOutgoingMessage(pairingChannel);
+  const epk = await generateEphemeralKeypair();
+  pairingChannel.simulateIncoming({
+    message: "pair:supp:request",
+    data: {
+      client_id: "client_id_1",
+      state: "mystate",
+      keys_jwk: ChromeUtils.base64URLEncode(new TextEncoder().encode(JSON.stringify(epk.publicJWK)), {pad: false}),
+      scope: "profile https://identity.mozilla.com/apps/oldsync",
+      code_challenge: "chal",
+      code_challenge_method: "S256",
+    },
+  });
+  const sentAuthMetadata = await promiseMetadataSent;
+  Assert.deepEqual(sentAuthMetadata, {
+    message: "pair:auth:metadata",
+    data: {email: EMAIL, avatar: AVATAR, displayName: DISPLAY_NAME, deviceName: DEVICE_NAME},
+  });
+  const oauthUrl = await promiseSwitchToWebContent;
+  Assert.equal(oauthUrl, `${OAUTH_URI}?client_id=client_id_1&scope=profile+https%3A%2F%2Fidentity.mozilla.com%2Fapps%2Foldsync&email=foo%40bar.com&uid=abcd&channel_id=${CHANNEL_ID}&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob%3Apair-auth-webchannel`);
+
+  let pairSuppMetadata = await simulateIncomingWebChannel(flow, "fxaccounts:pair_supplicant_metadata");
+  Assert.deepEqual({
+    ua: "Firefox Supp",
+    city: "Nice",
+    region: "PACA",
+    country: "France",
+    ipAddress: "127.0.0.1",
+  }, pairSuppMetadata);
+
+  const authorizeOAuthCode = sinon.spy(fxAccounts, "authorizeOAuthCode");
+  const promiseOAuthParamsMsg = promiseOutgoingMessage(pairingChannel);
+  await simulateIncomingWebChannel(flow, "fxaccounts:pair_authorize");
+  Assert.ok(authorizeOAuthCode.calledOnce);
+  const oauthCodeArgs = authorizeOAuthCode.firstCall.args[0];
+  Assert.equal(oauthCodeArgs.keys_jwk, ChromeUtils.base64URLEncode(new TextEncoder().encode(JSON.stringify(epk.publicJWK)), {pad: false}));
+  Assert.equal(oauthCodeArgs.client_id, "client_id_1");
+  Assert.equal(oauthCodeArgs.access_type, "offline");
+  Assert.equal(oauthCodeArgs.state, "mystate");
+  Assert.equal(oauthCodeArgs.scope, "profile https://identity.mozilla.com/apps/oldsync");
+  Assert.equal(oauthCodeArgs.code_challenge, "chal");
+  Assert.equal(oauthCodeArgs.code_challenge_method, "S256");
+  const oAuthParams = await promiseOAuthParamsMsg;
+  Assert.deepEqual(oAuthParams, {
+    "message": "pair:auth:authorize",
+    "data": {"code": "mycode", "state": "mystate"},
+  });
+  let heartbeat = await simulateIncomingWebChannel(flow, "fxaccounts:pair_heartbeat");
+  Assert.ok(!heartbeat.suppAuthorized);
+  await pairingChannel.simulateIncoming({
+    message: "pair:supp:authorize",
+  });
+  heartbeat = await simulateIncomingWebChannel(flow, "fxaccounts:pair_heartbeat");
+  Assert.ok(heartbeat.suppAuthorized);
+  await simulateIncomingWebChannel(flow, "fxaccounts:pair_complete");
+  // The flow should have been destroyed!
+  Assert.ok(!FxAccountsPairingFlow.get(CHANNEL_ID));
+  Assert.ok(pairingChannel.closed);
+  fxAccounts.authorizeOAuthCode.restore();
+});
+
+add_task(async function testUnknownPairingMessage() {
+  const emitter = new EventEmitter();
+  const pairingChannel = new MockPairingChannel();
+  await FxAccountsPairingFlow.start({emitter, pairingChannel, fxAccounts, fxaConfig, weave});
+  const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
+  const viewErrorObserved = emitter.once("view:Error");
+  pairingChannel.simulateIncoming({
+    message: "pair:boom",
+  });
+  await viewErrorObserved;
+  let heartbeat = await simulateIncomingWebChannel(flow, "fxaccounts:pair_heartbeat");
+  Assert.ok(heartbeat.err);
+});
+
+add_task(async function testUnknownWebChannelCommand() {
+  const emitter = new EventEmitter();
+  const pairingChannel = new MockPairingChannel();
+  await FxAccountsPairingFlow.start({emitter, pairingChannel, fxAccounts, fxaConfig, weave});
+  const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
+  const viewErrorObserved = emitter.once("view:Error");
+  await simulateIncomingWebChannel(flow, "fxaccounts:boom");
+  await viewErrorObserved;
+  let heartbeat = await simulateIncomingWebChannel(flow, "fxaccounts:pair_heartbeat");
+  Assert.ok(heartbeat.err);
+});
+
+add_task(async function testPairingChannelFailure() {
+  const emitter = new EventEmitter();
+  const pairingChannel = new MockPairingChannel();
+  await FxAccountsPairingFlow.start({emitter, pairingChannel, fxAccounts, fxaConfig, weave});
+  const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
+  const viewErrorObserved = emitter.once("view:Error");
+  sinon.stub(pairingChannel, "send").callsFake(() => { throw new Error("Boom!"); });
+  pairingChannel.simulateIncoming({
+    message: "pair:supp:request",
+    data: {
+      client_id: "client_id_1",
+      state: "mystate",
+      scope: "profile https://identity.mozilla.com/apps/oldsync",
+      code_challenge: "chal",
+      code_challenge_method: "S256",
+    },
+  });
+  await viewErrorObserved;
+
+  let heartbeat = await simulateIncomingWebChannel(flow, "fxaccounts:pair_heartbeat");
+  Assert.ok(heartbeat.err);
+});
+
+add_task(async function testFlowTimeout() {
+  const emitter = new EventEmitter();
+  const pairingChannel = new MockPairingChannel();
+  const viewErrorObserved = emitter.once("view:Error");
+  await FxAccountsPairingFlow.start({emitter, pairingChannel, fxAccounts, fxaConfig, weave, flowTimeout: 1});
+  const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
+  await viewErrorObserved;
+
+  let heartbeat = await simulateIncomingWebChannel(flow, "fxaccounts:pair_heartbeat");
+  Assert.ok(heartbeat.err.match(/Timeout/));
+});
+
+async function simulateIncomingWebChannel(flow, command) {
+  return flow.onWebChannelMessage(command);
+}
+
+async function promiseOutgoingMessage(pairingChannel) {
+  return new Promise(res => {
+    const onMessage = event => {
+      pairingChannel.removeEventListener("send", onMessage);
+      res(event.detail.data);
+    };
+    pairingChannel.addEventListener("send", onMessage);
+  });
+}
+
+async function generateEphemeralKeypair() {
+  const keypair = await crypto.subtle.generateKey({name: "ECDH", namedCurve: "P-256"}, true, ["deriveKey"]);
+  const publicJWK = await crypto.subtle.exportKey("jwk", keypair.publicKey);
+  const privateJWK = await crypto.subtle.exportKey("jwk", keypair.privateKey);
+  delete publicJWK.key_ops;
+  return {
+    publicJWK,
+    privateJWK,
+  };
+}
--- a/services/fxaccounts/tests/xpcshell/xpcshell.ini
+++ b/services/fxaccounts/tests/xpcshell/xpcshell.ini
@@ -11,13 +11,14 @@ support-files =
 [test_client.js]
 [test_commands.js]
 [test_credentials.js]
 [test_loginmgr_storage.js]
 [test_oauth_grant_client.js]
 [test_oauth_grant_client_server.js]
 [test_oauth_tokens.js]
 [test_oauth_token_storage.js]
+[test_pairing.js]
 [test_profile_client.js]
 [test_push_service.js]
 [test_web_channel.js]
 [test_profile.js]
 [test_storage_manager.js]
--- a/tools/lint/eslint/modules.json
+++ b/tools/lint/eslint/modules.json
@@ -81,17 +81,17 @@
   "FormAutofillSync.jsm": ["AddressesEngine", "CreditCardsEngine"],
   "FormAutofillUtils.jsm": ["FormAutofillUtils", "AddressDataLoader"],
   "forms.js": ["FormEngine", "FormRec", "FormValidator"],
   "forms.jsm": ["FormData"],
   "FrameScriptManager.jsm": ["getNewLoaderID"],
   "fxaccounts.jsm": ["Authentication"],
   "FxAccounts.jsm": ["fxAccounts", "FxAccounts"],
   "FxAccountsCommands.js": ["SendTab", "FxAccountsCommands"],
-  "FxAccountsCommon.js": ["log", "logPII", "FXACCOUNTS_PERMISSION", "DATA_FORMAT_VERSION", "DEFAULT_STORAGE_FILENAME", "ASSERTION_LIFETIME", "ASSERTION_USE_PERIOD", "CERT_LIFETIME", "KEY_LIFETIME", "POLL_SESSION", "ONLOGIN_NOTIFICATION", "ONVERIFIED_NOTIFICATION", "ONLOGOUT_NOTIFICATION", "ON_COMMAND_RECEIVED_NOTIFICATION", "ON_DEVICE_CONNECTED_NOTIFICATION", "ON_DEVICE_DISCONNECTED_NOTIFICATION", "ON_PROFILE_UPDATED_NOTIFICATION", "ON_PASSWORD_CHANGED_NOTIFICATION", "ON_PASSWORD_RESET_NOTIFICATION", "ON_VERIFY_LOGIN_NOTIFICATION", "ON_ACCOUNT_DESTROYED_NOTIFICATION", "ON_COLLECTION_CHANGED_NOTIFICATION", "FXA_PUSH_SCOPE_ACCOUNT_UPDATE", "ON_PROFILE_CHANGE_NOTIFICATION", "ON_ACCOUNT_STATE_CHANGE_NOTIFICATION", "ON_NEW_DEVICE_ID", "COMMAND_SENDTAB", "UI_REQUEST_SIGN_IN_FLOW", "UI_REQUEST_REFRESH_AUTH", "FX_OAUTH_CLIENT_ID", "WEBCHANNEL_ID", "PREF_LAST_FXA_USER", "ERRNO_ACCOUNT_ALREADY_EXISTS", "ERRNO_ACCOUNT_DOES_NOT_EXIST", "ERRNO_INCORRECT_PASSWORD", "ERRNO_UNVERIFIED_ACCOUNT", "ERRNO_INVALID_VERIFICATION_CODE", "ERRNO_NOT_VALID_JSON_BODY", "ERRNO_INVALID_BODY_PARAMETERS", "ERRNO_MISSING_BODY_PARAMETERS", "ERRNO_INVALID_REQUEST_SIGNATURE", "ERRNO_INVALID_AUTH_TOKEN", "ERRNO_INVALID_AUTH_TIMESTAMP", "ERRNO_MISSING_CONTENT_LENGTH", "ERRNO_REQUEST_BODY_TOO_LARGE", "ERRNO_TOO_MANY_CLIENT_REQUESTS", "ERRNO_INVALID_AUTH_NONCE", "ERRNO_ENDPOINT_NO_LONGER_SUPPORTED", "ERRNO_INCORRECT_LOGIN_METHOD", "ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD", "ERRNO_INCORRECT_API_VERSION", "ERRNO_INCORRECT_EMAIL_CASE", "ERRNO_ACCOUNT_LOCKED", "ERRNO_ACCOUNT_UNLOCKED", "ERRNO_UNKNOWN_DEVICE", "ERRNO_DEVICE_SESSION_CONFLICT", "ERRNO_SERVICE_TEMP_UNAVAILABLE", "ERRNO_PARSE", "ERRNO_NETWORK", "ERRNO_UNKNOWN_ERROR", "OAUTH_SERVER_ERRNO_OFFSET", "ERRNO_UNKNOWN_CLIENT_ID", "ERRNO_INCORRECT_CLIENT_SECRET", "ERRNO_INCORRECT_REDIRECT_URI", "ERRNO_INVALID_FXA_ASSERTION", "ERRNO_UNKNOWN_CODE", "ERRNO_INCORRECT_CODE", "ERRNO_EXPIRED_CODE", "ERRNO_OAUTH_INVALID_TOKEN", "ERRNO_INVALID_REQUEST_PARAM", "ERRNO_INVALID_RESPONSE_TYPE", "ERRNO_UNAUTHORIZED", "ERRNO_FORBIDDEN", "ERRNO_INVALID_CONTENT_TYPE", "ERROR_ACCOUNT_ALREADY_EXISTS", "ERROR_ACCOUNT_DOES_NOT_EXIST", "ERROR_ACCOUNT_LOCKED", "ERROR_ACCOUNT_UNLOCKED", "ERROR_ALREADY_SIGNED_IN_USER", "ERROR_DEVICE_SESSION_CONFLICT", "ERROR_ENDPOINT_NO_LONGER_SUPPORTED", "ERROR_INCORRECT_API_VERSION", "ERROR_INCORRECT_EMAIL_CASE", "ERROR_INCORRECT_KEY_RETRIEVAL_METHOD", "ERROR_INCORRECT_LOGIN_METHOD", "ERROR_INVALID_EMAIL", "ERROR_INVALID_AUDIENCE", "ERROR_INVALID_AUTH_TOKEN", "ERROR_INVALID_AUTH_TIMESTAMP", "ERROR_INVALID_AUTH_NONCE", "ERROR_INVALID_BODY_PARAMETERS", "ERROR_INVALID_PASSWORD", "ERROR_INVALID_VERIFICATION_CODE", "ERROR_INVALID_REFRESH_AUTH_VALUE", "ERROR_INVALID_REQUEST_SIGNATURE", "ERROR_INTERNAL_INVALID_USER", "ERROR_MISSING_BODY_PARAMETERS", "ERROR_MISSING_CONTENT_LENGTH", "ERROR_NO_TOKEN_SESSION", "ERROR_NO_SILENT_REFRESH_AUTH", "ERROR_NOT_VALID_JSON_BODY", "ERROR_OFFLINE", "ERROR_PERMISSION_DENIED", "ERROR_REQUEST_BODY_TOO_LARGE", "ERROR_SERVER_ERROR", "ERROR_SYNC_DISABLED", "ERROR_TOO_MANY_CLIENT_REQUESTS", "ERROR_SERVICE_TEMP_UNAVAILABLE", "ERROR_UI_ERROR", "ERROR_UI_REQUEST", "ERROR_PARSE", "ERROR_NETWORK", "ERROR_UNKNOWN", "ERROR_UNKNOWN_DEVICE", "ERROR_UNVERIFIED_ACCOUNT", "ERROR_UNKNOWN_CLIENT_ID", "ERROR_INCORRECT_CLIENT_SECRET", "ERROR_INCORRECT_REDIRECT_URI", "ERROR_INVALID_FXA_ASSERTION", "ERROR_UNKNOWN_CODE", "ERROR_INCORRECT_CODE", "ERROR_EXPIRED_CODE", "ERROR_OAUTH_INVALID_TOKEN", "ERROR_INVALID_REQUEST_PARAM", "ERROR_INVALID_RESPONSE_TYPE", "ERROR_UNAUTHORIZED", "ERROR_FORBIDDEN", "ERROR_INVALID_CONTENT_TYPE", "ERROR_NO_ACCOUNT", "ERROR_AUTH_ERROR", "ERROR_INVALID_PARAMETER", "ERROR_CODE_METHOD_NOT_ALLOWED", "ERROR_MSG_METHOD_NOT_ALLOWED", "DERIVED_KEYS_NAMES", "FXA_PWDMGR_PLAINTEXT_FIELDS", "FXA_PWDMGR_SECURE_FIELDS", "FXA_PWDMGR_MEMORY_FIELDS", "FXA_PWDMGR_REAUTH_WHITELIST", "FXA_PWDMGR_HOST", "FXA_PWDMGR_REALM", "SERVER_ERRNO_TO_ERROR", "ERROR_TO_GENERAL_ERROR_CLASS"],
+  "FxAccountsCommon.js": ["log", "logPII", "FXACCOUNTS_PERMISSION", "DATA_FORMAT_VERSION", "DEFAULT_STORAGE_FILENAME", "ASSERTION_LIFETIME", "ASSERTION_USE_PERIOD", "CERT_LIFETIME", "KEY_LIFETIME", "POLL_SESSION", "ONLOGIN_NOTIFICATION", "ONVERIFIED_NOTIFICATION", "ONLOGOUT_NOTIFICATION", "ON_COMMAND_RECEIVED_NOTIFICATION", "ON_DEVICE_CONNECTED_NOTIFICATION", "ON_DEVICE_DISCONNECTED_NOTIFICATION", "ON_PROFILE_UPDATED_NOTIFICATION", "ON_PASSWORD_CHANGED_NOTIFICATION", "ON_PASSWORD_RESET_NOTIFICATION", "ON_VERIFY_LOGIN_NOTIFICATION", "ON_ACCOUNT_DESTROYED_NOTIFICATION", "ON_COLLECTION_CHANGED_NOTIFICATION", "FXA_PUSH_SCOPE_ACCOUNT_UPDATE", "ON_PROFILE_CHANGE_NOTIFICATION", "ON_ACCOUNT_STATE_CHANGE_NOTIFICATION", "ON_NEW_DEVICE_ID", "COMMAND_SENDTAB", "SCOPE_PROFILE", "SCOPE_OLD_SYNC", "UI_REQUEST_SIGN_IN_FLOW", "UI_REQUEST_REFRESH_AUTH", "FX_OAUTH_CLIENT_ID", "WEBCHANNEL_ID", "COMMAND_PAIR_HEARTBEAT", "COMMAND_PAIR_SUPP_METADATA", "COMMAND_PAIR_AUTHORIZE", "COMMAND_PAIR_DECLINE", "COMMAND_PAIR_COMPLETE", "COMMAND_PAIR_PREFERENCES", "COMMAND_PROFILE_CHANGE", "COMMAND_CAN_LINK_ACCOUNT", "COMMAND_LOGIN", "COMMAND_LOGOUT", "COMMAND_DELETE", "COMMAND_SYNC_PREFERENCES", "COMMAND_CHANGE_PASSWORD", "COMMAND_FXA_STATUS", "PREF_LAST_FXA_USER", "PREF_REMOTE_PAIRING_URI", "ERRNO_ACCOUNT_ALREADY_EXISTS", "ERRNO_ACCOUNT_DOES_NOT_EXIST", "ERRNO_INCORRECT_PASSWORD", "ERRNO_UNVERIFIED_ACCOUNT", "ERRNO_INVALID_VERIFICATION_CODE", "ERRNO_NOT_VALID_JSON_BODY", "ERRNO_INVALID_BODY_PARAMETERS", "ERRNO_MISSING_BODY_PARAMETERS", "ERRNO_INVALID_REQUEST_SIGNATURE", "ERRNO_INVALID_AUTH_TOKEN", "ERRNO_INVALID_AUTH_TIMESTAMP", "ERRNO_MISSING_CONTENT_LENGTH", "ERRNO_REQUEST_BODY_TOO_LARGE", "ERRNO_TOO_MANY_CLIENT_REQUESTS", "ERRNO_INVALID_AUTH_NONCE", "ERRNO_ENDPOINT_NO_LONGER_SUPPORTED", "ERRNO_INCORRECT_LOGIN_METHOD", "ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD", "ERRNO_INCORRECT_API_VERSION", "ERRNO_INCORRECT_EMAIL_CASE", "ERRNO_ACCOUNT_LOCKED", "ERRNO_ACCOUNT_UNLOCKED", "ERRNO_UNKNOWN_DEVICE", "ERRNO_DEVICE_SESSION_CONFLICT", "ERRNO_SERVICE_TEMP_UNAVAILABLE", "ERRNO_PARSE", "ERRNO_NETWORK", "ERRNO_UNKNOWN_ERROR", "OAUTH_SERVER_ERRNO_OFFSET", "ERRNO_UNKNOWN_CLIENT_ID", "ERRNO_INCORRECT_CLIENT_SECRET", "ERRNO_INCORRECT_REDIRECT_URI", "ERRNO_INVALID_FXA_ASSERTION", "ERRNO_UNKNOWN_CODE", "ERRNO_INCORRECT_CODE", "ERRNO_EXPIRED_CODE", "ERRNO_OAUTH_INVALID_TOKEN", "ERRNO_INVALID_REQUEST_PARAM", "ERRNO_INVALID_RESPONSE_TYPE", "ERRNO_UNAUTHORIZED", "ERRNO_FORBIDDEN", "ERRNO_INVALID_CONTENT_TYPE", "ERROR_ACCOUNT_ALREADY_EXISTS", "ERROR_ACCOUNT_DOES_NOT_EXIST", "ERROR_ACCOUNT_LOCKED", "ERROR_ACCOUNT_UNLOCKED", "ERROR_ALREADY_SIGNED_IN_USER", "ERROR_DEVICE_SESSION_CONFLICT", "ERROR_ENDPOINT_NO_LONGER_SUPPORTED", "ERROR_INCORRECT_API_VERSION", "ERROR_INCORRECT_EMAIL_CASE", "ERROR_INCORRECT_KEY_RETRIEVAL_METHOD", "ERROR_INCORRECT_LOGIN_METHOD", "ERROR_INVALID_EMAIL", "ERROR_INVALID_AUDIENCE", "ERROR_INVALID_AUTH_TOKEN", "ERROR_INVALID_AUTH_TIMESTAMP", "ERROR_INVALID_AUTH_NONCE", "ERROR_INVALID_BODY_PARAMETERS", "ERROR_INVALID_PASSWORD", "ERROR_INVALID_VERIFICATION_CODE", "ERROR_INVALID_REFRESH_AUTH_VALUE", "ERROR_INVALID_REQUEST_SIGNATURE", "ERROR_INTERNAL_INVALID_USER", "ERROR_MISSING_BODY_PARAMETERS", "ERROR_MISSING_CONTENT_LENGTH", "ERROR_NO_TOKEN_SESSION", "ERROR_NO_SILENT_REFRESH_AUTH", "ERROR_NOT_VALID_JSON_BODY", "ERROR_OFFLINE", "ERROR_PERMISSION_DENIED", "ERROR_REQUEST_BODY_TOO_LARGE", "ERROR_SERVER_ERROR", "ERROR_SYNC_DISABLED", "ERROR_TOO_MANY_CLIENT_REQUESTS", "ERROR_SERVICE_TEMP_UNAVAILABLE", "ERROR_UI_ERROR", "ERROR_UI_REQUEST", "ERROR_PARSE", "ERROR_NETWORK", "ERROR_UNKNOWN", "ERROR_UNKNOWN_DEVICE", "ERROR_UNVERIFIED_ACCOUNT", "ERROR_UNKNOWN_CLIENT_ID", "ERROR_INCORRECT_CLIENT_SECRET", "ERROR_INCORRECT_REDIRECT_URI", "ERROR_INVALID_FXA_ASSERTION", "ERROR_UNKNOWN_CODE", "ERROR_INCORRECT_CODE", "ERROR_EXPIRED_CODE", "ERROR_OAUTH_INVALID_TOKEN", "ERROR_INVALID_REQUEST_PARAM", "ERROR_INVALID_RESPONSE_TYPE", "ERROR_UNAUTHORIZED", "ERROR_FORBIDDEN", "ERROR_INVALID_CONTENT_TYPE", "ERROR_NO_ACCOUNT", "ERROR_AUTH_ERROR", "ERROR_INVALID_PARAMETER", "ERROR_CODE_METHOD_NOT_ALLOWED", "ERROR_MSG_METHOD_NOT_ALLOWED", "DERIVED_KEYS_NAMES", "FXA_PWDMGR_PLAINTEXT_FIELDS", "FXA_PWDMGR_SECURE_FIELDS", "FXA_PWDMGR_MEMORY_FIELDS", "FXA_PWDMGR_REAUTH_WHITELIST", "FXA_PWDMGR_HOST", "FXA_PWDMGR_REALM", "SERVER_ERRNO_TO_ERROR", "ERROR_TO_GENERAL_ERROR_CLASS"],
   "FxAccountsOAuthGrantClient.jsm": ["FxAccountsOAuthGrantClient", "FxAccountsOAuthGrantClientError"],
   "FxAccountsProfileClient.jsm": ["FxAccountsProfileClient", "FxAccountsProfileClientError"],
   "FxAccountsPush.js": ["FxAccountsPushService"],
   "FxAccountsStorage.jsm": ["FxAccountsStorageManagerCanStoreField", "FxAccountsStorageManager"],
   "FxAccountsWebChannel.jsm": ["EnsureFxAccountsWebChannel"],
   "fxa_utils.js": ["initializeIdentityWithTokenServerResponse"],
   "gDevTools.jsm": ["gDevTools", "gDevToolsBrowser"],
   "Geometry.jsm": ["Point", "Rect"],