Bug 1900892 - Part 1: Factor out computeBackupKeys to ArchiveUtils. r=djackson,backup-reviewers,kpatenio

Factoring this out, as computing these keys is something that we need to do both
when generating the ArchiveEncryptionState, as well as when performing a
decryption.

This also renames "authKey" and "encKey" in ArchiveEncryptionState to use
"backupAuthKey" and "backupEncKey", as these are more in-line with what the
encryption design document uses (and because there are "authKeys" and "encKeys"
that will be used by the encryption mechanism that are distinct from the
backupAuthKey and backupEncKey).

Differential Revision: https://phabricator.services.mozilla.com/D212858
This commit is contained in:
Mike Conley
2024-06-21 14:07:25 +00:00
parent c14f60b16c
commit c43f563dc7
4 changed files with 111 additions and 87 deletions

View File

@@ -76,8 +76,8 @@ export class ArchiveEncryptionState {
* *
* @type {CryptoKey} * @type {CryptoKey}
*/ */
get authKey() { get backupAuthKey() {
return this.#state.authKey; return this.#state.backupAuthKey;
} }
/** /**
@@ -182,23 +182,10 @@ export class ArchiveEncryptionState {
} }
} }
let textEncoder = new TextEncoder();
let recoveryCodeBytes = textEncoder.encode(recoveryCode);
// Next, we turn the recoveryCode into some key material that we can use
// to derive a key from.
lazy.logConsole.debug("Creating keyMaterial from recovery code");
let keyMaterial = await crypto.subtle.importKey(
"raw",
recoveryCodeBytes,
"PBKDF2",
false /* extractable */,
["deriveBits"]
);
// Next, we generate a 32-byte salt, and then concatenate a static suffix // Next, we generate a 32-byte salt, and then concatenate a static suffix
// to it, including the version number. // to it, including the version number.
lazy.logConsole.debug("Creating salt"); lazy.logConsole.debug("Creating salt");
let textEncoder = new TextEncoder();
const SALT_SUFFIX = textEncoder.encode( const SALT_SUFFIX = textEncoder.encode(
"backupkey-v" + ArchiveEncryptionState.VERSION "backupkey-v" + ArchiveEncryptionState.VERSION
); );
@@ -209,67 +196,8 @@ export class ArchiveEncryptionState {
salt.set(saltPrefix); salt.set(saltPrefix);
salt.set(SALT_SUFFIX, saltPrefix.length); salt.set(SALT_SUFFIX, saltPrefix.length);
// Then we derive the "backup key", using let { backupAuthKey, backupEncKey } =
// PBKDF2(recoveryCode, saltPrefix || SALT_SUFFIX, SHA-256, 600,000) await lazy.ArchiveUtils.computeBackupKeys(recoveryCode, salt);
const ITERATIONS = 600_000;
// We actually use the backup key as bits to derive other information,
// like the HKDF authKey and encKey.
lazy.logConsole.debug("Deriving backupKeyBits");
let backupKeyBits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt,
iterations: ITERATIONS,
hash: "SHA-256",
},
keyMaterial,
256
);
// This is a little awkward, but the way that the WebCrypto API currently
// works is that we have to read in those bits as a "raw HKDF key", and
// only then can we derive our other HKDF keys from it.
let backupKeyHKDF = await crypto.subtle.importKey(
"raw",
backupKeyBits,
{
name: "HKDF",
hash: "SHA-256",
},
false /* extractable */,
["deriveKey", "deriveBits"]
);
// Derive BackupAuthKey as HKDF(backupKey, “backupkey-auth”, salt=None)
lazy.logConsole.debug("Deriving authKey from backupKey");
let authKey = new Uint8Array(
await crypto.subtle.deriveBits(
{
name: "HKDF",
salt: new Uint8Array(0), // no salt
info: textEncoder.encode("backupkey-auth"),
hash: "SHA-256",
},
backupKeyHKDF,
256
)
);
// Derive BackupEncKey as HKDF(BackupKey, info=“backupkey-enc-key”, salt=None)
lazy.logConsole.debug("Deriving backupEncKey");
let encKey = await crypto.subtle.deriveKey(
{
name: "HKDF",
salt: new Uint8Array(0), // no salt
info: textEncoder.encode("backupkey-enc-key"),
hash: "SHA-256",
},
backupKeyHKDF,
{ name: "AES-GCM", length: 256 },
true /* extractable */,
["encrypt", "wrapKey"]
);
lazy.logConsole.debug("Encrypting secrets with encKey"); lazy.logConsole.debug("Encrypting secrets with encKey");
const NONCE_SIZE = 96; const NONCE_SIZE = 96;
@@ -287,7 +215,7 @@ export class ArchiveEncryptionState {
name: "AES-GCM", name: "AES-GCM",
iv: nonce, iv: nonce,
}, },
encKey, backupEncKey,
secretsBytes secretsBytes
) )
); );
@@ -295,7 +223,7 @@ export class ArchiveEncryptionState {
this.#state = { this.#state = {
publicKey: keyPair.publicKey, publicKey: keyPair.publicKey,
salt, salt,
authKey, backupAuthKey,
nonce, nonce,
wrappedSecrets, wrappedSecrets,
}; };
@@ -312,7 +240,9 @@ export class ArchiveEncryptionState {
async serialize() { async serialize() {
let publicKey = await crypto.subtle.exportKey("jwk", this.#state.publicKey); let publicKey = await crypto.subtle.exportKey("jwk", this.#state.publicKey);
let salt = lazy.ArchiveUtils.arrayToBase64(this.#state.salt); let salt = lazy.ArchiveUtils.arrayToBase64(this.#state.salt);
let authKey = lazy.ArchiveUtils.arrayToBase64(this.#state.authKey); let backupAuthKey = lazy.ArchiveUtils.arrayToBase64(
this.#state.backupAuthKey
);
let nonce = lazy.ArchiveUtils.arrayToBase64(this.#state.nonce); let nonce = lazy.ArchiveUtils.arrayToBase64(this.#state.nonce);
let wrappedSecrets = lazy.ArchiveUtils.arrayToBase64( let wrappedSecrets = lazy.ArchiveUtils.arrayToBase64(
this.#state.wrappedSecrets this.#state.wrappedSecrets
@@ -320,7 +250,7 @@ export class ArchiveEncryptionState {
let result = { let result = {
publicKey, publicKey,
salt, salt,
authKey, backupAuthKey,
nonce, nonce,
wrappedSecrets, wrappedSecrets,
version: ArchiveEncryptionState.VERSION, version: ArchiveEncryptionState.VERSION,
@@ -360,7 +290,9 @@ export class ArchiveEncryptionState {
true /* extractable */, true /* extractable */,
["encrypt"] ["encrypt"]
); );
let authKey = lazy.ArchiveUtils.stringToArray(stateData.authKey); let backupAuthKey = lazy.ArchiveUtils.stringToArray(
stateData.backupAuthKey
);
let salt = lazy.ArchiveUtils.stringToArray(stateData.salt); let salt = lazy.ArchiveUtils.stringToArray(stateData.salt);
let nonce = lazy.ArchiveUtils.stringToArray(stateData.nonce); let nonce = lazy.ArchiveUtils.stringToArray(stateData.nonce);
let wrappedSecrets = lazy.ArchiveUtils.stringToArray( let wrappedSecrets = lazy.ArchiveUtils.stringToArray(
@@ -369,7 +301,7 @@ export class ArchiveEncryptionState {
this.#state = { this.#state = {
publicKey, publicKey,
authKey, backupAuthKey,
salt, salt,
nonce, nonce,
wrappedSecrets, wrappedSecrets,

View File

@@ -88,4 +88,96 @@ export const ArchiveUtils = {
get ARCHIVE_CHUNK_MAX_BYTES_SIZE() { get ARCHIVE_CHUNK_MAX_BYTES_SIZE() {
return 1048576; // 2 ^ 20 bytes, per guidance from security engineering. return 1048576; // 2 ^ 20 bytes, per guidance from security engineering.
}, },
/**
* @typedef {object} ComputeKeysResult
* @property {Uint8Array} backupAuthKey
* The computed BackupAuthKey. This is returned as a Uint8Array because
* this key is used as a salt for other derived keys.
* @property {CryptoKey} backupEncKey
* The computed BackupEncKey. This is an AES-GCM key used to encrypt and
* decrypt the secrets contained within a backup archive.
*/
/**
* Computes the BackupAuthKey and BackupEncKey from a recovery code and a
* salt.
*
* @param {string} recoveryCode
* A recovery code. Callers are responsible for checking the length /
* entropy of the recovery code.
* @param {Uint8Array} salt
* A salt that should be used for computing the keys.
* @returns {ComputeKeysResult}
*/
async computeBackupKeys(recoveryCode, salt) {
let textEncoder = new TextEncoder();
let recoveryCodeBytes = textEncoder.encode(recoveryCode);
let keyMaterial = await crypto.subtle.importKey(
"raw",
recoveryCodeBytes,
"PBKDF2",
false /* extractable */,
["deriveBits"]
);
// Then we derive the "backup key", using
// PBKDF2(recoveryCode, saltPrefix || SALT_SUFFIX, SHA-256, 600,000)
const ITERATIONS = 600_000;
let backupKeyBits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt,
iterations: ITERATIONS,
hash: "SHA-256",
},
keyMaterial,
256
);
// This is a little awkward, but the way that the WebCrypto API currently
// works is that we have to read in those bits as a "raw HKDF key", and
// only then can we derive our other HKDF keys from it.
let backupKeyHKDF = await crypto.subtle.importKey(
"raw",
backupKeyBits,
{
name: "HKDF",
hash: "SHA-256",
},
false /* extractable */,
["deriveKey", "deriveBits"]
);
// Re-derive BackupAuthKey as HKDF(backupKey, “backupkey-auth”, salt=None)
let backupAuthKey = new Uint8Array(
await crypto.subtle.deriveBits(
{
name: "HKDF",
salt: new Uint8Array(0), // no salt
info: textEncoder.encode("backupkey-auth"),
hash: "SHA-256",
},
backupKeyHKDF,
256
)
);
let backupEncKey = await crypto.subtle.deriveKey(
{
name: "HKDF",
salt: new Uint8Array(0), // no salt
info: textEncoder.encode("backupkey-enc-key"),
hash: "SHA-256",
},
backupKeyHKDF,
{ name: "AES-GCM", length: 256 },
true /* extractable */,
["encrypt", "decrypt", "wrapKey"]
);
return { backupAuthKey, backupEncKey };
},
}; };

View File

@@ -1007,7 +1007,7 @@ export class BackupService extends EventTarget {
? { ? {
publicKey: encState.publicKey, publicKey: encState.publicKey,
salt: encState.salt, salt: encState.salt,
authKey: encState.authKey, backupAuthKey: encState.backupAuthKey,
wrappedSecrets: encState.wrappedSecrets, wrappedSecrets: encState.wrappedSecrets,
} }
: null; : null;

View File

@@ -29,7 +29,7 @@ add_task(async function test_ArchiveEncryptionState_enable() {
Assert.equal(recoveryCode, TEST_RECOVERY_CODE, "Got back recovery code."); Assert.equal(recoveryCode, TEST_RECOVERY_CODE, "Got back recovery code.");
Assert.ok(encState.publicKey, "A public key was computed."); Assert.ok(encState.publicKey, "A public key was computed.");
Assert.ok(encState.authKey, "An auth key was computed."); Assert.ok(encState.backupAuthKey, "An auth key was computed.");
Assert.ok(encState.salt, "A salt was computed."); Assert.ok(encState.salt, "A salt was computed.");
Assert.ok(encState.nonce, "A nonce was computed."); Assert.ok(encState.nonce, "A nonce was computed.");
Assert.ok(encState.wrappedSecrets, "Wrapped secrets were computed."); Assert.ok(encState.wrappedSecrets, "Wrapped secrets were computed.");
@@ -66,8 +66,8 @@ add_task(
"Public keys match" "Public keys match"
); );
Assert.deepEqual( Assert.deepEqual(
encState.authKey, encState.backupAuthKey,
recoveredState.authKey, recoveredState.backupAuthKey,
"Auth keys match" "Auth keys match"
); );
Assert.deepEqual(encState.salt, recoveredState.salt, "Salts match"); Assert.deepEqual(encState.salt, recoveredState.salt, "Salts match");