diff options
Diffstat (limited to 'services/crypto')
-rw-r--r-- | services/crypto/cryptoComponents.manifest | 1 | ||||
-rw-r--r-- | services/crypto/modules/WeaveCrypto.sys.mjs | 232 | ||||
-rw-r--r-- | services/crypto/modules/jwcrypto.sys.mjs | 214 | ||||
-rw-r--r-- | services/crypto/modules/utils.sys.mjs | 539 | ||||
-rw-r--r-- | services/crypto/moz.build | 20 | ||||
-rw-r--r-- | services/crypto/tests/unit/head_helpers.js | 78 | ||||
-rw-r--r-- | services/crypto/tests/unit/test_crypto_crypt.js | 226 | ||||
-rw-r--r-- | services/crypto/tests/unit/test_crypto_random.js | 52 | ||||
-rw-r--r-- | services/crypto/tests/unit/test_jwcrypto.js | 51 | ||||
-rw-r--r-- | services/crypto/tests/unit/test_load_modules.js | 12 | ||||
-rw-r--r-- | services/crypto/tests/unit/test_utils_hawk.js | 346 | ||||
-rw-r--r-- | services/crypto/tests/unit/test_utils_httpmac.js | 73 | ||||
-rw-r--r-- | services/crypto/tests/unit/xpcshell.toml | 18 |
13 files changed, 1862 insertions, 0 deletions
diff --git a/services/crypto/cryptoComponents.manifest b/services/crypto/cryptoComponents.manifest new file mode 100644 index 0000000000..f9f47bb42a --- /dev/null +++ b/services/crypto/cryptoComponents.manifest @@ -0,0 +1 @@ +resource services-crypto resource://gre/modules/services-crypto/ diff --git a/services/crypto/modules/WeaveCrypto.sys.mjs b/services/crypto/modules/WeaveCrypto.sys.mjs new file mode 100644 index 0000000000..63ee51a1a2 --- /dev/null +++ b/services/crypto/modules/WeaveCrypto.sys.mjs @@ -0,0 +1,232 @@ +/* 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 CRYPT_ALGO = "AES-CBC"; +const CRYPT_ALGO_LENGTH = 256; +const CRYPT_ALGO_USAGES = ["encrypt", "decrypt"]; +const AES_CBC_IV_SIZE = 16; +const OPERATIONS = { ENCRYPT: 0, DECRYPT: 1 }; +const UTF_LABEL = "utf-8"; + +export function WeaveCrypto() { + this.init(); +} + +WeaveCrypto.prototype = { + prefBranch: null, + debug: true, // services.sync.log.cryptoDebug + + observer: { + _self: null, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + observe(subject, topic, data) { + let self = this._self; + self.log("Observed " + topic + " topic."); + if (topic == "nsPref:changed") { + self.debug = self.prefBranch.getBoolPref("cryptoDebug"); + } + }, + }, + + init() { + // Preferences. Add observer so we get notified of changes. + this.prefBranch = Services.prefs.getBranch("services.sync.log."); + this.prefBranch.addObserver("cryptoDebug", this.observer); + this.observer._self = this; + this.debug = this.prefBranch.getBoolPref("cryptoDebug", false); + ChromeUtils.defineLazyGetter( + this, + "encoder", + () => new TextEncoder(UTF_LABEL) + ); + ChromeUtils.defineLazyGetter( + this, + "decoder", + () => new TextDecoder(UTF_LABEL, { fatal: true }) + ); + }, + + log(message) { + if (!this.debug) { + return; + } + dump("WeaveCrypto: " + message + "\n"); + Services.console.logStringMessage("WeaveCrypto: " + message); + }, + + // /!\ Only use this for tests! /!\ + _getCrypto() { + return crypto; + }, + + async encrypt(clearTextUCS2, symmetricKey, iv) { + this.log("encrypt() called"); + let clearTextBuffer = this.encoder.encode(clearTextUCS2).buffer; + let encrypted = await this._commonCrypt( + clearTextBuffer, + symmetricKey, + iv, + OPERATIONS.ENCRYPT + ); + return this.encodeBase64(encrypted); + }, + + async decrypt(cipherText, symmetricKey, iv) { + this.log("decrypt() called"); + if (cipherText.length) { + cipherText = atob(cipherText); + } + let cipherTextBuffer = this.byteCompressInts(cipherText); + let decrypted = await this._commonCrypt( + cipherTextBuffer, + symmetricKey, + iv, + OPERATIONS.DECRYPT + ); + return this.decoder.decode(decrypted); + }, + + /** + * _commonCrypt + * + * @args + * data: data to encrypt/decrypt (ArrayBuffer) + * symKeyStr: symmetric key (Base64 String) + * ivStr: initialization vector (Base64 String) + * operation: operation to apply (either OPERATIONS.ENCRYPT or OPERATIONS.DECRYPT) + * @returns + * the encrypted/decrypted data (ArrayBuffer) + */ + async _commonCrypt(data, symKeyStr, ivStr, operation) { + this.log("_commonCrypt() called"); + ivStr = atob(ivStr); + + if (operation !== OPERATIONS.ENCRYPT && operation !== OPERATIONS.DECRYPT) { + throw new Error("Unsupported operation in _commonCrypt."); + } + // We never want an IV longer than the block size, which is 16 bytes + // for AES, neither do we want one smaller; throw in both cases. + if (ivStr.length !== AES_CBC_IV_SIZE) { + throw new Error(`Invalid IV size; must be ${AES_CBC_IV_SIZE} bytes.`); + } + + let iv = this.byteCompressInts(ivStr); + let symKey = await this.importSymKey(symKeyStr, operation); + let cryptMethod = ( + operation === OPERATIONS.ENCRYPT + ? crypto.subtle.encrypt + : crypto.subtle.decrypt + ).bind(crypto.subtle); + let algo = { name: CRYPT_ALGO, iv }; + + let keyBytes = await cryptMethod.call(crypto.subtle, algo, symKey, data); + return new Uint8Array(keyBytes); + }, + + async generateRandomKey() { + this.log("generateRandomKey() called"); + let algo = { + name: CRYPT_ALGO, + length: CRYPT_ALGO_LENGTH, + }; + let key = await crypto.subtle.generateKey(algo, true, CRYPT_ALGO_USAGES); + let keyBytes = await crypto.subtle.exportKey("raw", key); + return this.encodeBase64(new Uint8Array(keyBytes)); + }, + + generateRandomIV() { + return this.generateRandomBytes(AES_CBC_IV_SIZE); + }, + + generateRandomBytes(byteCount) { + this.log("generateRandomBytes() called"); + + let randBytes = new Uint8Array(byteCount); + crypto.getRandomValues(randBytes); + + return this.encodeBase64(randBytes); + }, + + // + // SymKey CryptoKey memoization. + // + + // Memoize the import of symmetric keys. We do this by using the base64 + // string itself as a key. + _encryptionSymKeyMemo: {}, + _decryptionSymKeyMemo: {}, + async importSymKey(encodedKeyString, operation) { + let memo; + + // We use two separate memos for thoroughness: operation is an input to + // key import. + switch (operation) { + case OPERATIONS.ENCRYPT: + memo = this._encryptionSymKeyMemo; + break; + case OPERATIONS.DECRYPT: + memo = this._decryptionSymKeyMemo; + break; + default: + throw new Error("Unsupported operation in importSymKey."); + } + + if (encodedKeyString in memo) { + return memo[encodedKeyString]; + } + + let symmetricKeyBuffer = this.makeUint8Array(encodedKeyString, true); + let algo = { name: CRYPT_ALGO }; + let usages = [operation === OPERATIONS.ENCRYPT ? "encrypt" : "decrypt"]; + let symKey = await crypto.subtle.importKey( + "raw", + symmetricKeyBuffer, + algo, + false, + usages + ); + memo[encodedKeyString] = symKey; + return symKey; + }, + + // + // Utility functions + // + + /** + * Returns an Uint8Array filled with a JS string, + * which means we only keep utf-16 characters from 0x00 to 0xFF. + */ + byteCompressInts(str) { + let arrayBuffer = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) { + arrayBuffer[i] = str.charCodeAt(i) & 0xff; + } + return arrayBuffer; + }, + + expandData(data) { + let expanded = ""; + for (let i = 0; i < data.length; i++) { + expanded += String.fromCharCode(data[i]); + } + return expanded; + }, + + encodeBase64(data) { + return btoa(this.expandData(data)); + }, + + makeUint8Array(input, isEncoded) { + if (isEncoded) { + input = atob(input); + } + return this.byteCompressInts(input); + }, +}; diff --git a/services/crypto/modules/jwcrypto.sys.mjs b/services/crypto/modules/jwcrypto.sys.mjs new file mode 100644 index 0000000000..d1f37eaa58 --- /dev/null +++ b/services/crypto/modules/jwcrypto.sys.mjs @@ -0,0 +1,214 @@ +/* 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 ECDH_PARAMS = { + name: "ECDH", + namedCurve: "P-256", +}; +const AES_PARAMS = { + name: "AES-GCM", + length: 256, +}; +const AES_TAG_LEN = 128; +const AES_GCM_IV_SIZE = 12; +const UTF8_ENCODER = new TextEncoder(); +const UTF8_DECODER = new TextDecoder(); + +class JWCrypto { + /** + * Encrypts the given data into a JWE using AES-256-GCM content encryption. + * + * This function implements a very small subset of the JWE encryption standard + * from https://tools.ietf.org/html/rfc7516. The only supported content encryption + * algorithm is enc="A256GCM" [1] and the only supported key encryption algorithm + * is alg="ECDH-ES" [2]. + * + * @param {Object} key Peer Public JWK. + * @param {ArrayBuffer} data + * + * [1] https://tools.ietf.org/html/rfc7518#section-5.3 + * [2] https://tools.ietf.org/html/rfc7518#section-4.6 + * + * @returns {Promise<String>} + */ + async generateJWE(key, data) { + // Generate an ephemeral key to use just for this encryption. + // The public component gets embedded in the JWE header. + const epk = await crypto.subtle.generateKey(ECDH_PARAMS, true, [ + "deriveKey", + ]); + const ownPublicJWK = await crypto.subtle.exportKey("jwk", epk.publicKey); + // Remove properties added by our WebCrypto implementation but that aren't typically + // used with JWE in the wild. This saves space in the resulting JWE, and makes it easier + // to re-import the resulting JWK. + delete ownPublicJWK.key_ops; + delete ownPublicJWK.ext; + let header = { alg: "ECDH-ES", enc: "A256GCM", epk: ownPublicJWK }; + // Import the peer's public key. + const peerPublicKey = await crypto.subtle.importKey( + "jwk", + key, + ECDH_PARAMS, + false, + ["deriveKey"] + ); + if (key.hasOwnProperty("kid")) { + header.kid = key.kid; + } + // Do ECDH agreement to get the content encryption key. + const contentKey = await deriveECDHSharedAESKey( + epk.privateKey, + peerPublicKey, + ["encrypt"] + ); + // Encrypt with AES-GCM using the generated key. + // Note that the IV is generated randomly, which *in general* is not safe to do with AES-GCM because + // it's too short to guarantee uniqueness. But we know that the AES-GCM key itself is unique and will + // only be used for this single encryption, making a random IV safe to use for this particular use-case. + let iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_SIZE)); + // Yes, additionalData is the byte representation of the base64 representation of the stringified header. + const additionalData = UTF8_ENCODER.encode( + ChromeUtils.base64URLEncode(UTF8_ENCODER.encode(JSON.stringify(header)), { + pad: false, + }) + ); + const encrypted = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv, + additionalData, + tagLength: AES_TAG_LEN, + }, + contentKey, + data + ); + // JWE needs the authentication tag as a separate string. + const tagIdx = encrypted.byteLength - ((AES_TAG_LEN + 7) >> 3); + let ciphertext = encrypted.slice(0, tagIdx); + let tag = encrypted.slice(tagIdx); + // JWE serialization in compact format. + header = UTF8_ENCODER.encode(JSON.stringify(header)); + header = ChromeUtils.base64URLEncode(header, { pad: false }); + tag = ChromeUtils.base64URLEncode(tag, { pad: false }); + ciphertext = ChromeUtils.base64URLEncode(ciphertext, { pad: false }); + iv = ChromeUtils.base64URLEncode(iv, { pad: false }); + return `${header}..${iv}.${ciphertext}.${tag}`; // No CEK + } + + /** + * Decrypts the given JWE using AES-256-GCM content encryption into a byte array. + * This function does the opposite of `JWCrypto.generateJWE`. + * The only supported content encryption algorithm is enc="A256GCM" [1] + * and the only supported key encryption algorithm is alg="ECDH-ES" [2]. + * + * @param {"ECDH-ES"} algorithm + * @param {CryptoKey} key Local private key + * + * [1] https://tools.ietf.org/html/rfc7518#section-5.3 + * [2] https://tools.ietf.org/html/rfc7518#section-4.6 + * + * @returns {Promise<Uint8Array>} + */ + async decryptJWE(jwe, key) { + let [header, cek, iv, ciphertext, authTag] = jwe.split("."); + const additionalData = UTF8_ENCODER.encode(header); + header = JSON.parse( + UTF8_DECODER.decode( + ChromeUtils.base64URLDecode(header, { padding: "reject" }) + ) + ); + if (!!cek.length || header.enc !== "A256GCM" || header.alg !== "ECDH-ES") { + throw new Error("Unknown algorithm."); + } + if ("apu" in header || "apv" in header) { + throw new Error("apu and apv header values are not supported."); + } + const peerPublicKey = await crypto.subtle.importKey( + "jwk", + header.epk, + ECDH_PARAMS, + false, + ["deriveKey"] + ); + // Do ECDH agreement to get the content encryption key. + const contentKey = await deriveECDHSharedAESKey(key, peerPublicKey, [ + "decrypt", + ]); + iv = ChromeUtils.base64URLDecode(iv, { padding: "reject" }); + ciphertext = new Uint8Array( + ChromeUtils.base64URLDecode(ciphertext, { padding: "reject" }) + ); + authTag = new Uint8Array( + ChromeUtils.base64URLDecode(authTag, { padding: "reject" }) + ); + const bundle = new Uint8Array([...ciphertext, ...authTag]); + + const decrypted = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv, + tagLength: AES_TAG_LEN, + additionalData, + }, + contentKey, + bundle + ); + return new Uint8Array(decrypted); + } +} + +/** + * Do an ECDH agreement between a public and private key, + * returning the derived encryption key as specced by + * JWA RFC. + * The raw ECDH secret is derived into a key using + * Concat KDF, as defined in Section 5.8.1 of [NIST.800-56A]. + * @param {CryptoKey} privateKey + * @param {CryptoKey} publicKey + * @param {String[]} keyUsages See `SubtleCrypto.deriveKey` 5th paramater documentation. + * @returns {Promise<CryptoKey>} + */ +async function deriveECDHSharedAESKey(privateKey, publicKey, keyUsages) { + const params = { ...ECDH_PARAMS, ...{ public: publicKey } }; + const sharedKey = await crypto.subtle.deriveKey( + params, + privateKey, + AES_PARAMS, + true, + keyUsages + ); + // This is the NIST Concat KDF specialized to a specific set of parameters, + // which basically turn it into a single application of SHA256. + // The details are from the JWA RFC. + let sharedKeyBytes = await crypto.subtle.exportKey("raw", sharedKey); + sharedKeyBytes = new Uint8Array(sharedKeyBytes); + const info = [ + "\x00\x00\x00\x07A256GCM", // 7-byte algorithm identifier + "\x00\x00\x00\x00", // empty PartyUInfo + "\x00\x00\x00\x00", // empty PartyVInfo + "\x00\x00\x01\x00", // keylen == 256 + ].join(""); + const pkcs = `\x00\x00\x00\x01${String.fromCharCode.apply( + null, + sharedKeyBytes + )}${info}`; + const pkcsBuf = Uint8Array.from( + Array.prototype.map.call(pkcs, c => c.charCodeAt(0)) + ); + const derivedKeyBytes = await crypto.subtle.digest( + { + name: "SHA-256", + }, + pkcsBuf + ); + return crypto.subtle.importKey( + "raw", + derivedKeyBytes, + AES_PARAMS, + false, + keyUsages + ); +} + +export const jwcrypto = new JWCrypto(); diff --git a/services/crypto/modules/utils.sys.mjs b/services/crypto/modules/utils.sys.mjs new file mode 100644 index 0000000000..3aa3db32d1 --- /dev/null +++ b/services/crypto/modules/utils.sys.mjs @@ -0,0 +1,539 @@ +/* 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 { Observers } from "resource://services-common/observers.sys.mjs"; + +import { CommonUtils } from "resource://services-common/utils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "textEncoder", function () { + return new TextEncoder(); +}); + +/** + * A number of `Legacy` suffixed functions are exposed by CryptoUtils. + * They work with octet strings, which were used before Javascript + * got ArrayBuffer and friends. + */ +export var CryptoUtils = { + xor(a, b) { + let bytes = []; + + if (a.length != b.length) { + throw new Error( + "can't xor unequal length strings: " + a.length + " vs " + b.length + ); + } + + for (let i = 0; i < a.length; i++) { + bytes[i] = a.charCodeAt(i) ^ b.charCodeAt(i); + } + + return String.fromCharCode.apply(String, bytes); + }, + + /** + * Generate a string of random bytes. + * @returns {String} Octet string + */ + generateRandomBytesLegacy(length) { + let bytes = CryptoUtils.generateRandomBytes(length); + return CommonUtils.arrayBufferToByteString(bytes); + }, + + generateRandomBytes(length) { + return crypto.getRandomValues(new Uint8Array(length)); + }, + + /** + * UTF8-encode a message and hash it with the given hasher. Returns a + * string containing bytes. + */ + digestUTF8(message, hasher) { + let data = lazy.textEncoder.encode(message); + hasher.update(data, data.length); + let result = hasher.finish(false); + return result; + }, + + /** + * Treat the given message as a bytes string (if necessary) and hash it with + * the given hasher. Returns a string containing bytes. + */ + digestBytes(bytes, hasher) { + if (typeof bytes == "string" || bytes instanceof String) { + bytes = CommonUtils.byteStringToArrayBuffer(bytes); + } + return CryptoUtils.digestBytesArray(bytes, hasher); + }, + + digestBytesArray(bytes, hasher) { + hasher.update(bytes, bytes.length); + let result = hasher.finish(false); + return result; + }, + + /** + * Encode the message into UTF-8 and feed the resulting bytes into the + * given hasher. Does not return a hash. This can be called multiple times + * with a single hasher, but eventually you must extract the result + * yourself. + */ + updateUTF8(message, hasher) { + let bytes = lazy.textEncoder.encode(message); + hasher.update(bytes, bytes.length); + }, + + sha256(message) { + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(hasher.SHA256); + return CommonUtils.bytesAsHex(CryptoUtils.digestUTF8(message, hasher)); + }, + + sha256Base64(message) { + let data = lazy.textEncoder.encode(message); + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(hasher.SHA256); + hasher.update(data, data.length); + return hasher.finish(true); + }, + + /** + * @param {string} alg Hash algorithm (common values are SHA-1 or SHA-256) + * @param {string} key Key as an octet string. + * @param {string} data Data as an octet string. + */ + async hmacLegacy(alg, key, data) { + if (!key || !key.length) { + key = "\0"; + } + data = CommonUtils.byteStringToArrayBuffer(data); + key = CommonUtils.byteStringToArrayBuffer(key); + const result = await CryptoUtils.hmac(alg, key, data); + return CommonUtils.arrayBufferToByteString(result); + }, + + /** + * @param {string} ikm IKM as an octet string. + * @param {string} salt Salt as an Hex string. + * @param {string} info Info as a regular string. + * @param {Number} len Desired output length in bytes. + */ + async hkdfLegacy(ikm, xts, info, len) { + ikm = CommonUtils.byteStringToArrayBuffer(ikm); + xts = CommonUtils.byteStringToArrayBuffer(xts); + info = lazy.textEncoder.encode(info); + const okm = await CryptoUtils.hkdf(ikm, xts, info, len); + return CommonUtils.arrayBufferToByteString(okm); + }, + + /** + * @param {String} alg Hash algorithm (common values are SHA-1 or SHA-256) + * @param {ArrayBuffer} key + * @param {ArrayBuffer} data + * @param {Number} len Desired output length in bytes. + * @returns {Uint8Array} + */ + async hmac(alg, key, data) { + const hmacKey = await crypto.subtle.importKey( + "raw", + key, + { name: "HMAC", hash: alg }, + false, + ["sign"] + ); + const result = await crypto.subtle.sign("HMAC", hmacKey, data); + return new Uint8Array(result); + }, + + /** + * @param {ArrayBuffer} ikm + * @param {ArrayBuffer} salt + * @param {ArrayBuffer} info + * @param {Number} len Desired output length in bytes. + * @returns {Uint8Array} + */ + async hkdf(ikm, salt, info, len) { + const key = await crypto.subtle.importKey( + "raw", + ikm, + { name: "HKDF" }, + false, + ["deriveBits"] + ); + const okm = await crypto.subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt, + info, + }, + key, + len * 8 + ); + return new Uint8Array(okm); + }, + + /** + * PBKDF2 password stretching with SHA-256 hmac. + * + * @param {string} passphrase Passphrase as an octet string. + * @param {string} salt Salt as an octet string. + * @param {string} iterations Number of iterations, a positive integer. + * @param {string} len Desired output length in bytes. + */ + async pbkdf2Generate(passphrase, salt, iterations, len) { + passphrase = CommonUtils.byteStringToArrayBuffer(passphrase); + salt = CommonUtils.byteStringToArrayBuffer(salt); + const key = await crypto.subtle.importKey( + "raw", + passphrase, + { name: "PBKDF2" }, + false, + ["deriveBits"] + ); + const output = await crypto.subtle.deriveBits( + { + name: "PBKDF2", + hash: "SHA-256", + salt, + iterations, + }, + key, + len * 8 + ); + return CommonUtils.arrayBufferToByteString(new Uint8Array(output)); + }, + + /** + * Compute the HTTP MAC SHA-1 for an HTTP request. + * + * @param identifier + * (string) MAC Key Identifier. + * @param key + * (string) MAC Key. + * @param method + * (string) HTTP request method. + * @param URI + * (nsIURI) HTTP request URI. + * @param extra + * (object) Optional extra parameters. Valid keys are: + * nonce_bytes - How many bytes the nonce should be. This defaults + * to 8. Note that this many bytes are Base64 encoded, so the + * string length of the nonce will be longer than this value. + * ts - Timestamp to use. Should only be defined for testing. + * nonce - String nonce. Should only be defined for testing as this + * function will generate a cryptographically secure random one + * if not defined. + * ext - Extra string to be included in MAC. Per the HTTP MAC spec, + * the format is undefined and thus application specific. + * @returns + * (object) Contains results of operation and input arguments (for + * symmetry). The object has the following keys: + * + * identifier - (string) MAC Key Identifier (from arguments). + * key - (string) MAC Key (from arguments). + * method - (string) HTTP request method (from arguments). + * hostname - (string) HTTP hostname used (derived from arguments). + * port - (string) HTTP port number used (derived from arguments). + * mac - (string) Raw HMAC digest bytes. + * getHeader - (function) Call to obtain the string Authorization + * header value for this invocation. + * nonce - (string) Nonce value used. + * ts - (number) Integer seconds since Unix epoch that was used. + */ + async computeHTTPMACSHA1(identifier, key, method, uri, extra) { + let ts = extra && extra.ts ? extra.ts : Math.floor(Date.now() / 1000); + let nonce_bytes = extra && extra.nonce_bytes > 0 ? extra.nonce_bytes : 8; + + // We are allowed to use more than the Base64 alphabet if we want. + let nonce = + extra && extra.nonce + ? extra.nonce + : btoa(CryptoUtils.generateRandomBytesLegacy(nonce_bytes)); + + let host = uri.asciiHost; + let port; + let usedMethod = method.toUpperCase(); + + if (uri.port != -1) { + port = uri.port; + } else if (uri.scheme == "http") { + port = "80"; + } else if (uri.scheme == "https") { + port = "443"; + } else { + throw new Error("Unsupported URI scheme: " + uri.scheme); + } + + let ext = extra && extra.ext ? extra.ext : ""; + + let requestString = + ts.toString(10) + + "\n" + + nonce + + "\n" + + usedMethod + + "\n" + + uri.pathQueryRef + + "\n" + + host + + "\n" + + port + + "\n" + + ext + + "\n"; + + const mac = await CryptoUtils.hmacLegacy("SHA-1", key, requestString); + + function getHeader() { + return CryptoUtils.getHTTPMACSHA1Header( + this.identifier, + this.ts, + this.nonce, + this.mac, + this.ext + ); + } + + return { + identifier, + key, + method: usedMethod, + hostname: host, + port, + mac, + nonce, + ts, + ext, + getHeader, + }; + }, + + /** + * Obtain the HTTP MAC Authorization header value from fields. + * + * @param identifier + * (string) MAC key identifier. + * @param ts + * (number) Integer seconds since Unix epoch. + * @param nonce + * (string) Nonce value. + * @param mac + * (string) Computed HMAC digest (raw bytes). + * @param ext + * (optional) (string) Extra string content. + * @returns + * (string) Value to put in Authorization header. + */ + getHTTPMACSHA1Header: function getHTTPMACSHA1Header( + identifier, + ts, + nonce, + mac, + ext + ) { + let header = + 'MAC id="' + + identifier + + '", ' + + 'ts="' + + ts + + '", ' + + 'nonce="' + + nonce + + '", ' + + 'mac="' + + btoa(mac) + + '"'; + + if (!ext) { + return header; + } + + return (header += ', ext="' + ext + '"'); + }, + + /** + * Given an HTTP header value, strip out any attributes. + */ + + stripHeaderAttributes(value) { + value = value || ""; + let i = value.indexOf(";"); + return value + .substring(0, i >= 0 ? i : undefined) + .trim() + .toLowerCase(); + }, + + /** + * Compute the HAWK client values (mostly the header) for an HTTP request. + * + * @param URI + * (nsIURI) HTTP request URI. + * @param method + * (string) HTTP request method. + * @param options + * (object) extra parameters (all but "credentials" are optional): + * credentials - (object, mandatory) HAWK credentials object. + * All three keys are required: + * id - (string) key identifier + * key - (string) raw key bytes + * ext - (string) application-specific data, included in MAC + * localtimeOffsetMsec - (number) local clock offset (vs server) + * payload - (string) payload to include in hash, containing the + * HTTP request body. If not provided, the HAWK hash + * will not cover the request body, and the server + * should not check it either. This will be UTF-8 + * encoded into bytes before hashing. This function + * cannot handle arbitrary binary data, sorry (the + * UTF-8 encoding process will corrupt any codepoints + * between U+0080 and U+00FF). Callers must be careful + * to use an HTTP client function which encodes the + * payload exactly the same way, otherwise the hash + * will not match. + * contentType - (string) payload Content-Type. This is included + * (without any attributes like "charset=") in the + * HAWK hash. It does *not* affect interpretation + * of the "payload" property. + * hash - (base64 string) pre-calculated payload hash. If + * provided, "payload" is ignored. + * ts - (number) pre-calculated timestamp, secs since epoch + * now - (number) current time, ms-since-epoch, for tests + * nonce - (string) pre-calculated nonce. Should only be defined + * for testing as this function will generate a + * cryptographically secure random one if not defined. + * @returns + * Promise<Object> Contains results of operation. The object has the + * following keys: + * field - (string) HAWK header, to use in Authorization: header + * artifacts - (object) other generated values: + * ts - (number) timestamp, in seconds since epoch + * nonce - (string) + * method - (string) + * resource - (string) path plus querystring + * host - (string) + * port - (number) + * hash - (string) payload hash (base64) + * ext - (string) app-specific data + * MAC - (string) request MAC (base64) + */ + async computeHAWK(uri, method, options) { + let credentials = options.credentials; + let ts = + options.ts || + Math.floor( + ((options.now || Date.now()) + (options.localtimeOffsetMsec || 0)) / + 1000 + ); + let port; + if (uri.port != -1) { + port = uri.port; + } else if (uri.scheme == "http") { + port = 80; + } else if (uri.scheme == "https") { + port = 443; + } else { + throw new Error("Unsupported URI scheme: " + uri.scheme); + } + + let artifacts = { + ts, + nonce: options.nonce || btoa(CryptoUtils.generateRandomBytesLegacy(8)), + method: method.toUpperCase(), + resource: uri.pathQueryRef, // This includes both path and search/queryarg. + host: uri.asciiHost.toLowerCase(), // This includes punycoding. + port: port.toString(10), + hash: options.hash, + ext: options.ext, + }; + + let contentType = CryptoUtils.stripHeaderAttributes(options.contentType); + + if ( + !artifacts.hash && + options.hasOwnProperty("payload") && + options.payload + ) { + const buffer = lazy.textEncoder.encode( + `hawk.1.payload\n${contentType}\n${options.payload}\n` + ); + const hash = await crypto.subtle.digest("SHA-256", buffer); + // HAWK specifies this .hash to use +/ (not _-) and include the + // trailing "==" padding. + artifacts.hash = ChromeUtils.base64URLEncode(hash, { pad: true }) + .replace(/-/g, "+") + .replace(/_/g, "/"); + } + + let requestString = + "hawk.1.header\n" + + artifacts.ts.toString(10) + + "\n" + + artifacts.nonce + + "\n" + + artifacts.method + + "\n" + + artifacts.resource + + "\n" + + artifacts.host + + "\n" + + artifacts.port + + "\n" + + (artifacts.hash || "") + + "\n"; + if (artifacts.ext) { + requestString += artifacts.ext.replace("\\", "\\\\").replace("\n", "\\n"); + } + requestString += "\n"; + + const hash = await CryptoUtils.hmacLegacy( + "SHA-256", + credentials.key, + requestString + ); + artifacts.mac = btoa(hash); + // The output MAC uses "+" and "/", and padded== . + + function escape(attribute) { + // This is used for "x=y" attributes inside HTTP headers. + return attribute.replace(/\\/g, "\\\\").replace(/\"/g, '\\"'); + } + let header = + 'Hawk id="' + + credentials.id + + '", ' + + 'ts="' + + artifacts.ts + + '", ' + + 'nonce="' + + artifacts.nonce + + '", ' + + (artifacts.hash ? 'hash="' + artifacts.hash + '", ' : "") + + (artifacts.ext ? 'ext="' + escape(artifacts.ext) + '", ' : "") + + 'mac="' + + artifacts.mac + + '"'; + return { + artifacts, + field: header, + }; + }, +}; + +var Svc = {}; + +Observers.add("xpcom-shutdown", function unloadServices() { + Observers.remove("xpcom-shutdown", unloadServices); + + for (let k in Svc) { + delete Svc[k]; + } +}); diff --git a/services/crypto/moz.build b/services/crypto/moz.build new file mode 100644 index 0000000000..4a1650cb6d --- /dev/null +++ b/services/crypto/moz.build @@ -0,0 +1,20 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Sync") + +XPCSHELL_TESTS_MANIFESTS += ["tests/unit/xpcshell.toml"] + +EXTRA_JS_MODULES["services-crypto"] += [ + "modules/jwcrypto.sys.mjs", + "modules/utils.sys.mjs", + "modules/WeaveCrypto.sys.mjs", +] + +EXTRA_COMPONENTS += [ + "cryptoComponents.manifest", +] diff --git a/services/crypto/tests/unit/head_helpers.js b/services/crypto/tests/unit/head_helpers.js new file mode 100644 index 0000000000..322f6761a9 --- /dev/null +++ b/services/crypto/tests/unit/head_helpers.js @@ -0,0 +1,78 @@ +/* import-globals-from ../../../common/tests/unit/head_helpers.js */ + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +try { + // In the context of xpcshell tests, there won't be a default AppInfo + // eslint-disable-next-line mozilla/use-services + Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo); +} catch (ex) { + // Make sure to provide the right OS so crypto loads the right binaries + var OS = "XPCShell"; + if (mozinfo.os == "win") { + OS = "WINNT"; + } else if (mozinfo.os == "mac") { + OS = "Darwin"; + } else { + OS = "Linux"; + } + + const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" + ); + updateAppInfo({ + name: "XPCShell", + ID: "{3e3ba16c-1675-4e88-b9c8-afef81b3d2ef}", + version: "1", + platformVersion: "", + OS, + }); +} + +function base64UrlDecode(s) { + s = s.replace(/-/g, "+"); + s = s.replace(/_/g, "/"); + + // Replace padding if it was stripped by the sender. + // See http://tools.ietf.org/html/rfc4648#section-4 + switch (s.length % 4) { + case 0: + break; // No pad chars in this case + case 2: + s += "=="; + break; // Two pad chars + case 3: + s += "="; + break; // One pad char + default: + throw new Error("Illegal base64url string!"); + } + + // With correct padding restored, apply the standard base64 decoder + return atob(s); +} + +// Register resource alias. Normally done in SyncComponents.manifest. +function addResourceAlias() { + const resProt = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + let uri = Services.io.newURI("resource://gre/modules/services-crypto/"); + resProt.setSubstitution("services-crypto", uri); +} +addResourceAlias(); + +/** + * Print some debug message to the console. All arguments will be printed, + * separated by spaces. + * + * @param [arg0, arg1, arg2, ...] + * Any number of arguments to print out + * @usage _("Hello World") -> prints "Hello World" + * @usage _(1, 2, 3) -> prints "1 2 3" + */ +var _ = function (some, debug, text, to) { + print(Array.from(arguments).join(" ")); +}; diff --git a/services/crypto/tests/unit/test_crypto_crypt.js b/services/crypto/tests/unit/test_crypto_crypt.js new file mode 100644 index 0000000000..8fadd307c7 --- /dev/null +++ b/services/crypto/tests/unit/test_crypto_crypt.js @@ -0,0 +1,226 @@ +const { WeaveCrypto } = ChromeUtils.importESModule( + "resource://services-crypto/WeaveCrypto.sys.mjs" +); + +var cryptoSvc = new WeaveCrypto(); + +add_task(async function test_key_memoization() { + let cryptoGlobal = cryptoSvc._getCrypto(); + let oldImport = cryptoGlobal.subtle.importKey; + if (!oldImport) { + _("Couldn't swizzle crypto.subtle.importKey; returning."); + return; + } + + let iv = cryptoSvc.generateRandomIV(); + let key = await cryptoSvc.generateRandomKey(); + let c = 0; + cryptoGlobal.subtle.importKey = function ( + format, + keyData, + algo, + extractable, + usages + ) { + c++; + return oldImport.call( + cryptoGlobal.subtle, + format, + keyData, + algo, + extractable, + usages + ); + }; + + // Encryption should cause a single counter increment. + Assert.equal(c, 0); + let cipherText = await cryptoSvc.encrypt("Hello, world.", key, iv); + Assert.equal(c, 1); + cipherText = await cryptoSvc.encrypt("Hello, world.", key, iv); + Assert.equal(c, 1); + + // ... as should decryption. + await cryptoSvc.decrypt(cipherText, key, iv); + await cryptoSvc.decrypt(cipherText, key, iv); + await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(c, 2); + + // Un-swizzle. + cryptoGlobal.subtle.importKey = oldImport; +}); + +// Just verify that it gets populated with the correct bytes. +add_task(async function test_makeUint8Array() { + ChromeUtils.importESModule("resource://gre/modules/ctypes.sys.mjs"); + + let item1 = cryptoSvc.makeUint8Array("abcdefghi", false); + Assert.ok(item1); + for (let i = 0; i < 8; ++i) { + Assert.equal(item1[i], "abcdefghi".charCodeAt(i)); + } +}); + +add_task(async function test_encrypt_decrypt() { + // First, do a normal run with expected usage... Generate a random key and + // iv, encrypt and decrypt a string. + var iv = cryptoSvc.generateRandomIV(); + Assert.equal(iv.length, 24); + + var key = await cryptoSvc.generateRandomKey(); + Assert.equal(key.length, 44); + + var mySecret = "bacon is a vegetable"; + var cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + Assert.equal(cipherText.length, 44); + + var clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(clearText.length, 20); + + // Did the text survive the encryption round-trip? + Assert.equal(clearText, mySecret); + Assert.notEqual(cipherText, mySecret); // just to be explicit + + // Do some more tests with a fixed key/iv, to check for reproducable results. + key = "St1tFCor7vQEJNug/465dQ=="; + iv = "oLjkfrLIOnK2bDRvW4kXYA=="; + + _("Testing small IV."); + mySecret = "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo="; + let shortiv = "YWJj"; + let err; + try { + await cryptoSvc.encrypt(mySecret, key, shortiv); + } catch (ex) { + err = ex; + } + Assert.ok(!!err); + + _("Testing long IV."); + let longiv = "gsgLRDaxWvIfKt75RjuvFWERt83FFsY2A0TW+0b2iVk="; + try { + await cryptoSvc.encrypt(mySecret, key, longiv); + } catch (ex) { + err = ex; + } + Assert.ok(!!err); + + // Test small input sizes + mySecret = ""; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "OGQjp6mK1a3fs9k9Ml4L3w=="); + Assert.equal(clearText, mySecret); + + mySecret = "x"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "96iMl4vhOxFUW/lVHHzVqg=="); + Assert.equal(clearText, mySecret); + + mySecret = "xx"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "olpPbETRYROCSqFWcH2SWg=="); + Assert.equal(clearText, mySecret); + + mySecret = "xxx"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "rRbpHGyVSZizLX/x43Wm+Q=="); + Assert.equal(clearText, mySecret); + + mySecret = "xxxx"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "HeC7miVGDcpxae9RmiIKAw=="); + Assert.equal(clearText, mySecret); + + // Test non-ascii input + // ("testuser1" using similar-looking glyphs) + mySecret = String.fromCharCode(355, 277, 349, 357, 533, 537, 101, 345, 185); + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "Pj4ixByXoH3SU3JkOXaEKPgwRAWplAWFLQZkpJd5Kr4="); + Assert.equal(clearText, mySecret); + + // Tests input spanning a block boundary (AES block size is 16 bytes) + mySecret = "123456789012345"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "e6c5hwphe45/3VN/M0bMUA=="); + Assert.equal(clearText, mySecret); + + mySecret = "1234567890123456"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "V6aaOZw8pWlYkoIHNkhsP1JOIQF87E2vTUvBUQnyV04="); + Assert.equal(clearText, mySecret); + + mySecret = "12345678901234567"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "V6aaOZw8pWlYkoIHNkhsP5GvxWJ9+GIAS6lXw+5fHTI="); + Assert.equal(clearText, mySecret); + + key = "iz35tuIMq4/H+IYw2KTgow=="; + iv = "TJYrvva2KxvkM8hvOIvWp3=="; + mySecret = "i like pie"; + + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "DLGx8BWqSCLGG7i/xwvvxg=="); + Assert.equal(clearText, mySecret); + + key = "c5hG3YG+NC61FFy8NOHQak1ZhMEWO79bwiAfar2euzI="; + iv = "gsgLRDaxWvIfKt75RjuvFW=="; + mySecret = "i like pie"; + + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + clearText = await cryptoSvc.decrypt(cipherText, key, iv); + Assert.equal(cipherText, "o+ADtdMd8ubzNWurS6jt0Q=="); + Assert.equal(clearText, mySecret); + + key = "St1tFCor7vQEJNug/465dQ=="; + iv = "oLjkfrLIOnK2bDRvW4kXYA=="; + mySecret = "does thunder read testcases?"; + cipherText = await cryptoSvc.encrypt(mySecret, key, iv); + Assert.equal(cipherText, "T6fik9Ros+DB2ablH9zZ8FWZ0xm/szSwJjIHZu7sjPs="); + + var badkey = "badkeybadkeybadkeybadk=="; + var badiv = "badivbadivbadivbadivbad="; + var badcipher = "crapinputcrapinputcrapinputcrapinputcrapinp="; + var failure; + + try { + failure = false; + clearText = await cryptoSvc.decrypt(cipherText, badkey, iv); + } catch (e) { + failure = true; + } + Assert.ok(failure); + + try { + failure = false; + clearText = await cryptoSvc.decrypt(cipherText, key, badiv); + } catch (e) { + failure = true; + } + Assert.ok(failure); + + try { + failure = false; + clearText = await cryptoSvc.decrypt(cipherText, badkey, badiv); + } catch (e) { + failure = true; + } + Assert.ok(failure); + + try { + failure = false; + clearText = await cryptoSvc.decrypt(badcipher, key, iv); + } catch (e) { + failure = true; + } + Assert.ok(failure); +}); diff --git a/services/crypto/tests/unit/test_crypto_random.js b/services/crypto/tests/unit/test_crypto_random.js new file mode 100644 index 0000000000..bd913c81e8 --- /dev/null +++ b/services/crypto/tests/unit/test_crypto_random.js @@ -0,0 +1,52 @@ +const { WeaveCrypto } = ChromeUtils.importESModule( + "resource://services-crypto/WeaveCrypto.sys.mjs" +); + +var cryptoSvc = new WeaveCrypto(); + +add_task(async function test_crypto_random() { + if (this.gczeal) { + _("Running crypto random tests with gczeal(2)."); + gczeal(2); + } + + // Test salt generation. + var salt; + + salt = cryptoSvc.generateRandomBytes(0); + Assert.equal(salt.length, 0); + salt = cryptoSvc.generateRandomBytes(1); + Assert.equal(salt.length, 4); + salt = cryptoSvc.generateRandomBytes(2); + Assert.equal(salt.length, 4); + salt = cryptoSvc.generateRandomBytes(3); + Assert.equal(salt.length, 4); + salt = cryptoSvc.generateRandomBytes(4); + Assert.equal(salt.length, 8); + salt = cryptoSvc.generateRandomBytes(8); + Assert.equal(salt.length, 12); + + // sanity check to make sure salts seem random + var salt2 = cryptoSvc.generateRandomBytes(8); + Assert.equal(salt2.length, 12); + Assert.notEqual(salt, salt2); + + salt = cryptoSvc.generateRandomBytes(1024); + Assert.equal(salt.length, 1368); + salt = cryptoSvc.generateRandomBytes(16); + Assert.equal(salt.length, 24); + + // Test random key generation + var keydata, keydata2, iv; + + keydata = await cryptoSvc.generateRandomKey(); + Assert.equal(keydata.length, 44); + keydata2 = await cryptoSvc.generateRandomKey(); + Assert.notEqual(keydata, keydata2); // sanity check for randomness + iv = cryptoSvc.generateRandomIV(); + Assert.equal(iv.length, 24); + + if (this.gczeal) { + gczeal(0); + } +}); diff --git a/services/crypto/tests/unit/test_jwcrypto.js b/services/crypto/tests/unit/test_jwcrypto.js new file mode 100644 index 0000000000..02f064d431 --- /dev/null +++ b/services/crypto/tests/unit/test_jwcrypto.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs", +}); + +// Enable logging from jwcrypto.jsm. +Services.prefs.setStringPref("services.crypto.jwcrypto.log.level", "Debug"); + +add_task(async function test_jwe_roundtrip_ecdh_es_encryption() { + const plaintext = crypto.getRandomValues(new Uint8Array(123)); + const remoteKey = await crypto.subtle.generateKey( + { + name: "ECDH", + namedCurve: "P-256", + }, + true, + ["deriveKey"] + ); + const remoteJWK = await crypto.subtle.exportKey("jwk", remoteKey.publicKey); + delete remoteJWK.key_ops; + const jwe = await jwcrypto.generateJWE(remoteJWK, plaintext); + const decrypted = await jwcrypto.decryptJWE(jwe, remoteKey.privateKey); + Assert.deepEqual(plaintext, decrypted); +}); + +add_task(async function test_jwe_header_includes_key_id() { + const plaintext = crypto.getRandomValues(new Uint8Array(123)); + const remoteKey = await crypto.subtle.generateKey( + { + name: "ECDH", + namedCurve: "P-256", + }, + true, + ["deriveKey"] + ); + const remoteJWK = await crypto.subtle.exportKey("jwk", remoteKey.publicKey); + delete remoteJWK.key_ops; + remoteJWK.kid = "key identifier"; + const jwe = await jwcrypto.generateJWE(remoteJWK, plaintext); + let [header /* other items deliberately ignored */] = jwe.split("."); + header = JSON.parse( + new TextDecoder().decode( + ChromeUtils.base64URLDecode(header, { padding: "reject" }) + ) + ); + Assert.equal(header.kid, "key identifier"); +}); diff --git a/services/crypto/tests/unit/test_load_modules.js b/services/crypto/tests/unit/test_load_modules.js new file mode 100644 index 0000000000..2c850d3dab --- /dev/null +++ b/services/crypto/tests/unit/test_load_modules.js @@ -0,0 +1,12 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const modules = ["utils.sys.mjs", "WeaveCrypto.sys.mjs"]; + +function run_test() { + for (let m of modules) { + let resource = "resource://services-crypto/" + m; + _("Attempting to import: " + resource); + ChromeUtils.importESModule(resource); + } +} diff --git a/services/crypto/tests/unit/test_utils_hawk.js b/services/crypto/tests/unit/test_utils_hawk.js new file mode 100644 index 0000000000..71702b7349 --- /dev/null +++ b/services/crypto/tests/unit/test_utils_hawk.js @@ -0,0 +1,346 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CryptoUtils } = ChromeUtils.importESModule( + "resource://services-crypto/utils.sys.mjs" +); + +function run_test() { + initTestLogging(); + + run_next_test(); +} + +add_task(async function test_hawk() { + let compute = CryptoUtils.computeHAWK; + + let method = "POST"; + let ts = 1353809207; + let nonce = "Ygvqdz"; + + let credentials = { + id: "123456", + key: "2983d45yun89q", + }; + + let uri_https = CommonUtils.makeURI( + "https://example.net/somewhere/over/the/rainbow" + ); + let opts = { + credentials, + ext: "Bazinga!", + ts, + nonce, + payload: "something to write about", + contentType: "text/plain", + }; + + let result = await compute(uri_https, method, opts); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", ' + + 'hash="2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=", ' + + 'ext="Bazinga!", ' + + 'mac="q1CwFoSHzPZSkbIvl0oYlD+91rBUEvFk763nMjMndj8="' + ); + Assert.equal(result.artifacts.ts, ts); + Assert.equal(result.artifacts.nonce, nonce); + Assert.equal(result.artifacts.method, method); + Assert.equal(result.artifacts.resource, "/somewhere/over/the/rainbow"); + Assert.equal(result.artifacts.host, "example.net"); + Assert.equal(result.artifacts.port, 443); + Assert.equal( + result.artifacts.hash, + "2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=" + ); + Assert.equal(result.artifacts.ext, "Bazinga!"); + + let opts_noext = { + credentials, + ts, + nonce, + payload: "something to write about", + contentType: "text/plain", + }; + result = await compute(uri_https, method, opts_noext); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", ' + + 'hash="2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=", ' + + 'mac="HTgtd0jPI6E4izx8e4OHdO36q00xFCU0FolNq3RiCYs="' + ); + Assert.equal(result.artifacts.ts, ts); + Assert.equal(result.artifacts.nonce, nonce); + Assert.equal(result.artifacts.method, method); + Assert.equal(result.artifacts.resource, "/somewhere/over/the/rainbow"); + Assert.equal(result.artifacts.host, "example.net"); + Assert.equal(result.artifacts.port, 443); + Assert.equal( + result.artifacts.hash, + "2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=" + ); + + /* Leaving optional fields out should work, although of course then we can't + * assert much about the resulting hashes. The resulting header should look + * roughly like: + * Hawk id="123456", ts="1378764955", nonce="QkynqsrS44M=", mac="/C5NsoAs2fVn+d/I5wMfwe2Gr1MZyAJ6pFyDHG4Gf9U=" + */ + + result = await compute(uri_https, method, { credentials }); + let fields = result.field.split(" "); + Assert.equal(fields[0], "Hawk"); + Assert.equal(fields[1], 'id="123456",'); // from creds.id + Assert.ok(fields[2].startsWith('ts="')); + /* The HAWK spec calls for seconds-since-epoch, not ms-since-epoch. + * Warning: this test will fail in the year 33658, and for time travellers + * who journey earlier than 2001. Please plan accordingly. */ + Assert.ok(result.artifacts.ts > 1000 * 1000 * 1000); + Assert.ok(result.artifacts.ts < 1000 * 1000 * 1000 * 1000); + Assert.ok(fields[3].startsWith('nonce="')); + Assert.equal(fields[3].length, 'nonce="12345678901=",'.length); + Assert.equal(result.artifacts.nonce.length, "12345678901=".length); + + let result2 = await compute(uri_https, method, { credentials }); + Assert.notEqual(result.artifacts.nonce, result2.artifacts.nonce); + + /* Using an upper-case URI hostname shouldn't affect the hash. */ + + let uri_https_upper = CommonUtils.makeURI( + "https://EXAMPLE.NET/somewhere/over/the/rainbow" + ); + result = await compute(uri_https_upper, method, opts); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", ' + + 'hash="2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=", ' + + 'ext="Bazinga!", ' + + 'mac="q1CwFoSHzPZSkbIvl0oYlD+91rBUEvFk763nMjMndj8="' + ); + + /* Using a lower-case method name shouldn't affect the hash. */ + result = await compute(uri_https_upper, method.toLowerCase(), opts); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", ' + + 'hash="2QfCt3GuY9HQnHWyWD3wX68ZOKbynqlfYmuO2ZBRqtY=", ' + + 'ext="Bazinga!", ' + + 'mac="q1CwFoSHzPZSkbIvl0oYlD+91rBUEvFk763nMjMndj8="' + ); + + /* The localtimeOffsetMsec field should be honored. HAWK uses this to + * compensate for clock skew between client and server: if the request is + * rejected with a timestamp out-of-range error, the error includes the + * server's time, and the client computes its clock offset and tries again. + * Clients can remember this offset for a while. + */ + + result = await compute(uri_https, method, { + credentials, + now: 1378848968650, + }); + Assert.equal(result.artifacts.ts, 1378848968); + + result = await compute(uri_https, method, { + credentials, + now: 1378848968650, + localtimeOffsetMsec: 1000 * 1000, + }); + Assert.equal(result.artifacts.ts, 1378848968 + 1000); + + /* Search/query-args in URIs should be included in the hash. */ + let makeURI = CommonUtils.makeURI; + result = await compute(makeURI("http://example.net/path"), method, opts); + Assert.equal(result.artifacts.resource, "/path"); + Assert.equal( + result.artifacts.mac, + "WyKHJjWaeYt8aJD+H9UeCWc0Y9C+07ooTmrcrOW4MPI=" + ); + + result = await compute(makeURI("http://example.net/path/"), method, opts); + Assert.equal(result.artifacts.resource, "/path/"); + Assert.equal( + result.artifacts.mac, + "xAYp2MgZQFvTKJT9u8nsvMjshCRRkuaeYqQbYSFp9Qw=" + ); + + result = await compute( + makeURI("http://example.net/path?query=search"), + method, + opts + ); + Assert.equal(result.artifacts.resource, "/path?query=search"); + Assert.equal( + result.artifacts.mac, + "C06a8pip2rA4QkBiosEmC32WcgFcW/R5SQC6kUWyqho=" + ); + + /* Test handling of the payload, which is supposed to be a bytestring + (String with codepoints from U+0000 to U+00FF, pre-encoded). */ + + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + }); + Assert.equal(result.artifacts.hash, undefined); + Assert.equal( + result.artifacts.mac, + "S3f8E4hAURAqJxOlsYugkPZxLoRYrClgbSQ/3FmKMbY=" + ); + + // Empty payload changes nothing. + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + payload: null, + }); + Assert.equal(result.artifacts.hash, undefined); + Assert.equal( + result.artifacts.mac, + "S3f8E4hAURAqJxOlsYugkPZxLoRYrClgbSQ/3FmKMbY=" + ); + + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + payload: "hello", + }); + Assert.equal( + result.artifacts.hash, + "uZJnFj0XVBA6Rs1hEvdIDf8NraM0qRNXdFbR3NEQbVA=" + ); + Assert.equal( + result.artifacts.mac, + "pLsHHzngIn5CTJhWBtBr+BezUFvdd/IadpTp/FYVIRM=" + ); + + // update, utf-8 payload + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + payload: "andré@example.org", // non-ASCII + }); + Assert.equal( + result.artifacts.hash, + "66DiyapJ0oGgj09IXWdMv8VCg9xk0PL5RqX7bNnQW2k=" + ); + Assert.equal( + result.artifacts.mac, + "2B++3x5xfHEZbPZGDiK3IwfPZctkV4DUr2ORg1vIHvk=" + ); + + /* If "hash" is provided, "payload" is ignored. */ + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + hash: "66DiyapJ0oGgj09IXWdMv8VCg9xk0PL5RqX7bNnQW2k=", + payload: "something else", + }); + Assert.equal( + result.artifacts.hash, + "66DiyapJ0oGgj09IXWdMv8VCg9xk0PL5RqX7bNnQW2k=" + ); + Assert.equal( + result.artifacts.mac, + "2B++3x5xfHEZbPZGDiK3IwfPZctkV4DUr2ORg1vIHvk=" + ); + + // the payload "hash" is also non-urlsafe base64 (+/) + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + payload: "something else", + }); + Assert.equal( + result.artifacts.hash, + "lERFXr/IKOaAoYw+eBseDUSwmqZTX0uKZpcWLxsdzt8=" + ); + Assert.equal( + result.artifacts.mac, + "jiZuhsac35oD7IdcblhFncBr8tJFHcwWLr8NIYWr9PQ=" + ); + + /* Test non-ascii hostname. HAWK (via the node.js "url" module) punycodes + * "ëxample.net" into "xn--xample-ova.net" before hashing. I still think + * punycode was a bad joke that got out of the lab and into a spec. + */ + + result = await compute(makeURI("http://ëxample.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + }); + Assert.equal( + result.artifacts.mac, + "pILiHl1q8bbNQIdaaLwAFyaFmDU70MGehFuCs3AA5M0=" + ); + Assert.equal(result.artifacts.host, "xn--xample-ova.net"); + + result = await compute(makeURI("http://example.net/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + ext: 'backslash=\\ quote=" EOF', + }); + Assert.equal( + result.artifacts.mac, + "BEMW76lwaJlPX4E/dajF970T6+GzWvaeyLzUt8eOTOc=" + ); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", ext="backslash=\\\\ quote=\\" EOF", mac="BEMW76lwaJlPX4E/dajF970T6+GzWvaeyLzUt8eOTOc="' + ); + + result = await compute(makeURI("http://example.net:1234/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + }); + Assert.equal( + result.artifacts.mac, + "6D3JSFDtozuq8QvJTNUc1JzeCfy6h5oRvlhmSTPv6LE=" + ); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", mac="6D3JSFDtozuq8QvJTNUc1JzeCfy6h5oRvlhmSTPv6LE="' + ); + + /* HAWK (the node.js library) uses a URL parser which stores the "port" + * field as a string, but makeURI() gives us an integer. So we'll diverge + * on ports with a leading zero. This test vector would fail on the node.js + * library (HAWK-1.1.1), where they get a MAC of + * "T+GcAsDO8GRHIvZLeepSvXLwDlFJugcZroAy9+uAtcw=". I think HAWK should be + * updated to do what we do here, so port="01234" should get the same hash + * as port="1234". + */ + result = await compute(makeURI("http://example.net:01234/path"), method, { + credentials, + ts: 1353809207, + nonce: "Ygvqdz", + }); + Assert.equal( + result.artifacts.mac, + "6D3JSFDtozuq8QvJTNUc1JzeCfy6h5oRvlhmSTPv6LE=" + ); + Assert.equal( + result.field, + 'Hawk id="123456", ts="1353809207", nonce="Ygvqdz", mac="6D3JSFDtozuq8QvJTNUc1JzeCfy6h5oRvlhmSTPv6LE="' + ); +}); + +add_test(function test_strip_header_attributes() { + let strip = CryptoUtils.stripHeaderAttributes; + + Assert.equal(strip(undefined), ""); + Assert.equal(strip("text/plain"), "text/plain"); + Assert.equal(strip("TEXT/PLAIN"), "text/plain"); + Assert.equal(strip(" text/plain "), "text/plain"); + Assert.equal(strip("text/plain ; charset=utf-8 "), "text/plain"); + + run_next_test(); +}); diff --git a/services/crypto/tests/unit/test_utils_httpmac.js b/services/crypto/tests/unit/test_utils_httpmac.js new file mode 100644 index 0000000000..4831683e70 --- /dev/null +++ b/services/crypto/tests/unit/test_utils_httpmac.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { CryptoUtils } = ChromeUtils.importESModule( + "resource://services-crypto/utils.sys.mjs" +); + +add_test(function setup() { + initTestLogging(); + run_next_test(); +}); + +add_task(async function test_sha1() { + _("Ensure HTTP MAC SHA1 generation works as expected."); + + let id = "vmo1txkttblmn51u2p3zk2xiy16hgvm5ok8qiv1yyi86ffjzy9zj0ez9x6wnvbx7"; + let key = "b8u1cc5iiio5o319og7hh8faf2gi5ym4aq0zwf112cv1287an65fudu5zj7zo7dz"; + let ts = 1329181221; + let method = "GET"; + let nonce = "wGX71"; + let uri = CommonUtils.makeURI("http://10.250.2.176/alias/"); + + let result = await CryptoUtils.computeHTTPMACSHA1(id, key, method, uri, { + ts, + nonce, + }); + + Assert.equal(btoa(result.mac), "jzh5chjQc2zFEvLbyHnPdX11Yck="); + + Assert.equal( + result.getHeader(), + 'MAC id="vmo1txkttblmn51u2p3zk2xiy16hgvm5ok8qiv1yyi86ffjzy9zj0ez9x6wnvbx7", ' + + 'ts="1329181221", nonce="wGX71", mac="jzh5chjQc2zFEvLbyHnPdX11Yck="' + ); + + let ext = "EXTRA DATA; foo,bar=1"; + + result = await CryptoUtils.computeHTTPMACSHA1(id, key, method, uri, { + ts, + nonce, + ext, + }); + Assert.equal(btoa(result.mac), "bNf4Fnt5k6DnhmyipLPkuZroH68="); + Assert.equal( + result.getHeader(), + 'MAC id="vmo1txkttblmn51u2p3zk2xiy16hgvm5ok8qiv1yyi86ffjzy9zj0ez9x6wnvbx7", ' + + 'ts="1329181221", nonce="wGX71", mac="bNf4Fnt5k6DnhmyipLPkuZroH68=", ' + + 'ext="EXTRA DATA; foo,bar=1"' + ); +}); + +add_task(async function test_nonce_length() { + _("Ensure custom nonce lengths are honoured."); + + function get_mac(length) { + let uri = CommonUtils.makeURI("http://example.com/"); + return CryptoUtils.computeHTTPMACSHA1("foo", "bar", "GET", uri, { + nonce_bytes: length, + }); + } + + let result = await get_mac(12); + Assert.equal(12, atob(result.nonce).length); + + result = await get_mac(2); + Assert.equal(2, atob(result.nonce).length); + + result = await get_mac(0); + Assert.equal(8, atob(result.nonce).length); + + result = await get_mac(-1); + Assert.equal(8, atob(result.nonce).length); +}); diff --git a/services/crypto/tests/unit/xpcshell.toml b/services/crypto/tests/unit/xpcshell.toml new file mode 100644 index 0000000000..9ce7748357 --- /dev/null +++ b/services/crypto/tests/unit/xpcshell.toml @@ -0,0 +1,18 @@ +[DEFAULT] +head = "head_helpers.js ../../../common/tests/unit/head_helpers.js" +firefox-appdir = "browser" +support-files = ["!/services/common/tests/unit/head_helpers.js"] + +["test_crypto_crypt.js"] + +["test_crypto_random.js"] +skip-if = ["appname == 'thunderbird'"] + +["test_jwcrypto.js"] +skip-if = ["appname == 'thunderbird'"] + +["test_load_modules.js"] + +["test_utils_hawk.js"] + +["test_utils_httpmac.js"] |