diff options
Diffstat (limited to 'web')
-rw-r--r-- | web/index.html | 392 | ||||
-rw-r--r-- | web/js/browser/browser.js | 4 | ||||
-rw-r--r-- | web/js/browser/lib/client.js | 792 | ||||
-rw-r--r-- | web/js/browser/lib/crypto.js | 163 | ||||
-rw-r--r-- | web/js/browser/lib/hawk.d.ts | 24 | ||||
-rw-r--r-- | web/js/browser/lib/hawk.js | 145 | ||||
-rw-r--r-- | web/js/browser/lib/recoveryKey.js | 38 | ||||
-rw-r--r-- | web/js/browser/lib/utils.js | 26 | ||||
-rw-r--r-- | web/js/crypto.js | 137 | ||||
-rw-r--r-- | web/js/crypto.test.js | 49 | ||||
-rw-r--r-- | web/js/main.js | 761 |
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); + } +} |