1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { CommonUtils } from "resource://services-common/utils.sys.mjs";
7 import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs";
11 DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY,
15 } from "resource://gre/modules/FxAccountsCommon.sys.mjs";
17 // The following top-level fields have since been deprecated and exist here purely
18 // to be removed from the account state when seen. After a reasonable period of time
19 // has passed, where users have been migrated away from those keys they should be safe to be removed
20 const DEPRECATED_DERIVED_KEYS_NAMES = [
29 // This scope and its associated key material were used by the old Kinto webextension
30 // storage backend, but has since been decommissioned. It's here entirely so that we
31 // remove the corresponding key from storage if present. We should be safe to remove it
32 // after some sensible period of time has elapsed to allow most clients to update.
33 const DEPRECATED_SCOPE_WEBEXT_SYNC = "sync:addon_storage";
35 // These are the scopes that correspond to new storage for the `LEGACY_DERIVED_KEYS_NAMES`.
36 // We will, if necessary, migrate storage for those keys so that it's associated with
38 const LEGACY_DERIVED_KEY_SCOPES = [SCOPE_OLD_SYNC];
40 // These are scopes that we used to store, but are no longer using,
41 // and hence should be deleted from storage if present.
42 const DEPRECATED_KEY_SCOPES = [
43 DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY,
44 DEPRECATED_SCOPE_WEBEXT_SYNC,
48 * Utilities for working with key material linked to the user's account.
50 * Each Firefox Account has 32 bytes of root key material called `kB` which is
51 * linked to the user's password, and which is used to derive purpose-specific
52 * subkeys for things like encrypting the user's sync data. This class provides
53 * the interface for working with such key material.
55 * Most recent FxA clients obtain appropriate key material directly as part of
56 * their sign-in flow, using a special extension of the OAuth2.0 protocol to
57 * securely deliver the derived keys without revealing `kB`. Keys obtained in
58 * in this way are called "scoped keys" since each corresponds to a particular
59 * OAuth scope, and this class provides a `getKeyForScope` method that is the
60 * preferred method for consumers to work with such keys.
62 * However, since the FxA integration in Firefox Desktop pre-dates the use of
63 * OAuth2.0, we also have a lot of code for fetching keys via an older flow.
64 * This flow uses a special `keyFetchToken` to obtain `kB` and then derive various
65 * sub-keys from it. Consumers should consider this an internal implementation
66 * detail of the `FxAccountsKeys` class and should prefer `getKeyForScope` where
67 * possible. We intend to remove support for Firefox ever directly handling `kB`
68 * at some point in the future.
70 export class FxAccountsKeys {
71 constructor(fxAccountsInternal) {
72 this._fxai = fxAccountsInternal;
76 * Checks if we currently have the key for a given scope, or if we have enough to
77 * be able to successfully fetch and unwrap it for the signed-in-user.
79 * Unlike `getKeyForScope`, this will not hit the network to fetch wrapped keys if
80 * they aren't available locally.
82 canGetKeyForScope(scope) {
83 return this._fxai.withCurrentAccountState(async currentState => {
84 let userData = await currentState.getUserAccountData();
86 throw new Error("Can't possibly get keys; User is not signed in");
88 if (!userData.verified) {
89 log.info("Can't get keys; user is not verified");
93 if (userData.scopedKeys && userData.scopedKeys.hasOwnProperty(scope)) {
97 // If we have a `keyFetchToken` we can fetch `kB`.
98 if (userData.keyFetchToken) {
102 log.info("Can't get keys; no key material or tokens available");
108 * Get the key for a specified OAuth scope.
110 * @param {String} scope The OAuth scope whose key should be returned
112 * @return Promise<JWK>
113 * If no key is available the promise resolves to `null`.
114 * If a key is available for the given scope, th promise resolves to a JWK with fields:
116 * scope: The requested scope
117 * kid: Key identifier
118 * k: Derived key material
119 * kty: Always "oct" for scoped keys
123 async getKeyForScope(scope) {
124 const { scopedKeys } = await this._loadOrFetchKeys();
125 if (!scopedKeys.hasOwnProperty(scope)) {
126 throw new Error(`Key not available for scope "${scope}"`);
130 ...scopedKeys[scope],
135 * Validates if the given scoped keys are valid keys
137 * @param { Object } scopedKeys: The scopedKeys bundle
139 * @return { Boolean }: true if the scopedKeys bundle is valid, false otherwise
141 validScopedKeys(scopedKeys) {
142 for (const expectedScope of Object.keys(scopedKeys)) {
143 const key = scopedKeys[expectedScope];
145 !key.hasOwnProperty("scope") ||
146 !key.hasOwnProperty("kid") ||
147 !key.hasOwnProperty("kty") ||
148 !key.hasOwnProperty("k")
152 const { scope, kid, kty, k } = key;
153 if (scope != expectedScope || kty != "oct") {
156 // We verify the format of the key id is `timestamp-fingerprint`
157 if (!kid.includes("-")) {
160 const [keyRotationTimestamp, fingerprint] = kid.split("-");
161 // We then verify that the timestamp is a valid timestamp
162 const keyRotationTimestampNum = Number(keyRotationTimestamp);
163 // If the value we got back is falsy it's not a valid timestamp
164 // note that we treat a 0 timestamp as invalid
165 if (!keyRotationTimestampNum) {
168 // For extra safety, we validate that the timestamp can be converted into a valid
170 const date = new Date(keyRotationTimestampNum);
171 if (isNaN(date.getTime()) || date.getTime() <= 0) {
175 // Finally, we validate that the fingerprint and the key itself are valid base64 values
176 // Note that we can't verify the fingerprint is correct here because we don't have kb
177 const validB64String = b64String => {
180 decoded = ChromeUtils.base64URLDecode(b64String, {
188 if (!validB64String(fingerprint) || !validB64String(k)) {
196 * Format a JWK kid as hex rather than base64.
198 * This is a backwards-compatibility helper for code that needs a raw key fingerprint
199 * for use as a key identifier, rather than the timestamp+fingerprint format used by
202 * @param {Object} jwk The JWK from which to extract the `kid` field as hex.
205 // The kid format is "{timestamp}-{b64url(fingerprint)}", but we have to be careful
206 // because the fingerprint component may contain "-" as well, and we want to ensure
207 // the timestamp component was non-empty.
208 const idx = jwk.kid.indexOf("-") + 1;
210 throw new Error(`Invalid kid: ${jwk.kid}`);
212 return CommonUtils.base64urlToHex(jwk.kid.slice(idx));
216 * Fetch encryption keys for the signed-in-user from the FxA API server.
218 * Not for user consumption. Exists to cause the keys to be fetched.
220 * Returns user data so that it can be chained with other methods.
223 * The promise resolves to the credentials object of the signed-in user:
225 * email: The user's email address
226 * uid: The user's unique id
227 * sessionToken: Session for the FxA server
228 * scopedKeys: Object mapping OAuth scopes to corresponding derived keys
229 * verified: email verification status
231 * @throws If there is no user signed in.
233 async _loadOrFetchKeys() {
234 return this._fxai.withCurrentAccountState(async currentState => {
236 let userData = await currentState.getUserAccountData();
238 throw new Error("Can't get keys; User is not signed in");
240 // If we have all the keys in latest storage location, we're good.
241 if (userData.scopedKeys) {
243 LEGACY_DERIVED_KEY_SCOPES.every(scope =>
244 userData.scopedKeys.hasOwnProperty(scope)
246 !DEPRECATED_KEY_SCOPES.some(scope =>
247 userData.scopedKeys.hasOwnProperty(scope)
249 !DEPRECATED_DERIVED_KEYS_NAMES.some(keyName =>
250 userData.hasOwnProperty(keyName)
256 // If not, we've got work to do, and we debounce to avoid duplicating it.
257 if (!currentState.whenKeysReadyDeferred) {
258 currentState.whenKeysReadyDeferred = Promise.withResolvers();
259 // N.B. we deliberately don't `await` here, and instead use the promise
260 // to resolve `whenKeysReadyDeferred` (which we then `await` below).
261 this._migrateOrFetchKeys(currentState, userData).then(
263 currentState.whenKeysReadyDeferred.resolve(dataWithKeys);
264 currentState.whenKeysReadyDeferred = null;
267 currentState.whenKeysReadyDeferred.reject(err);
268 currentState.whenKeysReadyDeferred = null;
272 return await currentState.whenKeysReadyDeferred.promise;
274 return this._fxai._handleTokenError(err);
280 * Set externally derived scoped keys in internal storage
281 * @param { Object } scopedKeys: The scoped keys object derived by the oauth flow
283 * @return { Promise }: A promise that resolves if the keys were successfully stored,
284 * or rejects if we failed to persist the keys, or if the user is not signed in already
286 async setScopedKeys(scopedKeys) {
287 return this._fxai.withCurrentAccountState(async currentState => {
288 const userData = await currentState.getUserAccountData();
290 throw new Error("Cannot persist keys, no user signed in");
292 await currentState.updateUserAccountData({
299 * Key storage migration or fetching logic.
301 * This method contains the doing-expensive-operations part of the logic of
302 * _loadOrFetchKeys(), factored out into a separate method so we can debounce it.
305 async _migrateOrFetchKeys(currentState, userData) {
306 // If the required scopes are present in `scopedKeys`, then we know that we've
307 // previously applied all earlier migrations
308 // so we are safe to delete deprecated fields that older migrations
309 // might have depended on.
311 userData.scopedKeys &&
312 LEGACY_DERIVED_KEY_SCOPES.every(scope =>
313 userData.scopedKeys.hasOwnProperty(scope)
316 return this._removeDeprecatedKeys(currentState, userData);
319 // Otherwise, we need to fetch from the network and unwrap.
320 if (!userData.sessionToken) {
321 throw new Error("No sessionToken");
323 if (!userData.keyFetchToken) {
324 throw new Error("No keyFetchToken");
326 return this._fetchAndUnwrapAndDeriveKeys(
328 userData.sessionToken,
329 userData.keyFetchToken
334 * Removes deprecated keys from storage and returns an
335 * updated user data object
337 async _removeDeprecatedKeys(currentState, userData) {
338 // Bug 1838708: Delete any deprecated high level keys from storage
339 const keysToRemove = DEPRECATED_DERIVED_KEYS_NAMES.filter(keyName =>
340 userData.hasOwnProperty(keyName)
342 if (keysToRemove.length) {
343 const removedKeys = {};
344 for (const keyName of keysToRemove) {
345 removedKeys[keyName] = null;
347 await currentState.updateUserAccountData({
350 userData = await currentState.getUserAccountData();
352 // Bug 1697596 - delete any deprecated scoped keys from storage.
353 const scopesToRemove = DEPRECATED_KEY_SCOPES.filter(scope =>
354 userData.scopedKeys.hasOwnProperty(scope)
356 if (scopesToRemove.length) {
357 const updatedScopedKeys = {
358 ...userData.scopedKeys,
360 for (const scope of scopesToRemove) {
361 delete updatedScopedKeys[scope];
363 await currentState.updateUserAccountData({
364 scopedKeys: updatedScopedKeys,
366 userData = await currentState.getUserAccountData();
372 * Fetch keys from the server, unwrap them, and derive required sub-keys.
374 * Once the user's email is verified, we can resquest the root key `kB` from the
375 * FxA server, unwrap it using the client-side secret `unwrapBKey`, and then
376 * derive all the sub-keys required for operation of the browser.
378 async _fetchAndUnwrapAndDeriveKeys(
385 `fetchAndUnwrapKeys: sessionToken: ${sessionToken}, keyFetchToken: ${keyFetchToken}`
389 // Sign out if we don't have the necessary tokens.
390 if (!sessionToken || !keyFetchToken) {
391 // this seems really bad and we should remove this - bug 1572313.
392 log.warn("improper _fetchAndUnwrapKeys() call: token missing");
393 await this._fxai.signOut();
397 // Deriving OAuth scoped keys requires additional metadata from the server.
398 // We fetch this first, before fetching the actual key material, because the
399 // keyFetchToken is single-use and we don't want to do a potentially-fallible
400 // operation after consuming it.
401 const scopedKeysMetadata = await this._fetchScopedKeysMetadata(
405 // Fetch the wrapped keys.
406 // It would be nice to be able to fetch this in a single operation with fetching
407 // the metadata above, but that requires server-side changes in FxA.
408 let { wrapKB } = await this._fetchKeys(keyFetchToken);
410 let data = await currentState.getUserAccountData();
412 // Sanity check that the user hasn't changed out from under us (which should
413 // be impossible given this is called within _withCurrentAccountState, but...)
414 if (data.keyFetchToken !== keyFetchToken) {
415 throw new Error("Signed in user changed while fetching keys!");
418 let kBbytes = CryptoUtils.xor(
419 CommonUtils.hexToBytes(data.unwrapBKey),
424 log.debug("kBbytes: " + kBbytes);
428 ...(await this._deriveKeys(data.uid, kBbytes, scopedKeysMetadata)),
429 keyFetchToken: null, // null values cause the item to be removed.
434 log.debug(`Keys Obtained: ${updateData.scopedKeys}`);
437 "Keys Obtained: " + Object.keys(updateData.scopedKeys).join(", ")
441 // Just double-check that scoped keys are there now
442 if (!updateData.scopedKeys) {
443 throw new Error(`user data missing: scopedKeys`);
446 await currentState.updateUserAccountData(updateData);
447 return currentState.getUserAccountData();
451 * Fetch the wrapped root key `wrapKB` from the FxA server.
453 * This consumes the single-use `keyFetchToken`.
455 _fetchKeys(keyFetchToken) {
456 let client = this._fxai.fxAccountsClient;
458 `Fetching keys with token ${!!keyFetchToken} from ${client.host}`
461 log.debug("fetchKeys - the token is " + keyFetchToken);
463 return client.accountKeys(keyFetchToken);
467 * Fetch additional metadata required for deriving scoped keys.
469 * This includes timestamps and a server-provided secret to mix in to
470 * the derived value in order to support key rotation.
472 async _fetchScopedKeysMetadata(sessionToken) {
473 // Hard-coded list of scopes that we know about.
474 // This list will probably grow in future.
475 const scopes = [SCOPE_OLD_SYNC].join(" ");
476 const scopedKeysMetadata =
477 await this._fxai.fxAccountsClient.getScopedKeyData(
482 // The server may decline us permission for some of those scopes, although it really shouldn't.
483 // We can live without them...except for the OLDSYNC scope, whose absence would be catastrophic.
484 if (!scopedKeysMetadata.hasOwnProperty(SCOPE_OLD_SYNC)) {
486 "The FxA server did not grant Firefox the `oldsync` scope; this is most unexpected!" +
487 ` scopes were: ${Object.keys(scopedKeysMetadata)}`
490 "The FxA server did not grant Firefox the `oldsync` scope"
493 return scopedKeysMetadata;
497 * Derive purpose-specific keys from the root FxA key `kB`.
499 * Everything that uses an encryption key from FxA uses a purpose-specific derived
500 * key. For new uses this is derived in a structured way based on OAuth scopes,
501 * while for legacy uses (mainly Firefox Sync) it is derived in a more ad-hoc fashion.
502 * This method does all the derivations for the uses that we know about.
505 async _deriveKeys(uid, kBbytes, scopedKeysMetadata) {
506 const scopedKeys = await this._deriveScopedKeys(
517 * Derive various scoped keys from the root FxA key `kB`.
519 * The `scopedKeysMetadata` object is additional information fetched from the server that
520 * that gets mixed in to the key derivation, with each member of the object corresponding
521 * to an OAuth scope that keys its own scoped key.
523 * As a special case for backwards-compatibility, sync-related scopes get special
524 * treatment to use a legacy derivation algorithm.
527 async _deriveScopedKeys(uid, kBbytes, scopedKeysMetadata) {
528 const scopedKeys = {};
529 for (const scope in scopedKeysMetadata) {
530 if (LEGACY_DERIVED_KEY_SCOPES.includes(scope)) {
531 scopedKeys[scope] = await this._deriveLegacyScopedKey(
535 scopedKeysMetadata[scope]
538 scopedKeys[scope] = await this._deriveScopedKey(
542 scopedKeysMetadata[scope]
550 * Derive a scoped key for an individual OAuth scope.
552 * The derivation here uses HKDF to combine:
553 * - the root key material kB
554 * - a unique identifier for this scoped key
555 * - a server-provided secret that allows for key rotation
556 * - the account uid as an additional salt
558 * It produces 32 bytes of (secret) key material along with a (potentially public)
559 * key identifier, formatted as a JWK.
561 * The full details are in the technical docs at
562 * https://docs.google.com/document/d/1IvQJFEBFz0PnL4uVlIvt8fBS_IPwSK-avK0BRIHucxQ/
564 async _deriveScopedKey(uid, kBbytes, scope, scopedKeyMetadata) {
565 kBbytes = CommonUtils.byteStringToArrayBuffer(kBbytes);
567 const FINGERPRINT_LENGTH = 16;
568 const KEY_LENGTH = 32;
569 const VALID_UID = /^[0-9a-f]{32}$/i;
570 const VALID_ROTATION_SECRET = /^[0-9a-f]{64}$/i;
572 // Engage paranoia mode for input data.
573 if (!VALID_UID.test(uid)) {
574 throw new Error("uid must be a 32-character hex string");
576 if (kBbytes.length != 32) {
577 throw new Error("kBbytes must be exactly 32 bytes");
580 typeof scopedKeyMetadata.identifier !== "string" ||
581 scopedKeyMetadata.identifier.length < 10
583 throw new Error("identifier must be a string of length >= 10");
585 if (typeof scopedKeyMetadata.keyRotationTimestamp !== "number") {
586 throw new Error("keyRotationTimestamp must be a number");
588 if (!VALID_ROTATION_SECRET.test(scopedKeyMetadata.keyRotationSecret)) {
589 throw new Error("keyRotationSecret must be a 64-character hex string");
592 // The server returns milliseconds, we want seconds as a string.
593 const keyRotationTimestamp =
594 "" + Math.round(scopedKeyMetadata.keyRotationTimestamp / 1000);
595 if (keyRotationTimestamp.length < 10) {
596 throw new Error("keyRotationTimestamp must round to a 10-digit number");
599 const keyRotationSecret = CommonUtils.hexToArrayBuffer(
600 scopedKeyMetadata.keyRotationSecret
602 const salt = CommonUtils.hexToArrayBuffer(uid);
603 const context = new TextEncoder().encode(
604 "identity.mozilla.com/picl/v1/scoped_key\n" + scopedKeyMetadata.identifier
607 const inputKey = new Uint8Array(64);
608 inputKey.set(kBbytes, 0);
609 inputKey.set(keyRotationSecret, 32);
611 const derivedKeyMaterial = await CryptoUtils.hkdf(
615 FINGERPRINT_LENGTH + KEY_LENGTH
617 const fingerprint = derivedKeyMaterial.slice(0, FINGERPRINT_LENGTH);
618 const key = derivedKeyMaterial.slice(
620 FINGERPRINT_LENGTH + KEY_LENGTH
625 keyRotationTimestamp +
627 ChromeUtils.base64URLEncode(fingerprint, {
630 k: ChromeUtils.base64URLEncode(key, {
638 * Derive the scoped key for the one of our legacy sync-related scopes.
640 * These uses a different key-derivation algoritm that incorporates less server-provided
641 * data, for backwards-compatibility reasons.
644 async _deriveLegacyScopedKey(uid, kBbytes, scope, scopedKeyMetadata) {
646 if (scope == SCOPE_OLD_SYNC) {
647 kid = await this._deriveXClientState(kBbytes);
648 key = await this._deriveSyncKey(kBbytes);
650 throw new Error(`Unexpected legacy key-bearing scope: ${scope}`);
652 kid = CommonUtils.byteStringToArrayBuffer(kid);
653 key = CommonUtils.byteStringToArrayBuffer(key);
654 return this._formatLegacyScopedKey(kid, key, scope, scopedKeyMetadata);
658 * Format key material for a legacy scyne-related scope as a JWK.
660 * @param {ArrayBuffer} kid bytes of the key hash to use in the key identifier
661 * @param {ArrayBuffer} key bytes of the derived sync key
662 * @param {String} scope the scope with which this key is associated
663 * @param {Number} keyRotationTimestamp server-provided timestamp of last key rotation
664 * @returns {Object} key material formatted as a JWK object
666 _formatLegacyScopedKey(kid, key, scope, { keyRotationTimestamp }) {
667 kid = ChromeUtils.base64URLEncode(kid, {
670 key = ChromeUtils.base64URLEncode(key, {
674 kid: `${keyRotationTimestamp}-${kid}`,
681 * Derive the Sync Key given the byte string kB.
683 * @returns Promise<HKDF(kB, undefined, "identity.mozilla.com/picl/v1/oldsync", 64)>
685 async _deriveSyncKey(kBbytes) {
686 return CryptoUtils.hkdfLegacy(
689 "identity.mozilla.com/picl/v1/oldsync",
695 * Derive the X-Client-State header given the byte string kB.
697 * @returns Promise<SHA256(kB)[:16]>
699 async _deriveXClientState(kBbytes) {
700 return this._sha256(kBbytes).slice(0, 16);
704 let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
707 hasher.init(hasher.SHA256);
708 return CryptoUtils.digestBytes(bytes, hasher);