diff options
Diffstat (limited to 'services/crypto/modules')
-rw-r--r-- | services/crypto/modules/WeaveCrypto.js | 245 | ||||
-rw-r--r-- | services/crypto/modules/jwcrypto.jsm | 377 | ||||
-rw-r--r-- | services/crypto/modules/utils.js | 587 |
3 files changed, 1209 insertions, 0 deletions
diff --git a/services/crypto/modules/WeaveCrypto.js b/services/crypto/modules/WeaveCrypto.js new file mode 100644 index 0000000000..fb9bfd5553 --- /dev/null +++ b/services/crypto/modules/WeaveCrypto.js @@ -0,0 +1,245 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["WeaveCrypto"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyGlobalGetters(this, ["crypto"]); + +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"; + +const KEY_DERIVATION_ALGO = "PBKDF2"; +const KEY_DERIVATION_HASHING_ALGO = "SHA-1"; +const KEY_DERIVATION_ITERATIONS = 4096; // PKCS#5 recommends at least 1000. +const DERIVED_KEY_ALGO = CRYPT_ALGO; + +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); + XPCOMUtils.defineLazyGetter( + this, + "encoder", + () => new TextEncoder(UTF_LABEL) + ); + XPCOMUtils.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.jsm b/services/crypto/modules/jwcrypto.jsm new file mode 100644 index 0000000000..61f7d136e3 --- /dev/null +++ b/services/crypto/modules/jwcrypto.jsm @@ -0,0 +1,377 @@ +/* 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"; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); + +XPCOMUtils.defineLazyServiceGetter( + this, + "IdentityCryptoService", + "@mozilla.org/identity/crypto-service;1", + "nsIIdentityCryptoService" +); +XPCOMUtils.defineLazyGlobalGetters(this, ["crypto"]); + +const EXPORTED_SYMBOLS = ["jwcrypto"]; + +const PREF_LOG_LEVEL = "services.crypto.jwcrypto.log.level"; +XPCOMUtils.defineLazyGetter(this, "log", function() { + const log = Log.repository.getLogger("Services.Crypto.jwcrypto"); + // Default log level is "Error", but consumers can change this with the pref + // "services.crypto.jwcrypto.log.level". + log.level = Log.Level.Error; + const appender = new Log.DumpAppender(); + log.addAppender(appender); + try { + const level = + Services.prefs.getPrefType(PREF_LOG_LEVEL) == + Ci.nsIPrefBranch.PREF_STRING && + Services.prefs.getCharPref(PREF_LOG_LEVEL); + log.level = Log.Level[level] || Log.Level.Error; + } catch (e) { + log.error(e); + } + + return log; +}); + +const ASSERTION_DEFAULT_DURATION_MS = 1000 * 60 * 2; // 2 minutes default assertion lifetime +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 > 0 || + 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); + } + + generateKeyPair(aAlgorithmName, aCallback) { + log.debug("generating"); + log.debug("Generate key pair; alg = " + aAlgorithmName); + + IdentityCryptoService.generateKeyPair(aAlgorithmName, (rv, aKeyPair) => { + if (!Components.isSuccessCode(rv)) { + return aCallback("key generation failed"); + } + + let publicKey; + + switch (aKeyPair.keyType) { + case "RS256": + publicKey = { + algorithm: "RS", + exponent: aKeyPair.hexRSAPublicKeyExponent, + modulus: aKeyPair.hexRSAPublicKeyModulus, + }; + break; + + case "DS160": + publicKey = { + algorithm: "DS", + y: aKeyPair.hexDSAPublicValue, + p: aKeyPair.hexDSAPrime, + q: aKeyPair.hexDSASubPrime, + g: aKeyPair.hexDSAGenerator, + }; + break; + + default: + return aCallback("unknown key type"); + } + + const keyWrapper = { + serializedPublicKey: JSON.stringify(publicKey), + _kp: aKeyPair, + }; + + return aCallback(null, keyWrapper); + }); + } + + /** + * Generate an assertion and return it through the provided callback. + * + * @param aCert + * Identity certificate + * + * @param aKeyPair + * KeyPair object + * + * @param aAudience + * Audience of the assertion + * + * @param aOptions (optional) + * Can include: + * { + * localtimeOffsetMsec: <clock offset in milliseconds>, + * now: <current date in milliseconds> + * duration: <validity duration for this assertion in milliseconds> + * } + * + * localtimeOffsetMsec is the number of milliseconds that need to be + * added to the local clock time to make it concur with the server. + * For example, if the local clock is two minutes fast, the offset in + * milliseconds would be -120000. + * + * @param aCallback + * Function to invoke with resulting assertion. Assertion + * will be string or null on failure. + */ + generateAssertion(aCert, aKeyPair, aAudience, aOptions, aCallback) { + if (typeof aOptions == "function") { + aCallback = aOptions; + aOptions = {}; + } + + // for now, we hack the algorithm name + // XXX bug 769851 + const header = { alg: "DS128" }; + const headerBytes = IdentityCryptoService.base64UrlEncode( + JSON.stringify(header) + ); + + function getExpiration( + duration = ASSERTION_DEFAULT_DURATION_MS, + localtimeOffsetMsec = 0, + now = Date.now() + ) { + return now + localtimeOffsetMsec + duration; + } + + const payload = { + exp: getExpiration( + aOptions.duration, + aOptions.localtimeOffsetMsec, + aOptions.now + ), + aud: aAudience, + }; + const payloadBytes = IdentityCryptoService.base64UrlEncode( + JSON.stringify(payload) + ); + + log.debug("payload", { payload, payloadBytes }); + const message = headerBytes + "." + payloadBytes; + aKeyPair._kp.sign(message, (rv, signature) => { + if (!Components.isSuccessCode(rv)) { + log.error("signer.sign failed"); + aCallback("Sign failed"); + return; + } + log.debug("signer.sign: success"); + const signedAssertion = message + "." + signature; + aCallback(null, aCert + "~" + signedAssertion); + }); + } +} + +/** + * 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 + ); +} + +const jwcrypto = new JWCrypto(); diff --git a/services/crypto/modules/utils.js b/services/crypto/modules/utils.js new file mode 100644 index 0000000000..3f37260f13 --- /dev/null +++ b/services/crypto/modules/utils.js @@ -0,0 +1,587 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["CryptoUtils"]; + +const { Observers } = ChromeUtils.import( + "resource://services-common/observers.js" +); +const { CommonUtils } = ChromeUtils.import( + "resource://services-common/utils.js" +); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyGlobalGetters(this, ["crypto"]); + +XPCOMUtils.defineLazyGetter(this, "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. + */ +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. The hasher is reset if it's an HMAC hasher. + */ + digestUTF8(message, hasher) { + let data = this._utf8Converter.convertToByteArray(message, {}); + hasher.update(data, data.length); + let result = hasher.finish(false); + if (hasher instanceof Ci.nsICryptoHMAC) { + hasher.reset(); + } + return result; + }, + + /** + * Treat the given message as a bytes string (if necessary) and hash it with + * the given hasher. Returns a string containing bytes. + * The hasher is reset if it's an HMAC hasher. + */ + 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); + if (hasher instanceof Ci.nsICryptoHMAC) { + hasher.reset(); + } + 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 = this._utf8Converter.convertToByteArray(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 = this._utf8Converter.convertToByteArray(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); + }, + + /** + * Produce an HMAC key object from a key string. + */ + makeHMACKey: function makeHMACKey(str) { + return Svc.KeyFactory.keyFromString(Ci.nsIKeyObject.HMAC, str); + }, + + /** + * Produce an HMAC hasher and initialize it with the given HMAC key. + */ + makeHMACHasher: function makeHMACHasher(type, key) { + let hasher = Cc["@mozilla.org/security/hmac;1"].createInstance( + Ci.nsICryptoHMAC + ); + hasher.init(type, key); + return hasher; + }, + + /** + * @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 = 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 = 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, + }; + }, +}; + +XPCOMUtils.defineLazyGetter(CryptoUtils, "_utf8Converter", function() { + let converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + + return converter; +}); + +var Svc = {}; + +XPCOMUtils.defineLazyServiceGetter( + Svc, + "KeyFactory", + "@mozilla.org/security/keyobjectfactory;1", + "nsIKeyObjectFactory" +); + +Observers.add("xpcom-shutdown", function unloadServices() { + Observers.remove("xpcom-shutdown", unloadServices); + + for (let k in Svc) { + delete Svc[k]; + } +}); |