From 2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328 Mon Sep 17 00:00:00 2001 From: pennae Date: Wed, 13 Jul 2022 10:33:30 +0200 Subject: initial import --- web/js/browser/browser.js | 4 + web/js/browser/lib/client.js | 792 ++++++++++++++++++++++++++++++++++++++ web/js/browser/lib/crypto.js | 163 ++++++++ web/js/browser/lib/hawk.d.ts | 24 ++ web/js/browser/lib/hawk.js | 145 +++++++ web/js/browser/lib/recoveryKey.js | 38 ++ web/js/browser/lib/utils.js | 26 ++ 7 files changed, 1192 insertions(+) create mode 100644 web/js/browser/browser.js create mode 100644 web/js/browser/lib/client.js create mode 100644 web/js/browser/lib/crypto.js create mode 100644 web/js/browser/lib/hawk.d.ts create mode 100644 web/js/browser/lib/hawk.js create mode 100644 web/js/browser/lib/recoveryKey.js create mode 100644 web/js/browser/lib/utils.js (limited to 'web/js/browser') 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 @@ +/// +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; +export declare function header(method: string, uri: string, token: string, kind: string, options: { + payload?: string; + timestamp?: number; + nonce?: string; + contentType?: string; + localtimeOffsetMsec?: number; +}): Promise; 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 + 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])); +} -- cgit v1.2.3