summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/index.html392
-rw-r--r--web/js/browser/browser.js4
-rw-r--r--web/js/browser/lib/client.js792
-rw-r--r--web/js/browser/lib/crypto.js163
-rw-r--r--web/js/browser/lib/hawk.d.ts24
-rw-r--r--web/js/browser/lib/hawk.js145
-rw-r--r--web/js/browser/lib/recoveryKey.js38
-rw-r--r--web/js/browser/lib/utils.js26
-rw-r--r--web/js/crypto.js137
-rw-r--r--web/js/crypto.test.js49
-rw-r--r--web/js/main.js761
11 files changed, 2531 insertions, 0 deletions
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..f36654f
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,392 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <script src="/js/main" type="module"></script>
+ <style>
+ @keyframes fade-blink {
+ 0% { opacity: 100%; }
+ 100% { opacity: 0%; }
+ }
+
+ #message-modal.error {
+ border: 2px solid red;
+ background: #fff0f0;
+ }
+
+ #message-modal-content {
+ text-align: center;
+ }
+
+ #message-modal-content p {
+ font-size: 150%;
+ }
+
+ #message-modal.animate #message-modal-content {
+ animation: fade-blink 1s ease-in-out infinite alternate;
+ }
+
+ #settings-avatar-img {
+ max-width: 200px;
+ max-height: 200px;
+ }
+
+ [hidden] {
+ display: none !important;
+ }
+
+ input[type=email]:invalid {
+ background: #ff8080;
+ }
+
+ .container.dialog {
+ display: flex;
+ width: 30em;
+ max-width: 100vw;
+ justify-content: center;
+ align-content: center;
+ text-align: center;
+ flex-direction: column;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ }
+
+ .container > ol {
+ margin-left: 1em;
+ }
+
+ hr {
+ width: 100%;
+ }
+
+ form {
+ display: flex;
+ justify-content: center;
+ align-content: center;
+ flex-direction: column;
+ }
+
+ table {
+ display: block;
+ padding: 1em;
+ border-collapse: collapse;
+ }
+
+ td {
+ padding: 0.5ex 1ch;
+ }
+ td:not(:last-child) {
+ border-right: 1px dashed black;
+ }
+ tr:not(:last-child) {
+ border-bottom: 1px dashed black;
+ }
+ thead > tr {
+ border-bottom: 1px solid black;
+ }
+
+ .cwts-container {
+ display: flex;
+ text-align: left;
+ flex-flow: row wrap;
+ }
+
+ .cwts-container div {
+ min-width: 33%;
+ }
+
+ .settings-container {
+ margin: 2em;
+ }
+
+ .settings-container form {
+ display: block;
+ }
+
+ .disabled {
+ color: grey;
+ font-style: italic;
+ }
+ </style>
+ <template id="tpl-fenix-style">
+ <style>
+ body { font-size: 300%; }
+ input { font-size: 100%; }
+ </style>
+ </template>
+ </head>
+ <body>
+ <noscript>this thing requires javascript!</noscript>
+
+ <dialog id="message-modal">
+ <div id="message-modal-content">
+ <p id="message"></p>
+ <a href="#" id="message-modal-close" hidden>close</a>
+ </div>
+ </dialog>
+
+ <div id="desktop-signedin" class="container dialog" hidden>
+ successfully signed in!
+ </div>
+
+ <div id="desktop-deleted" class="container dialog" hidden>
+ account has been deleted.
+ </div>
+
+ <div id="desktop-signup" class="container dialog" hidden>
+ <form id="frm-signup">
+ <label for="frm-signup-email">email</label>
+ <input id="frm-signup-email" type="email" name="email" maxlength="256" value="">
+ <label for="frm-signup-password">password</label>
+ <input id="frm-signup-password" type="password" name="password">
+ <input type="submit" name="submit" value="sign up">
+ </form>
+ </div>
+
+ <div id="desktop-signup-unverified" class="container dialog" hidden>
+ <p>signup completed! please verify your account by clicking the
+ link in the email you've just received.</p>
+ <p>if you haven't received anything, go to Settings/Sync and resend
+ the verification code.</p>
+ </div>
+
+ <div id="desktop-signin" class="container dialog" hidden>
+ <form id="frm-signin">
+ <label for="frm-signin-email">email</label>
+ <input id="frm-signin-email" type="email" name="email" maxlength="256" value="">
+ <label for="frm-signin-password">password</label>
+ <input id="frm-signin-password" type="password" name="password">
+ <input type="submit" name="submit" value="sign in">
+ <hr>
+ <div>
+ <a href="#" class="_signup">sign up</a> |
+ <a href="#" class="_reset">reset password</a>
+ </div>
+ </form>
+ </div>
+
+ <div id="desktop-signin-confirm" class="container dialog" hidden>
+ <p>signin completed! please verify your session by copying the code
+ you've just received as an email into this box.</p>
+ <form id="frm-signin-confirm">
+ <label for="frm-signin-confirm-code">code</label>
+ <input id="frm-signin-confirm-code" type="text" name="code" maxlength="6">
+ <input type="submit" name="submit" value="confirm signin">
+ <hr>
+ <a href="#" class="_resend">no email appeared? resend code</a>
+ </form>
+ </div>
+
+ <div id="desktop-resetpw" class="container dialog" hidden>
+ <form id="frm-resetpw">
+ <label for="frm-resetpw-email">email</label>
+ <input id="frm-resetpw-email" type="email" name="email" maxlength="256" value="">
+ <input type="submit" name="submit" value="send reset code">
+ </form>
+ </div>
+
+ <div id="desktop-resetpw-newpw" class="container dialog" hidden>
+ <form id="frm-resetpw-newpw">
+ <table>
+ <tr>
+ <td><label for="frm-resetpw-newpw-old">old password</label></td>
+ <td><input id="frm-resetpw-newpw-old" type="password" name="old"></td>
+ </tr>
+ <tr>
+ <td><label for="frm-resetpw-newpw-new">new password</label></td>
+ <td><input id="frm-resetpw-newpw-new" type="password" name="new"></td>
+ </tr>
+ <tr>
+ <td><label for="frm-resetpw-newpw-new-confirm">new password (confirm)</label></td>
+ <td>
+ <input id="frm-resetpw-newpw-new-confirm" type="password" name="new-confirm">
+ </td>
+ </tr>
+ </table>
+ <div>
+ <input type="submit" name="submit" value="change password">
+ </div>
+ </form>
+ </div>
+
+ <div id="desktop-cwts" class="container dialog" hidden>
+ <form id="frm-cwts">
+ <p>choose what to sync:</p>
+ <div class="cwts-container">
+ <div>
+ <input type="checkbox" id="frm-cwts-addons" name="addons">
+ <label for="frm-cwts-addons">add-ons</label>
+ </div>
+ <div>
+ <input type="checkbox" id="frm-cwts-addresses" name="addresses">
+ <label for="frm-cwts-addresses">addresses</label>
+ </div>
+ <div>
+ <input type="checkbox" id="frm-cwts-bookmarks" name="bookmarks">
+ <label for="frm-cwts-bookmarks">bookmarks</label>
+ </div>
+ <div>
+ <input type="checkbox" id="frm-cwts-creditcards" name="creditcards">
+ <label for="frm-cwts-creditcards">credit cards</label>
+ </div>
+ <div>
+ <input type="checkbox" id="frm-cwts-history" name="history">
+ <label for="frm-cwts-history">history</label>
+ </div>
+ <div>
+ <input type="checkbox" id="frm-cwts-passwords" name="passwords">
+ <label for="frm-cwts-passwords">passwords</label>
+ </div>
+ <!-- NOTE the spec says this key is named `preferences` -->
+ <div>
+ <input type="checkbox" id="frm-cwts-prefs" name="prefs">
+ <label for="frm-cwts-prefs">preferences</label>
+ </div>
+ <div>
+ <input type="checkbox" id="frm-cwts-tabs" name="tabs">
+ <label for="frm-cwts-tabs">open tabs</label>
+ </div>
+ </div>
+ <input type="submit" name="submit" value="start syncing">
+ </form>
+ </div>
+
+ <div id="desktop-settings" class="container" hidden>
+ <nav>
+ <a href="#/settings">settings</a> |
+ <a href="#/settings/change-password">change password</a> |
+ <a href="#/settings/destroy">delete account</a>
+ </nav>
+ <div class="settings-container tab" id="desktop-settings-main" hidden>
+ <form id="frm-settings-avatar">
+ <img id="settings-avatar-img">
+ <input type="file" accept="image/*" id="settings-avatar">
+ <input type="submit" id="settings-avatar-save" value="save">
+ </form>
+ <form id="frm-settings-name">
+ <label for="settings-name">user name:</label>
+ <input type="text" id="settings-name" maxlength="256">
+ <input type="submit" id="settings-name-save" value="save">
+ </form>
+ <table id="settings-clients">
+ <thead>
+ <tr>
+ <td>name</td>
+ <td>deviceType</td>
+ <td>createdTime</td>
+ <td>lastAccessTime</td>
+ <td>oauth?</td>
+ </tr>
+ </thead>
+ <tbody>
+ </tbody>
+ </table>
+ </div>
+ <div class="settings-container tab" id="desktop-settings-destroy" hidden>
+ <p>deleting your account requires confirmation.</p>
+ <form id="frm-settings-destroy">
+ <label for="frm-settings-destroy-email">email</label>
+ <input id="frm-settings-destroy-email" type="email"
+ name="email" maxlength="256" value="">
+ <label for="frm-settings-destroy-password">password</label>
+ <input id="frm-settings-destroy-password" type="password" name="password">
+ <input type="submit" name="submit" value="really delete account">
+ </form>
+ </div>
+ <div class="settings-container tab" id="desktop-settings-chpw" hidden>
+ <form id="frm-settings-chpw">
+ <table>
+ <tr>
+ <td><label for="frm-settings-chpw-old">old password</label></td>
+ <td><input id="frm-settings-chpw-old" type="password" name="old"></td>
+ </tr>
+ <tr>
+ <td><label for="frm-settings-chpw-new">new password</label></td>
+ <td><input id="frm-settings-chpw-new" type="password" name="new"></td>
+ </tr>
+ <tr>
+ <td><label for="frm-settings-chpw-new-confirm">new password (confirm)</label></td>
+ <td>
+ <input id="frm-settings-chpw-new-confirm" type="password" name="new-confirm">
+ </td>
+ </tr>
+ </table>
+ <div>
+ <input type="submit" name="submit" value="change password">
+ </div>
+ </form>
+ </div>
+ </div>
+
+ <div id="desktop-generate-invite" class="container dialog" hidden>
+ <form id="frm-generate-invite">
+ <label for="frm-generate-invite-email">invite valid for</label>
+ <select id="frm-generate-invite-email" name="ttl" maxlength="256">
+ <option value="1">one hour</option>
+ <option value="24">one day</option>
+ <option value="168">one week</option>
+ </select>
+ <input type="submit" name="submit" value="generate invite">
+ </form>
+ <div id="desktop-generate-invite-result" hidden>
+ invite link is <a id="desktop-generate-invite-result-link"></a>
+ </div>
+ </div>
+
+ <div id="fenix-signin-warning" class="container" hidden>
+ <p>it looks like you're trying to sign in to sync from an android firefox instance.
+ we're very sorry, but this is going to hurt a bit.</p>
+ <p>to sign in you'll need to follow these steps:</p>
+ <ol>
+ <li>
+ enable USB debugging in the android:
+ <ol>
+ <li>go to setting → about phone</li>
+ <li>scroll to the build number</li>
+ <li>tap it until a "your are new a developer" message appears</li>
+ <li>go to settings → system → developer options</li>
+ <li>scroll down to "USB debugging", enable it</li>
+ </ol>
+ </li>
+ <li>enable USB debugging in the android firefox settings</li>
+ <li>connect your android device to a PC for USB debugging</li>
+ <li>on this PC, open firefox and go to <pre>about:debugging</pre></li>
+ <li>enable USB devices</li>
+ <li>connect to your android device</li>
+ <li>open this page in a normal tab, not through the sign-in interface.
+ this is very important, if there's no normal tab with this URL open
+ <em>signin will not work</em></li>
+ <li>open the login page through the sign-in interface as well</li>
+ <li>in the PC debugger, inspect the new tab that has just appeared</li>
+ <li><a href="#" id="fenix-signin-dialog-show">actually sign in</a>.
+ clicking this link will lead away from this page, finishing sign-in
+ will bring you back.</li>
+ <li>go to the javascript debug console of the tab we inspected previously,
+ copy the long block of code</li>
+ <li>locate the <pre>Firefox Accounts WebChannel</pre> extension in the
+ debugger and inspect it. this may fail, if it does go to the setup
+ tab of the debugger and disable USB devices, enable then, reconnect
+ to your devices, and repeat this step
+ <p><b>WARNING:</b> this is known to not work on firefox 102. if you get
+ a blank tab instead of a tab with debug tools as seen earlier you
+ may have to downgrade your android firefox to 101 to log in.
+ unfortunately this seems to require uninstalling and reinstalling
+ firefox, which wipes your data!</p>
+ </li>
+ <li>go to the javascript debug console, paste the block of code, and run it</li>
+ <li>enjoy sync!</li>
+ </ol>
+ </div>
+
+ <div id="fenix-signin" class="container dialog" hidden>
+ <form id="frm-fenix-signin">
+ <label for="email">email</label>
+ <input type="text" name="email" value="">
+ <label for="password">password</label>
+ <input type="password" name="password">
+ <input type="submit" name="submit" value="do the fenix dance">
+ </form>
+ </div>
+ </body>
+</html>
diff --git a/web/js/browser/browser.js b/web/js/browser/browser.js
new file mode 100644
index 0000000..aa8b444
--- /dev/null
+++ b/web/js/browser/browser.js
@@ -0,0 +1,4 @@
+import AuthClient from './lib/client';
+export * from './lib/client';
+export * from './lib/recoveryKey';
+export default AuthClient;
diff --git a/web/js/browser/lib/client.js b/web/js/browser/lib/client.js
new file mode 100644
index 0000000..fadbdd9
--- /dev/null
+++ b/web/js/browser/lib/client.js
@@ -0,0 +1,792 @@
+/* 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/. */
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+var __rest = (this && this.__rest) || function (s, e) {
+ var t = {};
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
+ t[p] = s[p];
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
+ t[p[i]] = s[p[i]];
+ }
+ return t;
+};
+import * as crypto from './crypto';
+import * as hawk from './hawk';
+var ERRORS;
+(function (ERRORS) {
+ ERRORS[ERRORS["INVALID_TIMESTAMP"] = 111] = "INVALID_TIMESTAMP";
+ ERRORS[ERRORS["INCORRECT_EMAIL_CASE"] = 120] = "INCORRECT_EMAIL_CASE";
+})(ERRORS || (ERRORS = {}));
+var tokenType;
+(function (tokenType) {
+ tokenType["sessionToken"] = "sessionToken";
+ tokenType["passwordForgotToken"] = "passwordForgotToken";
+ tokenType["keyFetchToken"] = "keyFetchToken";
+ tokenType["accountResetToken"] = "accountResetToken";
+ tokenType["passwordChangeToken"] = "passwordChangeToken";
+})(tokenType || (tokenType = {}));
+var AUTH_PROVIDER;
+(function (AUTH_PROVIDER) {
+ AUTH_PROVIDER["GOOGLE"] = "google";
+ AUTH_PROVIDER["APPLE"] = "apple";
+})(AUTH_PROVIDER || (AUTH_PROVIDER = {}));
+function langHeader(lang) {
+ return new Headers(lang
+ ? {
+ 'Accept-Language': lang,
+ }
+ : {});
+}
+function pathWithKeys(path, keys) {
+ return `${path}${keys ? '?keys=true' : ''}`;
+}
+function fetchOrTimeout(input, init = {}, timeout) {
+ return __awaiter(this, void 0, void 0, function* () {
+ let id = 0;
+ if (typeof AbortController !== 'undefined') {
+ const aborter = new AbortController();
+ init.signal = aborter.signal;
+ id = setTimeout((() => aborter.abort()), timeout);
+ }
+ try {
+ return yield fetch(input, init);
+ }
+ finally {
+ if (id) {
+ clearTimeout(id);
+ }
+ }
+ });
+}
+function cleanStringify(value) {
+ // remove keys with null values
+ return JSON.stringify(value, (_, v) => (v == null ? undefined : v));
+}
+export default class AuthClient {
+ constructor(authServerUri, options = {}) {
+ if (new RegExp(`/${AuthClient.VERSION}$`).test(authServerUri)) {
+ this.uri = authServerUri;
+ }
+ else {
+ this.uri = `${authServerUri}/${AuthClient.VERSION}`;
+ }
+ this.localtimeOffsetMsec = 0;
+ this.timeout = options.timeout || 30000;
+ }
+ static create(authServerUri) {
+ return __awaiter(this, void 0, void 0, function* () {
+ if (typeof TextEncoder === 'undefined') {
+ yield import(
+ // @ts-ignore
+ /* webpackChunkName: "fast-text-encoding" */ 'fast-text-encoding');
+ }
+ yield crypto.checkWebCrypto();
+ return new AuthClient(authServerUri);
+ });
+ }
+ url(path) {
+ return `${this.uri}${path}`;
+ }
+ request(method, path, payload, headers = new Headers()) {
+ return __awaiter(this, void 0, void 0, function* () {
+ headers.set('Content-Type', 'application/json');
+ const response = yield fetchOrTimeout(this.url(path), {
+ method,
+ headers,
+ body: cleanStringify(payload),
+ }, this.timeout);
+ let result = yield response.text();
+ try {
+ result = JSON.parse(result);
+ }
+ catch (e) { }
+ if (result.errno) {
+ throw result;
+ }
+ if (!response.ok) {
+ throw {
+ error: 'Unknown error',
+ message: result,
+ errno: 999,
+ code: response.status,
+ };
+ }
+ return result;
+ });
+ }
+ hawkRequest(method, path, token, kind, payload, extraHeaders = new Headers()) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const makeHeaders = () => __awaiter(this, void 0, void 0, function* () {
+ const headers = yield hawk.header(method, this.url(path), token, kind, {
+ payload: cleanStringify(payload),
+ contentType: 'application/json',
+ localtimeOffsetMsec: this.localtimeOffsetMsec,
+ });
+ for (const [name, value] of extraHeaders) {
+ headers.set(name, value);
+ }
+ return headers;
+ });
+ try {
+ return yield this.request(method, path, payload, yield makeHeaders());
+ }
+ catch (e) {
+ if (e.errno === ERRORS.INVALID_TIMESTAMP) {
+ const serverTime = e.serverTime * 1000 || Date.now();
+ this.localtimeOffsetMsec = serverTime - Date.now();
+ return this.request(method, path, payload, yield makeHeaders());
+ }
+ throw e;
+ }
+ });
+ }
+ sessionGet(path, sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.hawkRequest('GET', path, sessionToken, tokenType.sessionToken);
+ });
+ }
+ sessionPost(path, sessionToken, payload, headers) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.hawkRequest('POST', path, sessionToken, tokenType.sessionToken, payload, headers);
+ });
+ }
+ sessionPut(path, sessionToken, payload, headers) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.hawkRequest('PUT', path, sessionToken, tokenType.sessionToken, payload, headers);
+ });
+ }
+ signUp(email, password, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield crypto.getCredentials(email, password);
+ const payloadOptions = (_a) => {
+ var { keys, lang } = _a, rest = __rest(_a, ["keys", "lang"]);
+ return rest;
+ };
+ const payload = Object.assign({ email, authPW: credentials.authPW }, payloadOptions(options));
+ const accountData = yield this.request('POST', pathWithKeys('/account/create', options.keys), payload, langHeader(options.lang));
+ if (options.keys) {
+ accountData.unwrapBKey = credentials.unwrapBKey;
+ }
+ return accountData;
+ });
+ }
+ signIn(email, password, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield crypto.getCredentials(email, password);
+ const payloadOptions = (_a) => {
+ var { keys } = _a, rest = __rest(_a, ["keys"]);
+ return rest;
+ };
+ const payload = Object.assign({ email, authPW: credentials.authPW }, payloadOptions(options));
+ try {
+ const accountData = yield this.request('POST', pathWithKeys('/account/login', options.keys), payload);
+ if (options.keys) {
+ accountData.unwrapBKey = credentials.unwrapBKey;
+ }
+ return accountData;
+ }
+ catch (error) {
+ if (error &&
+ error.email &&
+ error.errno === ERRORS.INCORRECT_EMAIL_CASE &&
+ !options.skipCaseError) {
+ options.skipCaseError = true;
+ options.originalLoginEmail = email;
+ return this.signIn(error.email, password, options);
+ }
+ else {
+ throw error;
+ }
+ }
+ });
+ }
+ verifyCode(uid, code, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('POST', '/recovery_email/verify_code', Object.assign({ uid,
+ code }, options));
+ });
+ }
+ recoveryEmailStatus(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/recovery_email/status', sessionToken);
+ });
+ }
+ recoveryEmailResendCode(sessionToken, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payloadOptions = (_a) => {
+ var { lang } = _a, rest = __rest(_a, ["lang"]);
+ return rest;
+ };
+ return this.sessionPost('/recovery_email/resend_code', sessionToken, payloadOptions(options), langHeader(options.lang));
+ });
+ }
+ passwordForgotSendCode(email, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payloadOptions = (_a) => {
+ var { lang } = _a, rest = __rest(_a, ["lang"]);
+ return rest;
+ };
+ const payload = Object.assign({ email }, payloadOptions(options));
+ return this.request('POST', '/password/forgot/send_code', payload, langHeader(options.lang));
+ });
+ }
+ passwordForgotResendCode(email, passwordForgotToken, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payloadOptions = (_a) => {
+ var { lang } = _a, rest = __rest(_a, ["lang"]);
+ return rest;
+ };
+ const payload = Object.assign({ email }, payloadOptions(options));
+ return this.hawkRequest('POST', '/password/forgot/resend_code', passwordForgotToken, tokenType.passwordForgotToken, payload, langHeader(options.lang));
+ });
+ }
+ passwordForgotVerifyCode(code, passwordForgotToken, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payload = Object.assign({ code }, options);
+ return this.hawkRequest('POST', '/password/forgot/verify_code', passwordForgotToken, tokenType.passwordForgotToken, payload);
+ });
+ }
+ passwordForgotStatus(passwordForgotToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.hawkRequest('GET', '/password/forgot/status', passwordForgotToken, tokenType.passwordForgotToken);
+ });
+ }
+ accountReset(email, newPassword, accountResetToken, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield crypto.getCredentials(email, newPassword);
+ const payloadOptions = (_a) => {
+ var { keys } = _a, rest = __rest(_a, ["keys"]);
+ return rest;
+ };
+ const payload = Object.assign({ authPW: credentials.authPW }, payloadOptions(options));
+ const accountData = yield this.hawkRequest('POST', pathWithKeys('/account/reset', options.keys), accountResetToken, tokenType.accountResetToken, payload);
+ if (options.keys && accountData.keyFetchToken) {
+ accountData.unwrapBKey = credentials.unwrapBKey;
+ }
+ return accountData;
+ });
+ }
+ finishSetup(token, email, newPassword) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield crypto.getCredentials(email, newPassword);
+ const payload = {
+ token,
+ authPW: credentials.authPW,
+ };
+ return yield this.request('POST', '/account/finish_setup', payload);
+ });
+ }
+ verifyAccountThirdParty(code, provider = AUTH_PROVIDER.GOOGLE, metricsContext = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payload = {
+ code,
+ provider,
+ metricsContext,
+ };
+ return yield this.request('POST', '/linked_account/login', payload);
+ });
+ }
+ unlinkThirdParty(sessionToken, providerId) {
+ return __awaiter(this, void 0, void 0, function* () {
+ let provider;
+ switch (providerId) {
+ case 2: {
+ provider = AUTH_PROVIDER.APPLE;
+ break;
+ }
+ default: {
+ provider = AUTH_PROVIDER.GOOGLE;
+ }
+ }
+ return yield this.sessionPost('/linked_account/unlink', sessionToken, {
+ provider,
+ });
+ });
+ }
+ accountKeys(keyFetchToken, unwrapBKey) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield hawk.deriveHawkCredentials(keyFetchToken, 'keyFetchToken');
+ const keyData = yield this.hawkRequest('GET', '/account/keys', keyFetchToken, tokenType.keyFetchToken);
+ const keys = yield crypto.unbundleKeyFetchResponse(credentials.bundleKey, keyData.bundle);
+ return {
+ kA: keys.kA,
+ kB: crypto.unwrapKB(keys.wrapKB, unwrapBKey),
+ };
+ });
+ }
+ accountDestroy(email, password, options = {}, sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield crypto.getCredentials(email, password);
+ const payload = {
+ email,
+ authPW: credentials.authPW,
+ };
+ try {
+ if (sessionToken) {
+ return yield this.sessionPost('/account/destroy', sessionToken, payload);
+ }
+ else {
+ return yield this.request('POST', '/account/destroy', payload);
+ }
+ }
+ catch (error) {
+ if (error &&
+ error.email &&
+ error.errno === ERRORS.INCORRECT_EMAIL_CASE &&
+ !options.skipCaseError) {
+ options.skipCaseError = true;
+ return this.accountDestroy(error.email, password, options, sessionToken);
+ }
+ else {
+ throw error;
+ }
+ }
+ });
+ }
+ accountStatus(uid) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('GET', `/account/status?uid=${uid}`);
+ });
+ }
+ accountStatusByEmail(email) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('POST', '/account/status', { email });
+ });
+ }
+ accountProfile(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/account/profile', sessionToken);
+ });
+ }
+ account(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/account', sessionToken);
+ });
+ }
+ sessionDestroy(sessionToken, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/session/destroy', sessionToken, options);
+ });
+ }
+ sessionStatus(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/session/status', sessionToken);
+ });
+ }
+ sessionVerifyCode(sessionToken, code, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/session/verify_code', sessionToken, Object.assign({ code }, options));
+ });
+ }
+ sessionResendVerifyCode(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/session/resend_code', sessionToken, {});
+ });
+ }
+ sessionReauth(sessionToken, email, password, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield crypto.getCredentials(email, password);
+ const payloadOptions = (_a) => {
+ var { keys } = _a, rest = __rest(_a, ["keys"]);
+ return rest;
+ };
+ const payload = Object.assign({ email, authPW: credentials.authPW }, payloadOptions(options));
+ try {
+ const accountData = yield this.sessionPost(pathWithKeys('/session/reauth', options.keys), sessionToken, payload);
+ if (options.keys) {
+ accountData.unwrapBKey = credentials.unwrapBKey;
+ }
+ return accountData;
+ }
+ catch (error) {
+ if (error &&
+ error.email &&
+ error.errno === ERRORS.INCORRECT_EMAIL_CASE &&
+ !options.skipCaseError) {
+ options.skipCaseError = true;
+ options.originalLoginEmail = email;
+ return this.sessionReauth(sessionToken, error.email, password, options);
+ }
+ else {
+ throw error;
+ }
+ }
+ });
+ }
+ certificateSign(sessionToken, publicKey, duration, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payload = {
+ publicKey,
+ duration,
+ };
+ return this.sessionPost(`/certificate/sign${options.service
+ ? `?service=${encodeURIComponent(options.service)}`
+ : ''}`, sessionToken, payload);
+ });
+ }
+ passwordChange(email, oldPassword, newPassword, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const oldCredentials = yield this.passwordChangeStart(email, oldPassword);
+ const keys = yield this.accountKeys(oldCredentials.keyFetchToken, oldCredentials.unwrapBKey);
+ const newCredentials = yield crypto.getCredentials(oldCredentials.email, newPassword);
+ const wrapKb = crypto.unwrapKB(keys.kB, newCredentials.unwrapBKey);
+ const sessionToken = options.sessionToken
+ ? (yield hawk.deriveHawkCredentials(options.sessionToken, 'sessionToken'))
+ .id
+ : undefined;
+ const payload = {
+ wrapKb,
+ authPW: newCredentials.authPW,
+ sessionToken,
+ };
+ const accountData = yield this.hawkRequest('POST', pathWithKeys('/password/change/finish', options.keys), oldCredentials.passwordChangeToken, tokenType.passwordChangeToken, payload);
+ if (options.keys && accountData.keyFetchToken) {
+ accountData.unwrapBKey = newCredentials.unwrapBKey;
+ }
+ return accountData;
+ });
+ }
+ passwordChangeStart(email, oldPassword, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const oldCredentials = yield crypto.getCredentials(email, oldPassword);
+ try {
+ const passwordData = yield this.request('POST', '/password/change/start', {
+ email,
+ oldAuthPW: oldCredentials.authPW,
+ });
+ return {
+ authPW: oldCredentials.authPW,
+ unwrapBKey: oldCredentials.unwrapBKey,
+ email: email,
+ keyFetchToken: passwordData.keyFetchToken,
+ passwordChangeToken: passwordData.passwordChangeToken,
+ };
+ }
+ catch (error) {
+ if (error &&
+ error.email &&
+ error.errno === ERRORS.INCORRECT_EMAIL_CASE &&
+ !options.skipCaseError) {
+ options.skipCaseError = true;
+ return this.passwordChangeStart(error.email, oldPassword, options);
+ }
+ else {
+ throw error;
+ }
+ }
+ });
+ }
+ createPassword(sessionToken, email, newPassword) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const newCredentials = yield crypto.getCredentials(email, newPassword);
+ const payload = {
+ authPW: newCredentials.authPW,
+ };
+ return this.sessionPost('/password/create', sessionToken, payload);
+ });
+ }
+ getRandomBytes() {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('POST', '/get_random_bytes');
+ });
+ }
+ deviceRegister(sessionToken, name, type, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payload = Object.assign({ name,
+ type }, options);
+ return this.sessionPost('/account/device', sessionToken, payload);
+ });
+ }
+ deviceUpdate(sessionToken, id, name, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payload = Object.assign({ id,
+ name }, options);
+ return this.sessionPost('/account/device', sessionToken, payload);
+ });
+ }
+ deviceDestroy(sessionToken, id) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/account/device/destroy', sessionToken, { id });
+ });
+ }
+ deviceList(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/account/devices', sessionToken);
+ });
+ }
+ sessions(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/account/sessions', sessionToken);
+ });
+ }
+ securityEvents(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/securityEvents', sessionToken);
+ });
+ }
+ deleteSecurityEvents(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.hawkRequest('DELETE', '/securityEvents', sessionToken, tokenType.sessionToken, {});
+ });
+ }
+ attachedClients(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/account/attached_clients', sessionToken);
+ });
+ }
+ attachedClientDestroy(sessionToken, clientInfo) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/account/attached_client/destroy', sessionToken, {
+ clientId: clientInfo.clientId,
+ deviceId: clientInfo.deviceId,
+ refreshTokenId: clientInfo.refreshTokenId,
+ sessionTokenId: clientInfo.sessionTokenId,
+ });
+ });
+ }
+ sendUnblockCode(email, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('POST', '/account/login/send_unblock_code', Object.assign({ email }, options));
+ });
+ }
+ rejectUnblockCode(uid, unblockCode) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('POST', '/account/login/reject_unblock_code', {
+ uid,
+ unblockCode,
+ });
+ });
+ }
+ consumeSigninCode(code, flowId, flowBeginTime, deviceId) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('POST', '/signinCodes/consume', {
+ code,
+ metricsContext: {
+ deviceId,
+ flowId,
+ flowBeginTime,
+ },
+ });
+ });
+ }
+ createSigninCode(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/signinCodes', sessionToken, {});
+ });
+ }
+ createCadReminder(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/emails/reminders/cad', sessionToken, {});
+ });
+ }
+ recoveryEmails(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/recovery_emails', sessionToken);
+ });
+ }
+ recoveryEmailCreate(sessionToken, email, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/recovery_email', sessionToken, Object.assign({ email }, options));
+ });
+ }
+ recoveryEmailDestroy(sessionToken, email) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/recovery_email/destroy', sessionToken, { email });
+ });
+ }
+ recoveryEmailSetPrimaryEmail(sessionToken, email) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/recovery_email/set_primary', sessionToken, {
+ email,
+ });
+ });
+ }
+ recoveryEmailSecondaryVerifyCode(sessionToken, email, code) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/recovery_email/secondary/verify_code', sessionToken, { email, code });
+ });
+ }
+ recoveryEmailSecondaryResendCode(sessionToken, email) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/recovery_email/secondary/resend_code', sessionToken, { email });
+ });
+ }
+ createTotpToken(sessionToken, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/totp/create', sessionToken, options);
+ });
+ }
+ deleteTotpToken(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/totp/destroy', sessionToken, {});
+ });
+ }
+ checkTotpTokenExists(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/totp/exists', sessionToken);
+ });
+ }
+ verifyTotpCode(sessionToken, code, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/session/verify/totp', sessionToken, Object.assign({ code }, options));
+ });
+ }
+ replaceRecoveryCodes(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/recoveryCodes', sessionToken);
+ });
+ }
+ updateRecoveryCodes(sessionToken, recoveryCodes) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPut('/recoveryCodes', sessionToken, { recoveryCodes });
+ });
+ }
+ consumeRecoveryCode(sessionToken, code) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/session/verify/recoveryCode', sessionToken, {
+ code,
+ });
+ });
+ }
+ createRecoveryKey(sessionToken, recoveryKeyId, recoveryData, enabled) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/recoveryKey', sessionToken, {
+ recoveryKeyId,
+ recoveryData,
+ enabled,
+ });
+ });
+ }
+ getRecoveryKey(accountResetToken, recoveryKeyId) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.hawkRequest('GET', `/recoveryKey/${recoveryKeyId}`, accountResetToken, tokenType.accountResetToken);
+ });
+ }
+ resetPasswordWithRecoveryKey(accountResetToken, email, newPassword, recoveryKeyId, keys, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield crypto.getCredentials(email, newPassword);
+ const newWrapKb = crypto.unwrapKB(keys.kB, credentials.unwrapBKey);
+ const payload = {
+ wrapKb: newWrapKb,
+ authPW: credentials.authPW,
+ sessionToken: options.sessionToken,
+ recoveryKeyId,
+ };
+ const accountData = yield this.hawkRequest('POST', pathWithKeys('/account/reset', options.keys), accountResetToken, tokenType.accountResetToken, payload);
+ if (options.keys && accountData.keyFetchToken) {
+ accountData.unwrapBKey = credentials.unwrapBKey;
+ }
+ return accountData;
+ });
+ }
+ deleteRecoveryKey(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.hawkRequest('DELETE', '/recoveryKey', sessionToken, tokenType.sessionToken, {});
+ });
+ }
+ recoveryKeyExists(sessionToken, email) {
+ return __awaiter(this, void 0, void 0, function* () {
+ if (sessionToken) {
+ return this.sessionPost('/recoveryKey/exists', sessionToken, { email });
+ }
+ return this.request('POST', '/recoveryKey/exists', { email });
+ });
+ }
+ verifyRecoveryKey(sessionToken, recoveryKeyId) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/recoveryKey/verify', sessionToken, {
+ recoveryKeyId,
+ });
+ });
+ }
+ createOAuthCode(sessionToken, clientId, state, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/oauth/authorization', sessionToken, {
+ access_type: options.access_type,
+ acr_values: options.acr_values,
+ client_id: clientId,
+ code_challenge: options.code_challenge,
+ code_challenge_method: options.code_challenge_method,
+ keys_jwe: options.keys_jwe,
+ redirect_uri: options.redirect_uri,
+ response_type: options.response_type,
+ scope: options.scope,
+ state,
+ });
+ });
+ }
+ createOAuthToken(sessionToken, clientId, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/oauth/token', sessionToken, {
+ grant_type: 'fxa-credentials',
+ access_type: options.access_type,
+ client_id: clientId,
+ scope: options.scope,
+ ttl: options.ttl,
+ });
+ });
+ }
+ getOAuthScopedKeyData(sessionToken, clientId, scope) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/account/scoped-key-data', sessionToken, {
+ client_id: clientId,
+ scope,
+ });
+ });
+ }
+ getSubscriptionPlans() {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('GET', '/oauth/subscriptions/plans');
+ });
+ }
+ getActiveSubscriptions(accessToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('GET', '/oauth/subscriptions/active', null, new Headers({
+ authorization: `Bearer ${accessToken}`,
+ }));
+ });
+ }
+ createSupportTicket(accessToken, supportTicket) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('POST', '/support/ticket', supportTicket, new Headers({
+ authorization: `Bearer ${accessToken}`,
+ }));
+ });
+ }
+ updateNewsletters(sessionToken, newsletters) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/newsletters', sessionToken, {
+ newsletters,
+ });
+ });
+ }
+ verifyIdToken(idToken, clientId, expiryGracePeriod) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payload = {
+ id_token: idToken,
+ client_id: clientId,
+ };
+ if (expiryGracePeriod) {
+ payload.expiry_grace_period = expiryGracePeriod;
+ }
+ return this.request('POST', '/oauth/id-token-verify', payload);
+ });
+ }
+ sendPushLoginRequest(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/session/verify/send_push', sessionToken, {});
+ });
+ }
+}
+AuthClient.VERSION = 'v1';
diff --git a/web/js/browser/lib/crypto.js b/web/js/browser/lib/crypto.js
new file mode 100644
index 0000000..6fa6107
--- /dev/null
+++ b/web/js/browser/lib/crypto.js
@@ -0,0 +1,163 @@
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+/* 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/. */
+import { hexToUint8, uint8ToBase64Url, uint8ToHex, xor } from './utils';
+const encoder = () => new TextEncoder();
+const NAMESPACE = 'identity.mozilla.com/picl/v1/';
+// These functions implement the onepw protocol
+// https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol
+export function getCredentials(email, password) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const passkey = yield crypto.subtle.importKey('raw', encoder().encode(password), 'PBKDF2', false, ['deriveBits']);
+ const quickStretchedRaw = yield crypto.subtle.deriveBits({
+ name: 'PBKDF2',
+ salt: encoder().encode(`${NAMESPACE}quickStretch:${email}`),
+ iterations: 1000,
+ hash: 'SHA-256',
+ }, passkey, 256);
+ const quickStretchedKey = yield crypto.subtle.importKey('raw', quickStretchedRaw, 'HKDF', false, ['deriveBits']);
+ const authPW = yield crypto.subtle.deriveBits({
+ name: 'HKDF',
+ salt: new Uint8Array(0),
+ // The builtin ts type definition for HKDF was wrong
+ // at the time this was written, hence the ignore
+ // @ts-ignore
+ info: encoder().encode(`${NAMESPACE}authPW`),
+ hash: 'SHA-256',
+ }, quickStretchedKey, 256);
+ const unwrapBKey = yield crypto.subtle.deriveBits({
+ name: 'HKDF',
+ salt: new Uint8Array(0),
+ // @ts-ignore
+ info: encoder().encode(`${NAMESPACE}unwrapBkey`),
+ hash: 'SHA-256',
+ }, quickStretchedKey, 256);
+ return {
+ authPW: uint8ToHex(new Uint8Array(authPW)),
+ unwrapBKey: uint8ToHex(new Uint8Array(unwrapBKey)),
+ };
+ });
+}
+export function deriveBundleKeys(key, keyInfo, payloadBytes = 64) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const baseKey = yield crypto.subtle.importKey('raw', hexToUint8(key), 'HKDF', false, ['deriveBits']);
+ const keyMaterial = yield crypto.subtle.deriveBits({
+ name: 'HKDF',
+ salt: new Uint8Array(0),
+ // @ts-ignore
+ info: encoder().encode(`${NAMESPACE}${keyInfo}`),
+ hash: 'SHA-256',
+ }, baseKey, (32 + payloadBytes) * 8);
+ const hmacKey = yield crypto.subtle.importKey('raw', new Uint8Array(keyMaterial.slice(0, 32)), {
+ name: 'HMAC',
+ hash: 'SHA-256',
+ length: 256,
+ }, true, ['verify']);
+ const xorKey = new Uint8Array(keyMaterial.slice(32));
+ return {
+ hmacKey,
+ xorKey,
+ };
+ });
+}
+export function unbundleKeyFetchResponse(key, bundle) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const b = hexToUint8(bundle);
+ const keys = yield deriveBundleKeys(key, 'account/keys');
+ const ciphertext = b.subarray(0, 64);
+ const expectedHmac = b.subarray(b.byteLength - 32);
+ const valid = yield crypto.subtle.verify('HMAC', keys.hmacKey, expectedHmac, ciphertext);
+ if (!valid) {
+ throw new Error('Bad HMac');
+ }
+ const keyAWrapB = xor(ciphertext, keys.xorKey);
+ return {
+ kA: uint8ToHex(keyAWrapB.subarray(0, 32)),
+ wrapKB: uint8ToHex(keyAWrapB.subarray(32)),
+ };
+ });
+}
+export function unwrapKB(wrapKB, unwrapBKey) {
+ return uint8ToHex(xor(hexToUint8(wrapKB), hexToUint8(unwrapBKey)));
+}
+export function hkdf(keyMaterial, salt, info, bytes) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const key = yield crypto.subtle.importKey('raw', keyMaterial, 'HKDF', false, [
+ 'deriveBits',
+ ]);
+ const result = yield crypto.subtle.deriveBits({
+ name: 'HKDF',
+ salt,
+ // @ts-ignore
+ info,
+ hash: 'SHA-256',
+ }, key, bytes * 8);
+ return new Uint8Array(result);
+ });
+}
+export function jweEncrypt(keyMaterial, kid, data, forTestingOnly) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const key = yield crypto.subtle.importKey('raw', keyMaterial, {
+ name: 'AES-GCM',
+ }, false, ['encrypt']);
+ const jweHeader = uint8ToBase64Url(encoder().encode(JSON.stringify({
+ enc: 'A256GCM',
+ alg: 'dir',
+ kid,
+ })));
+ const iv = (forTestingOnly === null || forTestingOnly === void 0 ? void 0 : forTestingOnly.testIV) || crypto.getRandomValues(new Uint8Array(12));
+ const encrypted = yield crypto.subtle.encrypt({
+ name: 'AES-GCM',
+ iv,
+ additionalData: encoder().encode(jweHeader),
+ tagLength: 128,
+ }, key, data);
+ const ciphertext = new Uint8Array(encrypted.slice(0, encrypted.byteLength - 16));
+ const authenticationTag = new Uint8Array(encrypted.slice(encrypted.byteLength - 16));
+ // prettier-ignore
+ const compactJWE = `${jweHeader}..${uint8ToBase64Url(iv)}.${uint8ToBase64Url(ciphertext)}.${uint8ToBase64Url(authenticationTag)}`;
+ return compactJWE;
+ });
+}
+export function checkWebCrypto() {
+ return __awaiter(this, void 0, void 0, function* () {
+ try {
+ yield crypto.subtle.importKey('raw', crypto.getRandomValues(new Uint8Array(16)), 'PBKDF2', false, ['deriveKey']);
+ yield crypto.subtle.importKey('raw', crypto.getRandomValues(new Uint8Array(32)), 'HKDF', false, ['deriveKey']);
+ yield crypto.subtle.importKey('raw', crypto.getRandomValues(new Uint8Array(32)), {
+ name: 'HMAC',
+ hash: 'SHA-256',
+ length: 256,
+ }, false, ['sign']);
+ yield crypto.subtle.importKey('raw', crypto.getRandomValues(new Uint8Array(32)), {
+ name: 'AES-GCM',
+ }, false, ['encrypt']);
+ yield crypto.subtle.digest('SHA-256', crypto.getRandomValues(new Uint8Array(16)));
+ return true;
+ }
+ catch (err) {
+ try {
+ console.warn('loading webcrypto shim', err);
+ // prettier-ignore
+ // @ts-ignore
+ window.asmCrypto = yield import(/* webpackChunkName: "asmcrypto.js" */ 'asmcrypto.js');
+ // prettier-ignore
+ // @ts-ignore
+ yield import(/* webpackChunkName: "webcrypto-liner" */ 'webcrypto-liner/build/shim');
+ return true;
+ }
+ catch (e) {
+ return false;
+ }
+ }
+ });
+}
diff --git a/web/js/browser/lib/hawk.d.ts b/web/js/browser/lib/hawk.d.ts
new file mode 100644
index 0000000..d4e5263
--- /dev/null
+++ b/web/js/browser/lib/hawk.d.ts
@@ -0,0 +1,24 @@
+/// <reference types="./lib/types" />
+export declare function deriveHawkCredentials(token: hexstring, context: string): Promise<{
+ id: string;
+ key: Uint8Array;
+ bundleKey: string;
+}>;
+export declare function hawkHeader(method: string, uri: string, options: {
+ credentials: {
+ id: string;
+ key: Uint8Array;
+ };
+ payload?: string;
+ timestamp?: number;
+ nonce?: string;
+ contentType?: string;
+ localtimeOffsetMsec?: number;
+}): Promise<string>;
+export declare function header(method: string, uri: string, token: string, kind: string, options: {
+ payload?: string;
+ timestamp?: number;
+ nonce?: string;
+ contentType?: string;
+ localtimeOffsetMsec?: number;
+}): Promise<Headers>;
diff --git a/web/js/browser/lib/hawk.js b/web/js/browser/lib/hawk.js
new file mode 100644
index 0000000..69c7153
--- /dev/null
+++ b/web/js/browser/lib/hawk.js
@@ -0,0 +1,145 @@
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+/* 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/. */
+import { hexToUint8, uint8ToBase64, uint8ToHex } from './utils';
+const encoder = () => new TextEncoder();
+const NAMESPACE = 'identity.mozilla.com/picl/v1/';
+export function deriveHawkCredentials(token, context) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const baseKey = yield crypto.subtle.importKey('raw', hexToUint8(token), 'HKDF', false, ['deriveBits']);
+ const keyMaterial = yield crypto.subtle.deriveBits({
+ name: 'HKDF',
+ salt: new Uint8Array(0),
+ // @ts-ignore
+ info: encoder().encode(`${NAMESPACE}${context}`),
+ hash: 'SHA-256',
+ }, baseKey, 32 * 3 * 8);
+ const id = new Uint8Array(keyMaterial.slice(0, 32));
+ const authKey = new Uint8Array(keyMaterial.slice(32, 64));
+ const bundleKey = new Uint8Array(keyMaterial.slice(64));
+ return {
+ id: uint8ToHex(id),
+ key: authKey,
+ bundleKey: uint8ToHex(bundleKey),
+ };
+ });
+}
+// The following is adapted from https://github.com/hapijs/hawk/blob/master/lib/browser.js
+/*
+ HTTP Hawk Authentication Scheme
+ Copyright (c) 2012-2013, Eran Hammer <eran@hueniverse.com>
+ MIT Licensed
+ */
+function parseUri(input) {
+ const parts = input.match(/^([^:]+)\:\/\/(?:[^@/]*@)?([^\/:]+)(?:\:(\d+))?([^#]*)(?:#.*)?$/);
+ if (!parts) {
+ return { host: '', port: '', resource: '' };
+ }
+ const scheme = parts[1].toLowerCase();
+ const uri = {
+ host: parts[2],
+ port: parts[3] || (scheme === 'http' ? '80' : scheme === 'https' ? '443' : ''),
+ resource: parts[4],
+ };
+ return uri;
+}
+function randomString(size) {
+ const randomSource = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
+ const len = randomSource.length;
+ const result = [];
+ for (let i = 0; i < size; ++i) {
+ result[i] = randomSource[Math.floor(Math.random() * len)];
+ }
+ return result.join('');
+}
+function generateNormalizedString(type, options) {
+ let normalized = 'hawk.1.' +
+ type +
+ '\n' +
+ options.ts +
+ '\n' +
+ options.nonce +
+ '\n' +
+ (options.method || '').toUpperCase() +
+ '\n' +
+ (options.resource || '') +
+ '\n' +
+ options.host.toLowerCase() +
+ '\n' +
+ options.port +
+ '\n' +
+ (options.hash || '') +
+ '\n';
+ if (options.ext) {
+ normalized += options.ext.replace(/\\/g, '\\\\').replace(/\n/g, '\\n');
+ }
+ normalized += '\n';
+ if (options.app) {
+ normalized += options.app + '\n' + (options.dlg || '') + '\n';
+ }
+ return normalized;
+}
+function calculatePayloadHash(payload = '', contentType = '') {
+ return __awaiter(this, void 0, void 0, function* () {
+ const data = encoder().encode(`hawk.1.payload\n${contentType}\n${payload}\n`);
+ const hash = yield crypto.subtle.digest('SHA-256', data);
+ return uint8ToBase64(new Uint8Array(hash));
+ });
+}
+function calculateMac(type, credentials, options) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const normalized = generateNormalizedString(type, options);
+ const key = yield crypto.subtle.importKey('raw', credentials.key, {
+ name: 'HMAC',
+ hash: 'SHA-256',
+ length: 256,
+ }, true, ['sign']);
+ const hmac = yield crypto.subtle.sign('HMAC', key, encoder().encode(normalized));
+ return uint8ToBase64(new Uint8Array(hmac));
+ });
+}
+export function hawkHeader(method, uri, options) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const timestamp = options.timestamp ||
+ Math.floor((Date.now() + (options.localtimeOffsetMsec || 0)) / 1000);
+ const parsedUri = parseUri(uri);
+ const hash = yield calculatePayloadHash(options.payload, options.contentType);
+ const artifacts = {
+ ts: timestamp,
+ nonce: options.nonce || randomString(6),
+ method,
+ resource: parsedUri.resource,
+ host: parsedUri.host,
+ port: parsedUri.port,
+ hash,
+ };
+ const mac = yield calculateMac('header', options.credentials, artifacts);
+ const header = 'Hawk id="' +
+ options.credentials.id +
+ '", ts="' +
+ artifacts.ts +
+ '", nonce="' +
+ artifacts.nonce +
+ (artifacts.hash ? '", hash="' + artifacts.hash : '') +
+ '", mac="' +
+ mac +
+ '"';
+ return header;
+ });
+}
+export function header(method, uri, token, kind, options) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield deriveHawkCredentials(token, kind);
+ const authorization = yield hawkHeader(method, uri, Object.assign({ credentials }, options));
+ return new Headers({ authorization });
+ });
+}
diff --git a/web/js/browser/lib/recoveryKey.js b/web/js/browser/lib/recoveryKey.js
new file mode 100644
index 0000000..cc8604d
--- /dev/null
+++ b/web/js/browser/lib/recoveryKey.js
@@ -0,0 +1,38 @@
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+import { jweEncrypt, hkdf } from './crypto';
+import { hexToUint8, uint8ToHex } from './utils';
+function randomKey() {
+ return __awaiter(this, void 0, void 0, function* () {
+ // The key is displayed in base32 'Crockford' so the length should be
+ // divisible by (5 bits per character) and (8 bits per byte).
+ // 20 bytes == 160 bits == 32 base32 characters
+ const recoveryKey = yield crypto.getRandomValues(new Uint8Array(20));
+ // Flip bits to set first char to base32 'A' as a version identifier.
+ // Why 'A'? https://github.com/mozilla/fxa-content-server/pull/6323#discussion_r201211711
+ recoveryKey[0] = 0x50 | (0x07 & recoveryKey[0]);
+ return recoveryKey;
+ });
+}
+export function generateRecoveryKey(uid, keys, forTestingOnly) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const recoveryKey = (forTestingOnly === null || forTestingOnly === void 0 ? void 0 : forTestingOnly.testRecoveryKey) || (yield randomKey());
+ const encoder = new TextEncoder();
+ const salt = hexToUint8(uid);
+ const encryptionKey = yield hkdf(recoveryKey, salt, encoder.encode('fxa recovery encrypt key'), 32);
+ const recoveryKeyId = uint8ToHex(yield hkdf(recoveryKey, salt, encoder.encode('fxa recovery fingerprint'), 16));
+ const recoveryData = yield jweEncrypt(encryptionKey, recoveryKeyId, encoder.encode(JSON.stringify(keys)), forTestingOnly ? { testIV: forTestingOnly.testIV } : undefined);
+ return {
+ recoveryKey,
+ recoveryKeyId,
+ recoveryData,
+ };
+ });
+}
diff --git a/web/js/browser/lib/utils.js b/web/js/browser/lib/utils.js
new file mode 100644
index 0000000..5bf0383
--- /dev/null
+++ b/web/js/browser/lib/utils.js
@@ -0,0 +1,26 @@
+/* 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 HEX_STRING = /^(?:[a-fA-F0-9]{2})+$/;
+export function hexToUint8(str) {
+ if (!HEX_STRING.test(str)) {
+ throw new Error(`invalid hex string: ${str}`);
+ }
+ const bytes = str.match(/[a-fA-F0-9]{2}/g);
+ return new Uint8Array(bytes.map((byte) => parseInt(byte, 16)));
+}
+export function uint8ToHex(array) {
+ return array.reduce((str, byte) => str + ('00' + byte.toString(16)).slice(-2), '');
+}
+export function uint8ToBase64(array) {
+ return btoa(String.fromCharCode(...array));
+}
+export function uint8ToBase64Url(array) {
+ return uint8ToBase64(array)
+ .replace(/=/g, '')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_');
+}
+export function xor(array1, array2) {
+ return new Uint8Array(array1.map((byte, i) => byte ^ array2[i]));
+}
diff --git a/web/js/crypto.js b/web/js/crypto.js
new file mode 100644
index 0000000..5c12a2c
--- /dev/null
+++ b/web/js/crypto.js
@@ -0,0 +1,137 @@
+'use strict';
+
+// taken from fxa
+function hexToUint8(str) {
+ const HEX_STRING = /^(?:[a-fA-F0-9]{2})+$/;
+ if (!HEX_STRING.test(str)) {
+ throw new Error(`invalid hex string: ${str}`);
+ }
+ const bytes = str.match(/[a-fA-F0-9]{2}/g);
+ return new Uint8Array(bytes.map((byte) => parseInt(byte, 16)));
+}
+function uint8ToHex(array) {
+ return array.reduce((str, byte) => str + ('00' + byte.toString(16)).slice(-2), '');
+}
+function uint8ToBase64(array) {
+ return btoa(String.fromCharCode(...array));
+}
+function uint8ToBase64Url(array) {
+ return uint8ToBase64(array).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
+}
+
+export function urlatob(s) {
+ return atob(s.replace(/-/g, '+').replace(/_/g, "/"));
+}
+
+export async function deriveScopedKey(kB, uid, identifier, key_rotation_secret, key_rotation_timestamp) {
+ async function importKey(k) {
+ return await crypto.subtle.importKey('raw', hexToUint8(k), 'HKDF', false, ['deriveBits']);
+ }
+
+ async function derive(salt, k, info, len) {
+ let params = {
+ name: 'HKDF',
+ salt,
+ info: new TextEncoder().encode(info),
+ hash: 'SHA-256',
+ };
+ return new Uint8Array(await crypto.subtle.deriveBits(params, await importKey(k), len * 8));
+ }
+
+ if (identifier == "https://identity.mozilla.com/apps/oldsync") {
+ const bits = await derive(
+ new Uint8Array(),
+ kB,
+ "identity.mozilla.com/picl/v1/oldsync",
+ 64);
+ const kHash = await crypto.subtle.digest("SHA-256", hexToUint8(kB));
+ const kid = key_rotation_timestamp.toString() + "-" +
+ uint8ToBase64Url(new Uint8Array(kHash.slice(0, 16)));
+ return {
+ k: uint8ToBase64Url(bits),
+ kty: "oct",
+ kid,
+ scope: identifier,
+ };
+ } else {
+ const bits = await derive(
+ hexToUint8(uid),
+ kB + key_rotation_secret,
+ "identity.mozilla.com/picl/v1/scoped_key\n" + identifier,
+ 16 + 32);
+ const fp = new Uint8Array(bits.slice(0, 16));
+ const key = new Uint8Array(bits.slice(16));
+ const kid = key_rotation_timestamp.toString() + "-" + uint8ToBase64Url(fp);
+ return {
+ k: uint8ToBase64Url(key),
+ kty: "oct",
+ kid,
+ scope: identifier,
+ };
+ }
+}
+
+export async function encryptScopedKeys(bundle, to, local_key, iv) {
+ let encode = s => new TextEncoder().encode(s);
+
+ let peer_key = await crypto.subtle.importKey(
+ "jwk",
+ JSON.parse(urlatob(to)),
+ { name: "ECDH", namedCurve: "P-256" },
+ false,
+ ['deriveKey']);
+ local_key = local_key || await crypto.subtle.generateKey(
+ { name: "ECDH", namedCurve: "P-256" },
+ true,
+ ['deriveKey']);
+ iv = iv || new Uint8Array(await crypto.subtle.exportKey(
+ "raw",
+ await crypto.subtle.generateKey({ name: "AES-CBC", length: 128 }, true, ['encrypt'])
+ )).slice(0, 12);
+
+ let key = await crypto.subtle.deriveKey(
+ { name: "ECDH", public: peer_key },
+ local_key.privateKey,
+ { name: "AES-GCM", length: 256 },
+ true,
+ ['encrypt']);
+ key = new Uint8Array(await crypto.subtle.exportKey("raw", key));
+ key = await crypto.subtle.digest(
+ "SHA-256",
+ new Uint8Array([
+ 0, 0, 0, 1, // rounds
+ ...key,
+ 0, 0, 0, 7, ...encode("A256GCM"),
+ 0, 0, 0, 0, // apu
+ 0, 0, 0, 0, // apv
+ 0, 0, 1, 0, // key size
+ ]));
+ key = await crypto.subtle.importKey("raw", key, { name: "AES-GCM" }, false, ['encrypt']);
+
+ let exported_key = await crypto.subtle.exportKey("jwk", local_key.publicKey);
+ let header = {
+ "alg": "ECDH-ES",
+ "enc": "A256GCM",
+ "epk": {
+ crv: "P-256",
+ kty: "EC",
+ x: exported_key.x,
+ y: exported_key.y
+ }
+ };
+
+ let ciphered = await crypto.subtle.encrypt(
+ { name: "AES-GCM", iv, additionalData: encode(uint8ToBase64Url(encode(JSON.stringify(header)))) },
+ key,
+ encode(JSON.stringify(bundle)));
+ let tag = ciphered.slice(-16);
+ ciphered = ciphered.slice(0, -16);
+
+ return (uint8ToBase64Url(encode(JSON.stringify(header))) +
+ ".." +
+ uint8ToBase64Url(new Uint8Array(iv)) +
+ "." +
+ uint8ToBase64Url(new Uint8Array(ciphered)) +
+ "." +
+ uint8ToBase64Url(new Uint8Array(tag)));
+}
diff --git a/web/js/crypto.test.js b/web/js/crypto.test.js
new file mode 100644
index 0000000..8e3247c
--- /dev/null
+++ b/web/js/crypto.test.js
@@ -0,0 +1,49 @@
+async function testCrypto() {
+ let keys = await deriveScopedKey(
+ "8b2e1303e21eee06a945683b8d495b9bf079ca30baa37eb8392d9ffa4767be45",
+ "aeaa1725c7a24ff983c6295725d5fc9b",
+ "app_key:https%3A//example.com",
+ "517d478cb4f994aa69930416648a416fdaa1762c5abf401a2acf11a0f185e98d",
+ 1510726317);
+ if (keys.k != "Kkbk1_Q0oCcTmggeDH6880bQrxin2RLu5D00NcJazdQ") throw "assert";
+ if (keys.kid != "1510726317-Voc-Eb9IpoTINuo9ll7bjA") throw "assert";
+ if (keys.kty != "oct") throw "assert";
+
+ let keys_jwk = "eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6IlNpQm42dWViamlnbVF" +
+ "xdzRUcE56czNBVXlDYWUxX3NHMmI5RnpocTNGeW8iLCJ5IjoicTk5WHExUld" +
+ "OVEZwazk5cGRRT1NqVXZ3RUxzczUxUGttQUdDWGhMZk1WNCJ9";
+ let bundle = {
+ app_key: {
+ k: "Kkbk1_Q0oCcTmggeDH6880bQrxin2RLu5D00NcJazdQ",
+ kid: "1510726317-Voc-Eb9IpoTINuo9ll7bjA",
+ kty: "oct"
+ }
+ };
+ let local_key = {
+ "crv": "P-256",
+ "kty": "EC",
+ "d": "X9tJG0Ue55tuepC-6msMg04Qv5gJtL95AIJ0X0gDj8Q",
+ "x": "N4zPRazB87vpeBgHzFvkvd_48owFYYxEVXRMrOU6LDo",
+ "y": "4ncUxN6x_xT1T1kzy_S_V2fYZ7uUJT_HVRNZBLJRsxU"
+ };
+ let iv = new Uint8Array([0xff, 0x4b, 0x18, 0x7f, 0xb1, 0xdd, 0x5a, 0xe4, 0x6f, 0xd9, 0xc3, 0x34]);
+ let k = await crypto.subtle.importKey(
+ "jwk",
+ local_key,
+ { name: "ECDH", namedCurve: "P-256" },
+ true,
+ ['deriveKey']);
+ console.log(await encryptScopedKeys(
+ bundle,
+ keys_jwk,
+ { publicKey: k, privateKey: k },
+ iv));
+
+ console.log(await deriveScopedKey(
+ "eaf9570b7219a4187d3d6bf3cec2770c2e0719b7cc0dfbb38243d6f1881675e9",
+ "aeaa1725c7a24ff983c6295725d5fc9b",
+ "https://identity.mozilla.com/apps/oldsync",
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ 1510726317123
+ ));
+}
diff --git a/web/js/main.js b/web/js/main.js
new file mode 100644
index 0000000..bffcdfb
--- /dev/null
+++ b/web/js/main.js
@@ -0,0 +1,761 @@
+// https://mozilla.github.io/ecosystem-platform/reference/webchannels-in-firefox-desktop-fennec
+
+'use strict';
+
+import AuthClient from './auth-client/browser';
+import { deriveScopedKey, encryptScopedKeys, urlatob } from './crypto';
+
+class Channel {
+ constructor() {
+ this.waiting = {};
+ this.idseq = 0;
+
+ window.addEventListener(
+ 'WebChannelMessageToContent',
+ ev => {
+ if (ev.detail.message.error) {
+ for (const wait in this.waiting) {
+ this.waiting[wait].reject(new Error(ev.detail.message.error));
+ }
+ this.waiting = {};
+ } else {
+ let message = this.waiting[ev.detail.message.messageId];
+ delete this.waiting[ev.detail.message.messageId];
+ if (message) {
+ message.resolve(ev.detail.message.data);
+ }
+ }
+ },
+ true
+ );
+ }
+
+ _send(id, command, data) {
+ const messageId = this.idseq++;
+ window.dispatchEvent(
+ new window.CustomEvent('WebChannelMessageToChrome', {
+ detail: JSON.stringify({
+ id,
+ message: { command, data, messageId },
+ }),
+ })
+ );
+ return messageId;
+ }
+
+ _send_wait(id, command, data) {
+ return new Promise((resolve, reject) => {
+ let messageId = this._send(id, command, data);
+ this.waiting[messageId] = { resolve, reject };
+ });
+ }
+
+ async getStatus(isPairing, service) {
+ return await channel._send_wait(
+ 'account_updates',
+ 'fxaccounts:fxa_status',
+ { isPairing, service });
+ }
+
+ loadCredentials(email, creds, engines) {
+ let services = undefined;
+ if (engines) {
+ services = {
+ sync: {
+ declinedEngines: engines.declined,
+ offeredEngines: engines.offered,
+ }
+ };
+ }
+ this._send('account_updates', 'fxaccounts:login', {
+ customizeSync: !!engines,
+ services,
+ email,
+ keyFetchToken: creds.keyFetchToken,
+ sessionToken: creds.sessionToken,
+ uid: creds.uid,
+ unwrapBKey: creds.unwrapBKey,
+ verified: creds.verified || false,
+ verifiedCanLinkAccount: false
+ });
+ }
+
+ passwordChanged(email, uid) {
+ this._send('account_updates', 'fxaccounts:change_password', {
+ email,
+ uid,
+ verified: true,
+ });
+ }
+
+ accountDestroyed(email, uid) {
+ this._send('account_updates', 'fxaccounts:delete', {
+ email,
+ uid,
+ });
+ }
+}
+
+class ProfileClient {
+ constructor(authClient, session) {
+ this._authClient = authClient;
+ this._session = session;
+ }
+
+ async _acquireToken(scope) {
+ // NOTE the api has destroy commands for tokens, the client doesn't
+ let token = await this._authClient.createOAuthToken(
+ this._session.signedInUser.sessionToken,
+ this._session.clientId,
+ { scope, ttl: 60 });
+ return token.access_token;
+ }
+
+ async _request(endpoint, token, options) {
+ options = options || {};
+ options.mode = "same-origin";
+ options.headers = new Headers({
+ authorization: `bearer ${token}`,
+ ...(options.headers || {}),
+ });
+ let req = new Request(`${client_config.profile_server_base_url}${endpoint}`, options);
+ let resp = await fetch(req);
+ if (!resp.ok) throw new Error(resp.statusText);
+ return await resp.json();
+ }
+
+ async getProfile() {
+ let token = await this._acquireToken("profile");
+ return await this._request("/v1/profile", token);
+ }
+
+ async setDisplayName(name) {
+ let token = await this._acquireToken("profile:display_name:write");
+ return await this._request("/v1/display_name", token, {
+ method: "POST",
+ body: JSON.stringify({ displayName: name }),
+ });
+ }
+
+ async setAvatar(avatar) {
+ let token = await this._acquireToken("profile:avatar:write");
+ return await this._request("/v1/avatar/upload", token, {
+ method: "POST",
+ body: avatar.slice(),
+ headers: {
+ "Content-Type": avatar.type,
+ },
+ });
+ }
+}
+
+function $(id) {
+ return document.getElementById(id);
+}
+
+function showMessage(message, className) {
+ let m = $("message-modal");
+ m.className = className || "";
+ $("message").innerText = message;
+ $("message-modal-close").hidden = true;
+ m.showModal();
+}
+
+function showError(prefix, e) {
+ console.log(e);
+ if (e instanceof Object && "errno" in e)
+ e = e.message;
+ showMessage(prefix + String(e), "error");
+}
+
+function showRecoverableError(prefix, e) {
+ if (e === undefined) {
+ e = prefix;
+ prefix = "";
+ }
+
+ let close = $("message-modal-close");
+ close.onclick = ev => {
+ ev.preventDefault();
+ hideMessage();
+ };
+ showError(prefix, e, "error");
+ close.hidden = false;
+}
+
+function hideMessage() {
+ $("message-modal").close();
+}
+
+function wrapHandler(fn) {
+ function failed(e) {
+ showRecoverableError("failed: ", e);
+ console.log(e);
+ }
+
+ return function() {
+ try {
+ let val = fn.apply(this, arguments);
+ if (val instanceof Promise) val = val.catch(failed);
+ return val;
+ } catch (e) {
+ failed(e);
+ throw e;
+ }
+ };
+}
+
+function switchTo(id, tabID) {
+ for (const screen of document.getElementsByClassName("container")) {
+ screen.hidden = true;
+ }
+ $(id).hidden = false;
+
+ if (tabID) {
+ for (const tab of $(id).getElementsByClassName("tab")) {
+ tab.hidden = true;
+ }
+ $(tabID).hidden = false;
+ }
+}
+
+function dateDiffText(when) {
+ let diff = Math.round(((+new Date()) - (+when)) / 1000);
+ let finalize = diff < 0 ? (s => `in ${s}`) : (s => `${s} ago`);
+ diff = Math.abs(diff);
+ let s;
+ if (diff < 5)
+ return "now";
+ else if (diff < 60)
+ s = `${diff} second`;
+ else if (diff < 60 * 60)
+ s = `${Math.round(diff / 60)} minute`;
+ else if (diff < 60 * 60 * 24)
+ s = `${Math.round(diff / 60 / 60)} hour`;
+ else if (diff < 60 * 60 * 24 * 31)
+ s = `${Math.round(diff / 60 / 60 / 24)} day`;
+ else
+ s = `${Math.round(diff / 60 / 60 / 24 / 31)} month`;
+ if (!/1 /.test(s))
+ s += "s";
+ return finalize(s);
+}
+
+//////////////////////////////////////////
+// initialization
+//////////////////////////////////////////
+
+let client_config;
+var channel = new Channel();
+const isAndroid = /Android/.test(navigator.userAgent);
+
+document.body.onload = () => {
+ showMessage("Loading ...", "animate");
+
+ if (isAndroid) {
+ document.head.appendChild($("tpl-fenix-style").content);
+ }
+
+ fetch("/.well-known/fxa-client-configuration")
+ .then(resp => {
+ if (!resp.ok) throw new Error(resp.statusText);
+ return resp.json();
+ })
+ .then(data => {
+ client_config = data;
+ hideMessage();
+ return initUI();
+ })
+ .catch(e => {
+ showError("initialization failed: ", e);
+ });
+};
+
+async function initUI() {
+ return isAndroid
+ ? await initUIAndroid()
+ : await initUIDesktop();
+}
+
+async function initUIDesktop() {
+ let present = wrapHandler(async () => {
+ if (window.location.hash.startsWith("#/verify/")) {
+ verify_init(JSON.parse(urlatob(window.location.hash.substr(9))));
+ } else if (window.location.hash.startsWith("#/register/")) {
+ signup_init(window.location.hash.substr(11));
+ } else {
+ let data = await channel.getStatus();
+ if (!data.signedInUser) {
+ signin_init();
+ } else if (window.location.hash == "#/settings") {
+ settings_init(data);
+ } else if (window.location.hash == "#/settings/change-password") {
+ settings_chpw_init(data);
+ } else if (window.location.hash == "#/settings/destroy") {
+ settings_destroy_init(data);
+ } else if (window.location.hash == "#/force_auth") {
+ signin_init();
+ } else if (window.location.hash == "#/generate-invite") {
+ generate_invite_init(data);
+ } else {
+ switchTo("desktop-signedin");
+ }
+ }
+ hideMessage();
+ });
+
+ window.onpopstate = () => window.setTimeout(present, 0);
+
+ for (let a of $("desktop-settings").querySelectorAll("nav a")) {
+ a.onclick = async ev => {
+ ev.preventDefault();
+ window.location = ev.target.href;
+ await present();
+ };
+ }
+ await present();
+}
+
+async function initUIAndroid() {
+ fenix_signin_init();
+}
+
+//////////////////////////////////////////
+// signin form
+//////////////////////////////////////////
+
+function signin_init() {
+ switchTo("desktop-signin");
+ let frm = $("frm-signin");
+ frm.onsubmit = wrapHandler(signin_run);
+ frm.getElementsByClassName("_signup")[0].onclick = wrapHandler(signin_signup_instead);
+ frm.getElementsByClassName("_reset")[0].onclick = wrapHandler(ev => {
+ ev.preventDefault();
+ password_reset_init();
+ });
+}
+
+function signin_signup_instead(ev) {
+ ev.preventDefault();
+ signup_init();
+}
+
+// NOTE it looks like firefox discards its session token before asking for reauth
+// (eg if oauth verification of a sync token fails). we can't use the reauth
+// endpoint for that, so we'll just always log in.
+async function signin_run(ev) {
+ ev.preventDefault();
+ let frm = ev.target;
+
+ if (frm[0].value == "" || !frm[0].validity.valid) {
+ return showRecoverableError("email is not valid");
+ }
+ if (frm[1].value == "" || !frm[1].validity.valid) {
+ return showRecoverableError("password is not valid");
+ }
+
+ let c = new AuthClient(client_config.auth_server_base_url);
+ try {
+ let session = await c.signIn(frm["email"].value, frm["password"].value, {
+ keys: true,
+ });
+
+ signin_confirm_init(frm["email"].value, session);
+ } catch (e) {
+ if (e.errno == 104) {
+ showRecoverableError(`
+ account is not verified. please verify or wait a few minutes and register again.
+ `.trim());
+ } else {
+ throw e;
+ }
+ }
+}
+
+//////////////////////////////////////////
+// sign-in confirmation
+//////////////////////////////////////////
+
+function signin_confirm_init(email, session) {
+ let frm = $("frm-signin-confirm");
+ frm.getElementsByClassName("_resend")[0].onclick = wrapHandler(async ev => {
+ ev.preventDefault();
+
+ let c = new AuthClient(client_config.auth_server_base_url);
+ await c.sessionResendVerifyCode(session.sessionToken);
+ alert("code resent!");
+ });
+ frm.onsubmit = wrapHandler(async ev => {
+ ev.preventDefault();
+
+ let c = new AuthClient(client_config.auth_server_base_url);
+ c.sessionVerifyCode(session.sessionToken, frm["code"].value)
+ .then(resp => {
+ channel.loadCredentials(email, session, { offered: [], declined: [] });
+ switchTo("desktop-signedin");
+ })
+ .catch(e => {
+ showError("verification failed: ", e);
+ });
+ });
+ switchTo("desktop-signin-confirm");
+}
+
+//////////////////////////////////////////
+// password reset
+//////////////////////////////////////////
+
+function password_reset_init() {
+ let frm = $("frm-resetpw");
+ frm.onsubmit = wrapHandler(async ev => {
+ ev.preventDefault();
+
+ let c = new AuthClient(client_config.auth_server_base_url);
+ let token = await c.passwordForgotSendCode(frm["email"].value);
+ let code = prompt(`
+ please enter your password reset code from the email you've just received
+ `.trim());
+ if (code === null) {
+ return showRecoverableError("password reset aborted");
+ }
+
+ let reset_token = await c.passwordForgotVerifyCode(code, token.passwordForgotToken);
+ password_reset_newpw(frm['email'].value, reset_token.accountResetToken);
+ });
+ switchTo("desktop-resetpw");
+}
+
+function password_reset_newpw(email, reset_token) {
+ let frm = $("frm-resetpw-newpw");
+ frm.onsubmit = wrapHandler(async ev => {
+ ev.preventDefault();
+
+ let c = new AuthClient(client_config.auth_server_base_url);
+ if (frm['new'].value != frm['new-confirm'].value) {
+ return showRecoverableError("passwords don't match!");
+ }
+
+ await c.accountReset(email, frm['new'].value, reset_token);
+ switchTo("desktop-signin");
+ });
+ switchTo("desktop-resetpw-newpw");
+}
+
+//////////////////////////////////////////
+// signup form
+//////////////////////////////////////////
+
+function signup_init(code) {
+ let frm = $("frm-signup");
+ switchTo("desktop-signup");
+ frm.onsubmit = wrapHandler(async ev => signup_run(ev, code));
+}
+
+async function signup_run(ev, code) {
+ ev.preventDefault();
+ let frm = ev.target;
+
+ if (frm[0].value == "" || !frm[0].validity.valid) {
+ return showRecoverableError("email is not valid");
+ }
+ if (frm[1].value == "" || !frm[1].validity.valid) {
+ return showRecoverableError("password is not valid");
+ }
+
+ let c = new AuthClient(client_config.auth_server_base_url);
+ let session = await c.signUp(frm["email"].value, frm["password"].value, {
+ keys: true,
+ style: code,
+ });
+
+ cwts_continue(frm["email"].value, session);
+}
+
+//////////////////////////////////////////
+// choose-what-to-sync form
+//////////////////////////////////////////
+
+function cwts_continue(email, session) {
+ // TODO we don't query browser capabilities, but we probably should
+ let frm = $("frm-cwts");
+ frm.onsubmit = wrapHandler((ev) => {
+ ev.preventDefault();
+
+ let offered = [], declined = [];
+ for (const engine of frm.querySelectorAll("input[type=checkbox]")) {
+ if (!engine.checked) declined.push(engine.name);
+ else offered.push(engine.name);
+ }
+ channel.loadCredentials(email, session, { offered, declined });
+
+ switchTo("desktop-signup-unverified");
+ });
+ switchTo("desktop-cwts");
+}
+
+//////////////////////////////////////////
+// verification
+//////////////////////////////////////////
+
+function verify_init(context) {
+ let c = new AuthClient(client_config.auth_server_base_url);
+ c.verifyCode(context.uid, context.code)
+ .then(resp => {
+ switchTo("desktop-signedin");
+ })
+ .catch(e => {
+ showError("verification failed: ", e);
+ });
+}
+
+//////////////////////////////////////////
+// settings root
+//////////////////////////////////////////
+
+function settings_init(session) {
+ switchTo("desktop-settings", "desktop-settings-main");
+
+ let inner = async () => {
+ showMessage("Loading ...", "animate");
+
+ let ac = new AuthClient(client_config.auth_server_base_url)
+ let pc = new ProfileClient(ac, session);
+
+ let initProfile = async () => {
+ let profile = await pc.getProfile();
+ $("settings-name").value = profile.displayName || "";
+ $("frm-settings-name").onsubmit = wrapHandler(async ev => {
+ showMessage("Applying ...")
+ ev.preventDefault();
+ await pc.setDisplayName($("settings-name").value);
+ hideMessage();
+ });
+ $("settings-avatar-img").src = profile.avatar;
+ $("frm-settings-avatar").onsubmit = wrapHandler(async ev => {
+ showMessage("Saving ...")
+ ev.preventDefault();
+ await pc.setAvatar($("settings-avatar").files[0]);
+ settings_init(session);
+ });
+ };
+
+ await Promise.all([
+ initProfile(),
+ settings_populateClients(ac, session),
+ ]);
+
+ hideMessage();
+ };
+
+ inner().catch(e => {
+ showError("initialization failed: ", e);
+ });
+}
+
+async function settings_populateClients(authClient, session) {
+ let clients = await authClient.attachedClients(session.signedInUser.sessionToken);
+
+ let body = $("settings-clients").getElementsByTagName("tbody")[0];
+ body.innerHTML = "";
+ for (const c of clients) {
+ let row = document.createElement("tr");
+ let add = (val, tip) => {
+ let cell = document.createElement("td");
+ cell.innerText = val || "";
+ if (tip) cell.title = tip;
+ row.appendChild(cell);
+ };
+ let addDateDiff = val => {
+ let text = dateDiffText(new Date(val));
+ add(text, new Date(val));
+ };
+ add(c.name);
+ add(c.deviceType);
+ addDateDiff(c.createdTime * 1000);
+ addDateDiff(c.lastAccessTime * 1000);
+ add(c.scope ? "yes" : "", (c.scope ? c.scope : "").replace(/ +/g, "\n"));
+ if (c.isCurrentSession) {
+ let cell = document.createElement("td");
+ cell.innerHTML = `<span class="disabled">current session</span>`;
+ row.appendChild(cell);
+ } else if (c.deviceId || c.sessionTokenId || c.refreshTokenId) {
+ let remove = document.createElement("button");
+ remove.innerText = 'remove';
+ remove.onclick = wrapHandler(async ev => {
+ ev.preventDefault();
+ let info = { clientId: c.clientId };
+ if (c.deviceId)
+ info.deviceId = c.deviceId;
+ else if (c.sessionTokenId)
+ info.sessionTokenId = c.sessionTokenId;
+ else if (c.refreshTokenId)
+ info.refreshTokenId = c.refreshTokenId;
+ showMessage("Processing ...", "animate");
+ await authClient.attachedClientDestroy(session.signedInUser.sessionToken, info);
+ await settings_populateClients(authClient, session);
+ hideMessage();
+ });
+ row.appendChild(remove);
+ }
+ body.appendChild(row);
+ }
+}
+
+//////////////////////////////////////////
+// settings change password
+//////////////////////////////////////////
+
+function settings_chpw_init(session) {
+ switchTo("desktop-settings", "desktop-settings-chpw");
+ let frm = $("frm-settings-chpw");
+ frm.onsubmit = wrapHandler(async ev => {
+ ev.preventDefault();
+ let frm = ev.target;
+
+ if (frm["new"].value != frm["new-confirm"].value) {
+ showRecoverableError("passwords don't match");
+ return;
+ }
+
+ let c = new AuthClient(client_config.auth_server_base_url);
+ let resp = await c.passwordChange(session.signedInUser.email, frm['old'].value, frm['new'].value, {});
+
+ channel.passwordChanged(session.signedInUser.email, session.signedInUser.uid);
+ alert("password changed");
+ });
+}
+
+//////////////////////////////////////////
+// settings destroy
+//////////////////////////////////////////
+
+function settings_destroy_init(session) {
+ switchTo("desktop-settings", "desktop-settings-destroy");
+ let frm = $("frm-settings-destroy");
+ frm.onsubmit = wrapHandler(async ev => {
+ ev.preventDefault();
+ let frm = ev.target;
+
+ if (frm[0].value == "" || !frm[0].validity.valid) {
+ return showRecoverableError("email is not valid");
+ }
+ if (frm[1].value == "" || !frm[1].validity.valid) {
+ return showRecoverableError("password is not valid");
+ }
+
+ let c = new AuthClient(client_config.auth_server_base_url);
+ await c.accountDestroy(frm["email"].value, frm["password"].value);
+
+ channel.accountDestroyed(frm["email"], session.signedInUser.uid);
+ switchTo("desktop-deleted");
+ });
+}
+
+//////////////////////////////////////////
+// generate invite
+//////////////////////////////////////////
+
+function generate_invite_init(session) {
+ let frm = $("frm-generate-invite");
+ $("desktop-generate-invite-result").hidden = true;
+ frm.onsubmit = wrapHandler(async ev => {
+ ev.preventDefault();
+
+ let server_url = new URL(client_config.auth_server_base_url);
+ server_url.pathname = server_url.pathname.split("/").slice(0, -1).join("/") + "/_invite";
+ let c = new AuthClient(server_url.toString());
+ let resp = await c.sessionPost(
+ "/generate",
+ session.signedInUser.sessionToken,
+ { 'ttl_hours': parseInt(frm["ttl"].value) });
+
+ $("desktop-generate-invite-result-link").href = resp.url;
+ $("desktop-generate-invite-result-link").innerText = resp.url;
+ $("desktop-generate-invite-result").hidden = false;
+
+ });
+ switchTo("desktop-generate-invite");
+}
+
+//////////////////////////////////////////
+// fenix signin
+//////////////////////////////////////////
+
+function fenix_signin_init() {
+ switchTo("fenix-signin-warning");
+ $("fenix-signin-dialog-show").onclick = wrapHandler(async ev => {
+ ev.preventDefault();
+ switchTo("fenix-signin");
+ $("frm-fenix-signin").onsubmit = wrapHandler(fenix_signin_step2);
+ });
+}
+
+async function fenix_signin_step2(ev) {
+ ev.preventDefault();
+
+ let url = new URL(window.location);
+ let params = new URLSearchParams(url.search);
+ let param = (p) => {
+ let val = params.get(p);
+ if (val === undefined) throw `missing parameter ${p}`;
+ return val;
+ };
+
+ let frm = ev.target;
+ let c = new AuthClient(client_config.auth_server_base_url);
+ let session = await c.signIn(frm["email"].value, frm["password"].value, {
+ keys: true,
+ });
+ let verifyCode = prompt("enter verify code");
+ await c.sessionVerifyCode(session.sessionToken, verifyCode);
+ try {
+ let keys = await c.accountKeys(session.keyFetchToken, session.unwrapBKey);
+ let scoped_keys = await c.getOAuthScopedKeyData(
+ session.sessionToken,
+ param("client_id"),
+ param("scope"));
+ for (var scope in scoped_keys) {
+ scoped_keys[scope] = await deriveScopedKey(
+ keys.kB,
+ session.uid,
+ scope,
+ scoped_keys[scope].keyRotationSecret,
+ scoped_keys[scope].keyRotationTimestamp);
+ }
+
+ let keys_jwe = await encryptScopedKeys(scoped_keys, param("keys_jwk"));
+
+ let code = await c.createOAuthCode(
+ session.sessionToken,
+ param("client_id"),
+ param("state"),
+ {
+ access_type: param("access_type"),
+ keys_jwe,
+ response_type: param("response_type"),
+ scope: param("scope"),
+ code_challenge_method: param("code_challenge_method"),
+ code_challenge: param("code_challenge"),
+ });
+
+ console.log(`browser.tabs.executeScript({code: \`
+ port = browser.runtime.connectNative("mozacWebchannel");
+ port.postMessage({
+ id: "account_updates",
+ message: {
+ command: "fxaccounts:oauth_login",
+ data: {
+ "code": "${code.code}",
+ "state": "${code.state}",
+ "redirect": "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel",
+ "action": "signin"
+ },
+ messageId: 1,
+ }});
+ \`});`);
+
+ switchTo("fenix-signin-warning");
+ } finally {
+ c.sessionDestroy(session.sessionToken);
+ }
+}