737 lines
25 KiB
JavaScript
737 lines
25 KiB
JavaScript
/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
|
|
import { CommonUtils } from "resource://services-common/utils.sys.mjs";
|
|
|
|
import { CryptoUtils } from "resource://services-crypto/utils.sys.mjs";
|
|
|
|
import {
|
|
SCOPE_APP_SYNC,
|
|
DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY,
|
|
OAUTH_CLIENT_ID,
|
|
log,
|
|
logPII,
|
|
} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
|
|
|
|
// The following top-level fields have since been deprecated and exist here purely
|
|
// to be removed from the account state when seen. After a reasonable period of time
|
|
// has passed, where users have been migrated away from those keys they should be safe to be removed
|
|
const DEPRECATED_DERIVED_KEYS_NAMES = [
|
|
"kSync",
|
|
"kXCS",
|
|
"kExtSync",
|
|
"kExtKbHash",
|
|
"ecosystemUserId",
|
|
"ecosystemAnonId",
|
|
];
|
|
|
|
const lazy = {};
|
|
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"oauthEnabled",
|
|
"identity.fxaccounts.oauth.enabled",
|
|
true
|
|
);
|
|
|
|
// Is key fetching enabled/possible in the current configuration?
|
|
function keyFetchingEnabled() {
|
|
return !lazy.oauthEnabled;
|
|
}
|
|
|
|
// This scope and its associated key material were used by the old Kinto webextension
|
|
// storage backend, but has since been decommissioned. It's here entirely so that we
|
|
// remove the corresponding key from storage if present. We should be safe to remove it
|
|
// after some sensible period of time has elapsed to allow most clients to update.
|
|
const DEPRECATED_SCOPE_WEBEXT_SYNC = "sync:addon_storage";
|
|
|
|
// These are the scopes that correspond to new storage for the `LEGACY_DERIVED_KEYS_NAMES`.
|
|
// We will, if necessary, migrate storage for those keys so that it's associated with
|
|
// these scopes.
|
|
const LEGACY_DERIVED_KEY_SCOPES = [SCOPE_APP_SYNC];
|
|
|
|
// These are scopes that we used to store, but are no longer using,
|
|
// and hence should be deleted from storage if present.
|
|
const DEPRECATED_KEY_SCOPES = [
|
|
DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY,
|
|
DEPRECATED_SCOPE_WEBEXT_SYNC,
|
|
];
|
|
|
|
/**
|
|
* Utilities for working with key material linked to the user's account.
|
|
*
|
|
* Each Firefox Account has 32 bytes of root key material called `kB` which is
|
|
* linked to the user's password, and which is used to derive purpose-specific
|
|
* subkeys for things like encrypting the user's sync data. This class provides
|
|
* the interface for working with such key material.
|
|
*
|
|
* Most recent FxA clients obtain appropriate key material directly as part of
|
|
* their sign-in flow, using a special extension of the OAuth2.0 protocol to
|
|
* securely deliver the derived keys without revealing `kB`. Keys obtained in
|
|
* in this way are called "scoped keys" since each corresponds to a particular
|
|
* OAuth scope, and this class provides a `getKeyForScope` method that is the
|
|
* preferred method for consumers to work with such keys.
|
|
*
|
|
* However, since the FxA integration in Firefox Desktop pre-dates the use of
|
|
* OAuth2.0, we also have a lot of code for fetching keys via an older flow.
|
|
* This flow uses a special `keyFetchToken` to obtain `kB` and then derive various
|
|
* sub-keys from it. Consumers should consider this an internal implementation
|
|
* detail of the `FxAccountsKeys` class and should prefer `getKeyForScope` where
|
|
* possible. We intend to remove support for Firefox ever directly handling `kB`
|
|
* at some point in the future.
|
|
*
|
|
* Note that Desktop is now slowly moving to these newer oauth flows - so all this
|
|
* key fetching and use of the keyFetchToken should be considered deprecated, and
|
|
* must not be used when the OAuth is in use. This code remains behind just for
|
|
* this transition and should be removed once we are committed to never rolling
|
|
* the flows back to the pre-oauth days.
|
|
*/
|
|
export class FxAccountsKeys {
|
|
constructor(fxAccountsInternal) {
|
|
this._fxai = fxAccountsInternal;
|
|
}
|
|
|
|
/**
|
|
* Checks if we currently have the key for a given scope, or if we have enough to
|
|
* be able to successfully fetch and unwrap it for the signed-in-user.
|
|
*
|
|
* Unlike `getKeyForScope`, this will not hit the network to fetch wrapped keys if
|
|
* they aren't available locally.
|
|
*/
|
|
canGetKeyForScope(scope) {
|
|
return this._fxai.withCurrentAccountState(async currentState => {
|
|
let userData = await currentState.getUserAccountData();
|
|
if (!userData) {
|
|
throw new Error("Can't possibly get keys; User is not signed in");
|
|
}
|
|
if (!userData.verified) {
|
|
log.info("Can't get keys; user is not verified");
|
|
return false;
|
|
}
|
|
|
|
if (userData.scopedKeys && userData.scopedKeys.hasOwnProperty(scope)) {
|
|
return true;
|
|
}
|
|
|
|
// If we have a `keyFetchToken` we can fetch `kB`.
|
|
if (userData.keyFetchToken) {
|
|
// this is a kind of defense-in-depth for our oauth flows in case something is confused.
|
|
if (!keyFetchingEnabled()) {
|
|
log.error(
|
|
"Key management confusion: we should never have a keyFetchToken in oauth flows; ignoring it"
|
|
);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
log.info("Can't get keys; no key material or tokens available");
|
|
return false;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the key for a specified OAuth scope.
|
|
*
|
|
* @param {String} scope The OAuth scope whose key should be returned
|
|
*
|
|
* @return Promise<JWK>
|
|
* If no key is available the promise resolves to `null`.
|
|
* If a key is available for the given scope, th promise resolves to a JWK with fields:
|
|
* {
|
|
* scope: The requested scope
|
|
* kid: Key identifier
|
|
* k: Derived key material
|
|
* kty: Always "oct" for scoped keys
|
|
* }
|
|
*
|
|
*/
|
|
async getKeyForScope(scope) {
|
|
const { scopedKeys } = await this._loadOrFetchKeys();
|
|
if (!scopedKeys.hasOwnProperty(scope)) {
|
|
throw new Error(`Key not available for scope "${scope}"`);
|
|
}
|
|
return {
|
|
scope,
|
|
...scopedKeys[scope],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validates if the given scoped keys are valid keys
|
|
*
|
|
* @param { Object } scopedKeys: The scopedKeys bundle
|
|
*
|
|
* @return { Boolean }: true if the scopedKeys bundle is valid, false otherwise
|
|
*/
|
|
validScopedKeys(scopedKeys) {
|
|
for (const expectedScope of Object.keys(scopedKeys)) {
|
|
const key = scopedKeys[expectedScope];
|
|
if (
|
|
!key.hasOwnProperty("scope") ||
|
|
!key.hasOwnProperty("kid") ||
|
|
!key.hasOwnProperty("kty") ||
|
|
!key.hasOwnProperty("k")
|
|
) {
|
|
return false;
|
|
}
|
|
const { scope, kid, kty, k } = key;
|
|
if (scope != expectedScope || kty != "oct") {
|
|
return false;
|
|
}
|
|
// We verify the format of the key id is `timestamp-fingerprint`
|
|
if (!kid.includes("-")) {
|
|
return false;
|
|
}
|
|
const dashIndex = kid.indexOf("-");
|
|
const keyRotationTimestamp = kid.substring(0, dashIndex);
|
|
const fingerprint = kid.substring(dashIndex + 1);
|
|
// We then verify that the timestamp is a valid timestamp
|
|
const keyRotationTimestampNum = Number(keyRotationTimestamp);
|
|
// If the value we got back is falsy it's not a valid timestamp
|
|
// note that we treat a 0 timestamp as invalid
|
|
if (!keyRotationTimestampNum) {
|
|
return false;
|
|
}
|
|
// For extra safety, we validate that the timestamp can be converted into a valid
|
|
// Date object
|
|
const date = new Date(keyRotationTimestampNum);
|
|
if (isNaN(date.getTime()) || date.getTime() <= 0) {
|
|
return false;
|
|
}
|
|
|
|
// Finally, we validate that the fingerprint and the key itself are valid base64 values
|
|
// Note that we can't verify the fingerprint is correct here because we don't have kb
|
|
const validB64String = b64String => {
|
|
let decoded;
|
|
try {
|
|
decoded = ChromeUtils.base64URLDecode(b64String, {
|
|
padding: "reject",
|
|
});
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
return !!decoded;
|
|
};
|
|
if (!validB64String(fingerprint) || !validB64String(k)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Format a JWK kid as hex rather than base64.
|
|
*
|
|
* This is a backwards-compatibility helper for code that needs a raw key fingerprint
|
|
* for use as a key identifier, rather than the timestamp+fingerprint format used by
|
|
* FxA scoped keys.
|
|
*
|
|
* @param {Object} jwk The JWK from which to extract the `kid` field as hex.
|
|
*/
|
|
kidAsHex(jwk) {
|
|
// The kid format is "{timestamp}-{b64url(fingerprint)}", but we have to be careful
|
|
// because the fingerprint component may contain "-" as well, and we want to ensure
|
|
// the timestamp component was non-empty.
|
|
const idx = jwk.kid.indexOf("-") + 1;
|
|
if (idx <= 1) {
|
|
throw new Error(`Invalid kid: ${jwk.kid}`);
|
|
}
|
|
return CommonUtils.base64urlToHex(jwk.kid.slice(idx));
|
|
}
|
|
|
|
/**
|
|
* Fetch encryption keys for the signed-in-user from the FxA API server.
|
|
*
|
|
* Not for user consumption. Exists to cause the keys to be fetched.
|
|
*
|
|
* Returns user data so that it can be chained with other methods.
|
|
*
|
|
* @return Promise
|
|
* The promise resolves to the credentials object of the signed-in user:
|
|
* {
|
|
* email: The user's email address
|
|
* uid: The user's unique id
|
|
* sessionToken: Session for the FxA server
|
|
* scopedKeys: Object mapping OAuth scopes to corresponding derived keys
|
|
* verified: email verification status
|
|
* }
|
|
* @throws If there is no user signed in.
|
|
*/
|
|
async _loadOrFetchKeys() {
|
|
return this._fxai.withCurrentAccountState(async currentState => {
|
|
try {
|
|
let userData = await currentState.getUserAccountData();
|
|
if (!userData) {
|
|
throw new Error("Can't get keys; User is not signed in");
|
|
}
|
|
// If we have all the keys in latest storage location, we're good.
|
|
if (userData.scopedKeys) {
|
|
if (
|
|
LEGACY_DERIVED_KEY_SCOPES.every(scope =>
|
|
userData.scopedKeys.hasOwnProperty(scope)
|
|
) &&
|
|
!DEPRECATED_KEY_SCOPES.some(scope =>
|
|
userData.scopedKeys.hasOwnProperty(scope)
|
|
) &&
|
|
!DEPRECATED_DERIVED_KEYS_NAMES.some(keyName =>
|
|
userData.hasOwnProperty(keyName)
|
|
)
|
|
) {
|
|
return userData;
|
|
}
|
|
}
|
|
// If not, we've got work to do, and we debounce to avoid duplicating it.
|
|
if (!currentState.whenKeysReadyDeferred) {
|
|
currentState.whenKeysReadyDeferred = Promise.withResolvers();
|
|
// N.B. we deliberately don't `await` here, and instead use the promise
|
|
// to resolve `whenKeysReadyDeferred` (which we then `await` below).
|
|
this._migrateOrFetchKeys(currentState, userData).then(
|
|
dataWithKeys => {
|
|
currentState.whenKeysReadyDeferred.resolve(dataWithKeys);
|
|
currentState.whenKeysReadyDeferred = null;
|
|
},
|
|
err => {
|
|
currentState.whenKeysReadyDeferred.reject(err);
|
|
currentState.whenKeysReadyDeferred = null;
|
|
}
|
|
);
|
|
}
|
|
return await currentState.whenKeysReadyDeferred.promise;
|
|
} catch (err) {
|
|
return this._fxai._handleTokenError(err);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set externally derived scoped keys in internal storage
|
|
* @param { Object } scopedKeys: The scoped keys object derived by the oauth flow
|
|
*
|
|
* @return { Promise }: A promise that resolves if the keys were successfully stored,
|
|
* or rejects if we failed to persist the keys, or if the user is not signed in already
|
|
*/
|
|
async setScopedKeys(scopedKeys) {
|
|
return this._fxai.withCurrentAccountState(async currentState => {
|
|
const userData = await currentState.getUserAccountData();
|
|
if (!userData) {
|
|
throw new Error("Cannot persist keys, no user signed in");
|
|
}
|
|
await currentState.updateUserAccountData({
|
|
scopedKeys,
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Key storage migration or fetching logic.
|
|
*
|
|
* This method contains the doing-expensive-operations part of the logic of
|
|
* _loadOrFetchKeys(), factored out into a separate method so we can debounce it.
|
|
*
|
|
*/
|
|
async _migrateOrFetchKeys(currentState, userData) {
|
|
// If the required scopes are present in `scopedKeys`, then we know that we've
|
|
// previously applied all earlier migrations
|
|
// so we are safe to delete deprecated fields that older migrations
|
|
// might have depended on.
|
|
if (
|
|
userData.scopedKeys &&
|
|
LEGACY_DERIVED_KEY_SCOPES.every(scope =>
|
|
userData.scopedKeys.hasOwnProperty(scope)
|
|
)
|
|
) {
|
|
return this._removeDeprecatedKeys(currentState, userData);
|
|
}
|
|
|
|
// Otherwise, we need to fetch from the network and unwrap.
|
|
if (!userData.sessionToken) {
|
|
throw new Error("No sessionToken");
|
|
}
|
|
if (!userData.keyFetchToken) {
|
|
throw new Error("No keyFetchToken");
|
|
}
|
|
return this._fetchAndUnwrapAndDeriveKeys(
|
|
currentState,
|
|
userData.sessionToken,
|
|
userData.keyFetchToken
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Removes deprecated keys from storage and returns an
|
|
* updated user data object
|
|
*/
|
|
async _removeDeprecatedKeys(currentState, userData) {
|
|
// Bug 1838708: Delete any deprecated high level keys from storage
|
|
const keysToRemove = DEPRECATED_DERIVED_KEYS_NAMES.filter(keyName =>
|
|
userData.hasOwnProperty(keyName)
|
|
);
|
|
if (keysToRemove.length) {
|
|
const removedKeys = {};
|
|
for (const keyName of keysToRemove) {
|
|
removedKeys[keyName] = null;
|
|
}
|
|
await currentState.updateUserAccountData({
|
|
...removedKeys,
|
|
});
|
|
userData = await currentState.getUserAccountData();
|
|
}
|
|
// Bug 1697596 - delete any deprecated scoped keys from storage.
|
|
const scopesToRemove = DEPRECATED_KEY_SCOPES.filter(scope =>
|
|
userData.scopedKeys.hasOwnProperty(scope)
|
|
);
|
|
if (scopesToRemove.length) {
|
|
const updatedScopedKeys = {
|
|
...userData.scopedKeys,
|
|
};
|
|
for (const scope of scopesToRemove) {
|
|
delete updatedScopedKeys[scope];
|
|
}
|
|
await currentState.updateUserAccountData({
|
|
scopedKeys: updatedScopedKeys,
|
|
});
|
|
userData = await currentState.getUserAccountData();
|
|
}
|
|
return userData;
|
|
}
|
|
|
|
/**
|
|
* Fetch keys from the server, unwrap them, and derive required sub-keys.
|
|
*
|
|
* Once the user's email is verified, we can resquest the root key `kB` from the
|
|
* FxA server, unwrap it using the client-side secret `unwrapBKey`, and then
|
|
* derive all the sub-keys required for operation of the browser.
|
|
*/
|
|
async _fetchAndUnwrapAndDeriveKeys(
|
|
currentState,
|
|
sessionToken,
|
|
keyFetchToken
|
|
) {
|
|
if (logPII()) {
|
|
log.debug(
|
|
`fetchAndUnwrapKeys: sessionToken: ${sessionToken}, keyFetchToken: ${keyFetchToken}`
|
|
);
|
|
}
|
|
|
|
// Sign out if we don't have the necessary tokens.
|
|
if (!sessionToken || !keyFetchToken) {
|
|
// this seems really bad and we should remove this - bug 1572313.
|
|
log.warn("improper _fetchAndUnwrapKeys() call: token missing");
|
|
await this._fxai.signOut();
|
|
return null;
|
|
}
|
|
|
|
// Deriving OAuth scoped keys requires additional metadata from the server.
|
|
// We fetch this first, before fetching the actual key material, because the
|
|
// keyFetchToken is single-use and we don't want to do a potentially-fallible
|
|
// operation after consuming it.
|
|
const scopedKeysMetadata =
|
|
await this._fetchScopedKeysMetadata(sessionToken);
|
|
|
|
// Fetch the wrapped keys.
|
|
// It would be nice to be able to fetch this in a single operation with fetching
|
|
// the metadata above, but that requires server-side changes in FxA.
|
|
let { wrapKB } = await this._fetchKeys(keyFetchToken);
|
|
|
|
let data = await currentState.getUserAccountData();
|
|
|
|
// Sanity check that the user hasn't changed out from under us (which should
|
|
// be impossible given this is called within _withCurrentAccountState, but...)
|
|
if (data.keyFetchToken !== keyFetchToken) {
|
|
throw new Error("Signed in user changed while fetching keys!");
|
|
}
|
|
|
|
let kBbytes = CryptoUtils.xor(
|
|
CommonUtils.hexToBytes(data.unwrapBKey),
|
|
wrapKB
|
|
);
|
|
|
|
if (logPII()) {
|
|
log.debug("kBbytes: " + kBbytes);
|
|
}
|
|
|
|
let updateData = {
|
|
...(await this._deriveKeys(data.uid, kBbytes, scopedKeysMetadata)),
|
|
keyFetchToken: null, // null values cause the item to be removed.
|
|
unwrapBKey: null,
|
|
};
|
|
|
|
if (logPII()) {
|
|
log.debug(`Keys Obtained: ${updateData.scopedKeys}`);
|
|
} else {
|
|
log.debug(
|
|
"Keys Obtained: " + Object.keys(updateData.scopedKeys).join(", ")
|
|
);
|
|
}
|
|
|
|
// Just double-check that scoped keys are there now
|
|
if (!updateData.scopedKeys) {
|
|
throw new Error(`user data missing: scopedKeys`);
|
|
}
|
|
|
|
await currentState.updateUserAccountData(updateData);
|
|
return currentState.getUserAccountData();
|
|
}
|
|
|
|
/**
|
|
* Fetch the wrapped root key `wrapKB` from the FxA server.
|
|
*
|
|
* This consumes the single-use `keyFetchToken`.
|
|
*/
|
|
_fetchKeys(keyFetchToken) {
|
|
let client = this._fxai.fxAccountsClient;
|
|
log.debug(
|
|
`Fetching keys with token ${!!keyFetchToken} from ${client.host}`
|
|
);
|
|
if (logPII()) {
|
|
log.debug("fetchKeys - the token is " + keyFetchToken);
|
|
}
|
|
return client.accountKeys(keyFetchToken);
|
|
}
|
|
|
|
/**
|
|
* Fetch additional metadata required for deriving scoped keys.
|
|
*
|
|
* This includes timestamps and a server-provided secret to mix in to
|
|
* the derived value in order to support key rotation.
|
|
*/
|
|
async _fetchScopedKeysMetadata(sessionToken) {
|
|
// Hard-coded list of scopes that we know about.
|
|
// This list will probably grow in future.
|
|
const scopes = [SCOPE_APP_SYNC].join(" ");
|
|
const scopedKeysMetadata =
|
|
await this._fxai.fxAccountsClient.getScopedKeyData(
|
|
sessionToken,
|
|
OAUTH_CLIENT_ID,
|
|
scopes
|
|
);
|
|
// The server may decline us permission for some of those scopes, although it really shouldn't.
|
|
// We can live without them...except for the sync scope, whose absence would be catastrophic.
|
|
if (!scopedKeysMetadata.hasOwnProperty(SCOPE_APP_SYNC)) {
|
|
log.warn(
|
|
"The FxA server did not grant Firefox the sync scope; this is most unexpected!" +
|
|
` scopes were: ${Object.keys(scopedKeysMetadata)}`
|
|
);
|
|
throw new Error("The FxA server did not grant Firefox the sync scope");
|
|
}
|
|
return scopedKeysMetadata;
|
|
}
|
|
|
|
/**
|
|
* Derive purpose-specific keys from the root FxA key `kB`.
|
|
*
|
|
* Everything that uses an encryption key from FxA uses a purpose-specific derived
|
|
* key. For new uses this is derived in a structured way based on OAuth scopes,
|
|
* while for legacy uses (mainly Firefox Sync) it is derived in a more ad-hoc fashion.
|
|
* This method does all the derivations for the uses that we know about.
|
|
*
|
|
*/
|
|
async _deriveKeys(uid, kBbytes, scopedKeysMetadata) {
|
|
const scopedKeys = await this._deriveScopedKeys(
|
|
uid,
|
|
kBbytes,
|
|
scopedKeysMetadata
|
|
);
|
|
return {
|
|
scopedKeys,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Derive various scoped keys from the root FxA key `kB`.
|
|
*
|
|
* The `scopedKeysMetadata` object is additional information fetched from the server that
|
|
* that gets mixed in to the key derivation, with each member of the object corresponding
|
|
* to an OAuth scope that keys its own scoped key.
|
|
*
|
|
* As a special case for backwards-compatibility, sync-related scopes get special
|
|
* treatment to use a legacy derivation algorithm.
|
|
*
|
|
*/
|
|
async _deriveScopedKeys(uid, kBbytes, scopedKeysMetadata) {
|
|
const scopedKeys = {};
|
|
for (const scope in scopedKeysMetadata) {
|
|
if (LEGACY_DERIVED_KEY_SCOPES.includes(scope)) {
|
|
scopedKeys[scope] = await this._deriveLegacyScopedKey(
|
|
uid,
|
|
kBbytes,
|
|
scope,
|
|
scopedKeysMetadata[scope]
|
|
);
|
|
} else {
|
|
scopedKeys[scope] = await this._deriveScopedKey(
|
|
uid,
|
|
kBbytes,
|
|
scope,
|
|
scopedKeysMetadata[scope]
|
|
);
|
|
}
|
|
}
|
|
return scopedKeys;
|
|
}
|
|
|
|
/**
|
|
* Derive a scoped key for an individual OAuth scope.
|
|
*
|
|
* The derivation here uses HKDF to combine:
|
|
* - the root key material kB
|
|
* - a unique identifier for this scoped key
|
|
* - a server-provided secret that allows for key rotation
|
|
* - the account uid as an additional salt
|
|
*
|
|
* It produces 32 bytes of (secret) key material along with a (potentially public)
|
|
* key identifier, formatted as a JWK.
|
|
*
|
|
* The full details are in the technical docs at
|
|
* https://docs.google.com/document/d/1IvQJFEBFz0PnL4uVlIvt8fBS_IPwSK-avK0BRIHucxQ/
|
|
*/
|
|
async _deriveScopedKey(uid, kBbytes, scope, scopedKeyMetadata) {
|
|
kBbytes = CommonUtils.byteStringToArrayBuffer(kBbytes);
|
|
|
|
const FINGERPRINT_LENGTH = 16;
|
|
const KEY_LENGTH = 32;
|
|
const VALID_UID = /^[0-9a-f]{32}$/i;
|
|
const VALID_ROTATION_SECRET = /^[0-9a-f]{64}$/i;
|
|
|
|
// Engage paranoia mode for input data.
|
|
if (!VALID_UID.test(uid)) {
|
|
throw new Error("uid must be a 32-character hex string");
|
|
}
|
|
if (kBbytes.length != 32) {
|
|
throw new Error("kBbytes must be exactly 32 bytes");
|
|
}
|
|
if (
|
|
typeof scopedKeyMetadata.identifier !== "string" ||
|
|
scopedKeyMetadata.identifier.length < 10
|
|
) {
|
|
throw new Error("identifier must be a string of length >= 10");
|
|
}
|
|
if (typeof scopedKeyMetadata.keyRotationTimestamp !== "number") {
|
|
throw new Error("keyRotationTimestamp must be a number");
|
|
}
|
|
if (!VALID_ROTATION_SECRET.test(scopedKeyMetadata.keyRotationSecret)) {
|
|
throw new Error("keyRotationSecret must be a 64-character hex string");
|
|
}
|
|
|
|
// The server returns milliseconds, we want seconds as a string.
|
|
const keyRotationTimestamp =
|
|
"" + Math.round(scopedKeyMetadata.keyRotationTimestamp / 1000);
|
|
if (keyRotationTimestamp.length < 10) {
|
|
throw new Error("keyRotationTimestamp must round to a 10-digit number");
|
|
}
|
|
|
|
const keyRotationSecret = CommonUtils.hexToArrayBuffer(
|
|
scopedKeyMetadata.keyRotationSecret
|
|
);
|
|
const salt = CommonUtils.hexToArrayBuffer(uid);
|
|
const context = new TextEncoder().encode(
|
|
"identity.mozilla.com/picl/v1/scoped_key\n" + scopedKeyMetadata.identifier
|
|
);
|
|
|
|
const inputKey = new Uint8Array(64);
|
|
inputKey.set(kBbytes, 0);
|
|
inputKey.set(keyRotationSecret, 32);
|
|
|
|
const derivedKeyMaterial = await CryptoUtils.hkdf(
|
|
inputKey,
|
|
salt,
|
|
context,
|
|
FINGERPRINT_LENGTH + KEY_LENGTH
|
|
);
|
|
const fingerprint = derivedKeyMaterial.slice(0, FINGERPRINT_LENGTH);
|
|
const key = derivedKeyMaterial.slice(
|
|
FINGERPRINT_LENGTH,
|
|
FINGERPRINT_LENGTH + KEY_LENGTH
|
|
);
|
|
|
|
return {
|
|
kid:
|
|
keyRotationTimestamp +
|
|
"-" +
|
|
ChromeUtils.base64URLEncode(fingerprint, {
|
|
pad: false,
|
|
}),
|
|
k: ChromeUtils.base64URLEncode(key, {
|
|
pad: false,
|
|
}),
|
|
kty: "oct",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Derive the scoped key for the one of our legacy sync-related scopes.
|
|
*
|
|
* These uses a different key-derivation algoritm that incorporates less server-provided
|
|
* data, for backwards-compatibility reasons.
|
|
*
|
|
*/
|
|
async _deriveLegacyScopedKey(uid, kBbytes, scope, scopedKeyMetadata) {
|
|
let kid, key;
|
|
if (scope == SCOPE_APP_SYNC) {
|
|
kid = await this._deriveXClientState(kBbytes);
|
|
key = await this._deriveSyncKey(kBbytes);
|
|
} else {
|
|
throw new Error(`Unexpected legacy key-bearing scope: ${scope}`);
|
|
}
|
|
kid = CommonUtils.byteStringToArrayBuffer(kid);
|
|
key = CommonUtils.byteStringToArrayBuffer(key);
|
|
return this._formatLegacyScopedKey(kid, key, scope, scopedKeyMetadata);
|
|
}
|
|
|
|
/**
|
|
* Format key material for a legacy scyne-related scope as a JWK.
|
|
*
|
|
* @param {ArrayBuffer} kid bytes of the key hash to use in the key identifier
|
|
* @param {ArrayBuffer} key bytes of the derived sync key
|
|
* @param {String} scope the scope with which this key is associated
|
|
* @param {Number} keyRotationTimestamp server-provided timestamp of last key rotation
|
|
* @returns {Object} key material formatted as a JWK object
|
|
*/
|
|
_formatLegacyScopedKey(kid, key, scope, { keyRotationTimestamp }) {
|
|
kid = ChromeUtils.base64URLEncode(kid, {
|
|
pad: false,
|
|
});
|
|
key = ChromeUtils.base64URLEncode(key, {
|
|
pad: false,
|
|
});
|
|
return {
|
|
kid: `${keyRotationTimestamp}-${kid}`,
|
|
k: key,
|
|
kty: "oct",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Derive the Sync Key given the byte string kB.
|
|
*
|
|
* @returns Promise<HKDF(kB, undefined, "identity.mozilla.com/picl/v1/oldsync", 64)>
|
|
*/
|
|
async _deriveSyncKey(kBbytes) {
|
|
return CryptoUtils.hkdfLegacy(
|
|
kBbytes,
|
|
undefined,
|
|
"identity.mozilla.com/picl/v1/oldsync",
|
|
2 * 32
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Derive the X-Client-State header given the byte string kB.
|
|
*
|
|
* @returns Promise<SHA256(kB)[:16]>
|
|
*/
|
|
async _deriveXClientState(kBbytes) {
|
|
return this._sha256(kBbytes).slice(0, 16);
|
|
}
|
|
|
|
_sha256(bytes) {
|
|
let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
|
|
Ci.nsICryptoHash
|
|
);
|
|
hasher.init(hasher.SHA256);
|
|
return CryptoUtils.digestBytes(bytes, hasher);
|
|
}
|
|
}
|