/* 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/. */

this.EXPORTED_SYMBOLS = ["WeaveCrypto"];

var {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;

Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://services-common/async.js");

Cu.importGlobalProperties(["crypto"]);

const CRYPT_ALGO        = "AES-CBC";
const CRYPT_ALGO_LENGTH = 256;
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;

this.WeaveCrypto = function WeaveCrypto() {
    this.init();
};

WeaveCrypto.prototype = {
    prefBranch: null,
    debug: true,  // services.sync.log.cryptoDebug

    observer: {
        _self: null,

        QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
                                               Ci.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;
    },

    encrypt(clearTextUCS2, symmetricKey, iv) {
        this.log("encrypt() called");
        let clearTextBuffer = this.encoder.encode(clearTextUCS2).buffer;
        let encrypted = this._commonCrypt(clearTextBuffer, symmetricKey, iv, OPERATIONS.ENCRYPT);
        return this.encodeBase64(encrypted);
    },

    decrypt(cipherText, symmetricKey, iv) {
        this.log("decrypt() called");
        if (cipherText.length) {
            cipherText = atob(cipherText);
        }
        let cipherTextBuffer = this.byteCompressInts(cipherText);
        let decrypted = 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)
    */
    _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 "Invalid IV size; must be " + AES_CBC_IV_SIZE + " bytes.";
        }

        let iv = this.byteCompressInts(ivStr);
        let symKey = this.importSymKey(symKeyStr, operation);
        let cryptMethod = (operation === OPERATIONS.ENCRYPT
                           ? crypto.subtle.encrypt
                           : crypto.subtle.decrypt)
                          .bind(crypto.subtle);
        let algo = { name: CRYPT_ALGO, iv };


        return Async.promiseSpinningly(
            cryptMethod(algo, symKey, data)
            .then(keyBytes => new Uint8Array(keyBytes))
        );
    },


    generateRandomKey() {
        this.log("generateRandomKey() called");
        let algo = {
            name: CRYPT_ALGO,
            length: CRYPT_ALGO_LENGTH
        };
        return Async.promiseSpinningly(
            crypto.subtle.generateKey(algo, true, [])
            .then(key => crypto.subtle.exportKey("raw", key))
            .then(keyBytes => {
                keyBytes = new Uint8Array(keyBytes);
                return this.encodeBase64(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: {},
    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 "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"];

        return Async.promiseSpinningly(
            crypto.subtle.importKey("raw", symmetricKeyBuffer, algo, false, usages)
            .then(symKey => {
                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);
    },

    /**
     * Returns the expanded data string for the derived key.
     */
    deriveKeyFromPassphrase(passphrase, saltStr, keyLength = 32) {
        this.log("deriveKeyFromPassphrase() called.");
        let keyData = this.makeUint8Array(passphrase, false);
        let salt = this.makeUint8Array(saltStr, true);
        let importAlgo = { name: KEY_DERIVATION_ALGO };
        let deriveAlgo = {
            name: KEY_DERIVATION_ALGO,
            salt,
            iterations: KEY_DERIVATION_ITERATIONS,
            hash: { name: KEY_DERIVATION_HASHING_ALGO },
        };
        let derivedKeyType = {
            name: DERIVED_KEY_ALGO,
            length: keyLength * 8,
        };
        return Async.promiseSpinningly(
            crypto.subtle.importKey("raw", keyData, importAlgo, false, ["deriveKey"])
            .then(key => crypto.subtle.deriveKey(deriveAlgo, key, derivedKeyType, true, []))
            .then(derivedKey => crypto.subtle.exportKey("raw", derivedKey))
            .then(keyBytes => {
                keyBytes = new Uint8Array(keyBytes);
                return this.expandData(keyBytes);
            })
        );
    },
};
