VulpesOS/gaia/shared/js/sync/crypto/fxsyncwebcrypto.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

307 lines
11 KiB
JavaScript
Executable file

/* 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/. */
'use strict';
/* global
crypto,
KeyDerivation,
StringConversion
*/
/* exported
FxSyncWebCrypto
*/
// WebCrypto-based client for Firefox Sync.
const FxSyncWebCrypto = Object.freeze((() => {
const HKDF_INFO_STR = 'identity.mozilla.com/picl/v1/oldsync';
const importKeyBundle = (aesKeyAB, hmacKeyAB) => {
const pAes = crypto.subtle.importKey('raw', aesKeyAB,
{ name: 'AES-CBC', length: 256 },
true, [ 'encrypt', 'decrypt' ]);
const pHmac = crypto.subtle.importKey('raw', hmacKeyAB,
{ name: 'HMAC', hash: 'SHA-256' },
true, [ 'sign', 'verify' ]);
return Promise.all([pAes, pHmac]).then(results => {
return Object.freeze({
aes: results[0],
hmac: results[1]
});
}, () => {
// err might be a TypeError if aesKeyAB or hmacKeyAB was no ArrayBuffer.
throw new Error('Could not import key bundle.');
});
};
const importKb = function(kBByteArray) {
// The number 64 here comes from
// (256 bits for AES + 256 bits for HMAC) / (8 bits per byte).
return KeyDerivation.hkdf(kBByteArray,
StringConversion.stringToUtf8Uint8Array(
HKDF_INFO_STR),
new Uint8Array(64), 64).then(output => {
const aesKeyAB = output.slice(0, 32).buffer;
const hmacKeyAB = output.slice(32).buffer;
return Object.freeze(importKeyBundle(aesKeyAB, hmacKeyAB));
});
};
const verifySyncKeys = function(
signedTextByteArray,
cryptoKeysHmacByteArray,
hmacKey) {
return crypto.subtle.verify({ name: 'HMAC', hash: 'AES-256' },
hmacKey, cryptoKeysHmacByteArray, signedTextByteArray);
};
const importSyncKeys = function(
cryptoKeysIVByteArray,
cryptoKeysCiphertextByteArray,
aesKey) {
return crypto.subtle.decrypt({ name: 'AES-CBC', iv: cryptoKeysIVByteArray },
aesKey,
cryptoKeysCiphertextByteArray)
.then(keyBundleAB => {
const cryptoKeysJSON = String.fromCharCode.apply(
null,
new Uint8Array(keyBundleAB));
var bulkKeyBundle;
try {
bulkKeyBundle = JSON.parse(cryptoKeysJSON);
return importKeyBundle(
StringConversion.base64StringToUint8Array(
bulkKeyBundle.default[0]),
StringConversion.base64StringToUint8Array(
bulkKeyBundle.default[1])).then(keyBundle => {
bulkKeyBundle.defaultAsKeyBundle = keyBundle;
return Object.freeze(bulkKeyBundle);
});
} catch(e) {
throw new Error('Deciphered crypto keys, but not JSON');
}
}, () => {
throw new Error(`Could not decrypt crypto keys using AES part of stretche\
d kB key`);
});
};
const importFromStrings = (obj) => {
const ret = {};
try {
ret.ciphertext = StringConversion.
base64StringToUint8Array(obj.ciphertext);
} catch (e) {
throw new Error('Could not parse ciphertext as a base64 string');
}
// Intentionally using StringConversion.stringToUtf8Uint8Array
// instead of StringConversion.base64StringToUint8Array on the ciphertext
// here - see https://github.com/mozilla/firefox-ios/blob/ \
// 1cce59c8eac282e151568f1204ffbbcc27349eff/Sync/KeyBundle.swift#L178.
ret.hmacSignedText = StringConversion
.stringToUtf8Uint8Array(obj.ciphertext);
try {
ret.IV = StringConversion.base64StringToUint8Array(obj.IV);
} catch (e) {
throw new Error('Could not parse IV as a base64 string');
}
try {
ret.hmacSignature = StringConversion.hexStringToUint8Array(obj.hmac);
} catch (e) {
throw new Error('Could not parse hmac as a hex string');
}
return Object.freeze(ret);
};
const getBulkKeyBundle = (kBByteArray, cryptoKeys) => {
return importKb(kBByteArray).then(mainSyncKey => {
return verifySyncKeys(cryptoKeys.hmacSignedText,
cryptoKeys.hmacSignature,
mainSyncKey.hmac).then(verified => {
if (verified) {
return importSyncKeys(cryptoKeys.IV,
cryptoKeys.ciphertext,
mainSyncKey.aes);
}
throw new Error(new Error(`SyncKeys hmac could not be verified with cur\
rent main key`));
});
});
};
/*
* setKeys - import kB and crypto/keys
*
* @param {String} kB Hex string with kB from FxA onepw protocol
* @param {Object} cryptoKeysStrings Object with:
* - ciphertext {String} A Base64 String containing an AES-CBC ciphertext
* - IV {String} A Base64 String containing the AES-CBC Initialization
* Vector
* - hmac {String} A Hex String containing the HMAC-SHA256 signature
* @returns {Promise} A promise that will resolve after importing kB,
* decrypting cryptoKeys, and setting this.bulkKeyBundle.
*/
const setKeys = function(kB, cryptoKeysStrings) {
var kBByteArray, cryptoKeys;
// Input checking.
try {
kBByteArray = StringConversion.hexStringToUint8Array(kB);
} catch (e) {
return Promise.reject(new Error('Could not parse kB as a hex string'));
}
try {
cryptoKeys = importFromStrings(cryptoKeysStrings);
} catch(err) {
return Promise.reject(err);
}
return getBulkKeyBundle(kBByteArray, cryptoKeys).then(bulkKeyBundle => {
this.bulkKeyBundle = bulkKeyBundle;
});
};
/*
* decrypt - verify and decrypt a Weave Basic Object
*
* @param {Object} payloadStrings Object with:
* - ciphertext {String} A Base64 String containing an AES-CBC ciphertext
* - IV {String} A Base64 String containing the AES-CBC Initialization
* Vector
* - hmac {String} A Hex String containing the HMAC-SHA256 signature
* @param {String} collectionName String The name of the Sync collection
* (currently ignored, see
* https://github.com/michielbdejong/fxsync-webcrypto/issues/19)
* @returns {Promise} A promise for the decrypted Weave Basic Object.
*/
const decrypt = function(payloadStrings, collectionName) {
if (typeof payloadStrings !== 'object') {
return Promise.reject(new Error('PayloadStrings is not an object'));
}
if (typeof collectionName !== 'string') {
return Promise.reject(new Error('collectionName is not a string'));
}
var keyBundle;
try {
keyBundle = this.bulkKeyBundle.defaultAsKeyBundle;
} catch(e) {
return Promise.reject(new Error(`No key bundle found for \
${collectionName} - did you call setKeys?`));
}
const payload = importFromStrings(payloadStrings);
return crypto.subtle.verify({ name: 'HMAC', hash: 'SHA-256' },
keyBundle.hmac, payload.hmacSignature,
payload.hmacSignedText).then(result => {
if (!result) {
throw new Error(`Record verification failed with current hmac key for \
${collectionName}`);
}
}).then(() => {
return crypto.subtle.decrypt({ name: 'AES-CBC', iv: payload.IV },
keyBundle.aes,
payload.ciphertext)
.then(recordArrayBuffer => {
var recordObj;
// recordArrayBuffer which is the cleartext payload from FxSync contains
// UTF-8 JSON.
const recordJSON = StringConversion.utf8Uint8ArrayToString(
new Uint8Array(recordArrayBuffer));
try {
recordObj = JSON.parse(recordJSON);
} catch(e) {
throw new Error('Deciphered record, but not JSON');
}
return recordObj;
}, () => {
throw new Error(`Could not decrypt record using AES part of key bundle \
for collection ${collectionName}`);
});
});
};
const encryptAndSign = (keyBundle, cleartext) => {
// Generate a random IV using the PRNG of the device.
var IV = new Uint8Array(16);
crypto.getRandomValues(IV);
return crypto.subtle.encrypt({ name: 'AES-CBC', iv: IV }, keyBundle.aes,
cleartext).then(ciphertext => {
const ciphertextB64 = StringConversion.arrayBufferToBase64String(
ciphertext);
return crypto.subtle.sign({ name: 'HMAC', hash: 'SHA-256' },
keyBundle.hmac,
StringConversion.stringToUtf8Uint8Array(
ciphertextB64)).
then(hmac => {
return {
hmac: StringConversion.arrayBufferToHexString(hmac),
ciphertext: ciphertextB64,
IV: StringConversion.uint8ArrayToBase64String(IV)
};
});
});
};
/*
* encrypt - encrypt and sign a record
*
* @param {Object} record Object The data to be JSON-stringified and stored
* @param {String} collectionName String The name of the Sync collection
* (currently ignored, see
* https://github.com/michielbdejong/fxsync-webcrypto/issues/19)
* @returns {Promise} A promise for an object with ciphertext, IV, and hmac.
*/
const encrypt = function(record, collectionName) {
if (typeof record !== 'object') {
return Promise.reject(new Error('Record should be an object'));
}
if (typeof collectionName !== 'string') {
return Promise.reject(new Error('collectionName is not a string'));
}
var cleartextStr, keyBundle;
try {
cleartextStr = JSON.stringify(record);
} catch(e) {
return Promise.reject(new Error('Record cannot be JSON-stringified'));
}
const cleartext = StringConversion.stringToUtf8Uint8Array(cleartextStr);
try {
keyBundle = this.bulkKeyBundle.defaultAsKeyBundle;
} catch(e) {
return Promise.reject(new Error(`No key bundle found for \
${collectionName} - did you call setKeys?`));
}
return encryptAndSign(keyBundle, cleartext);
};
const FxSyncWebCrypto = function() {
// Basic check for presence of WebCrypto.
if (!crypto || !crypto.subtle) {
throw new Error('This environment does not support WebCrypto');
}
this.bulkKeyBundle = null;
};
FxSyncWebCrypto.prototype = Object.freeze({
setKeys: setKeys,
decrypt: decrypt,
encrypt: encrypt
});
// Expose this for unit test:
FxSyncWebCrypto.importKeyBundle = Object.freeze(importKeyBundle);
return FxSyncWebCrypto;
})());