/* 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";

this.EXPORTED_SYMBOLS = ['ExtensionStorageEngine', 'EncryptionRemoteTransformer',
                         'KeyRingEncryptionRemoteTransformer'];

const {classes: Cc, interfaces: Ci, utils: Cu} = Components;

Cu.import("resource://services-crypto/utils.js");
Cu.import("resource://services-sync/constants.js");
Cu.import("resource://services-sync/engines.js");
Cu.import("resource://services-sync/keys.js");
Cu.import("resource://services-sync/util.js");
Cu.import("resource://services-common/async.js");
XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorageSync",
                                  "resource://gre/modules/ExtensionStorageSync.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
                                  "resource://gre/modules/FxAccounts.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                  "resource://gre/modules/Task.jsm");

/**
 * The Engine that manages syncing for the web extension "storage"
 * API, and in particular ext.storage.sync.
 *
 * ext.storage.sync is implemented using Kinto, so it has mechanisms
 * for syncing that we do not need to integrate in the Firefox Sync
 * framework, so this is something of a stub.
 */
this.ExtensionStorageEngine = function ExtensionStorageEngine(service) {
  SyncEngine.call(this, "Extension-Storage", service);
};
ExtensionStorageEngine.prototype = {
  __proto__: SyncEngine.prototype,
  _trackerObj: ExtensionStorageTracker,
  // we don't need these since we implement our own sync logic
  _storeObj: undefined,
  _recordObj: undefined,

  syncPriority: 10,
  allowSkippedRecord: false,

  _sync: function () {
    return Async.promiseSpinningly(ExtensionStorageSync.syncAll());
  },

  get enabled() {
    // By default, we sync extension storage if we sync addons. This
    // lets us simplify the UX since users probably don't consider
    // "extension preferences" a separate category of syncing.
    // However, we also respect engine.extension-storage.force, which
    // can be set to true or false, if a power user wants to customize
    // the behavior despite the lack of UI.
    const forced = Svc.Prefs.get("engine." + this.prefName + ".force", undefined);
    if (forced !== undefined) {
      return forced;
    }
    return Svc.Prefs.get("engine.addons", false);
  },
};

function ExtensionStorageTracker(name, engine) {
  Tracker.call(this, name, engine);
}
ExtensionStorageTracker.prototype = {
  __proto__: Tracker.prototype,

  startTracking: function () {
    Svc.Obs.add("ext.storage.sync-changed", this);
  },

  stopTracking: function () {
    Svc.Obs.remove("ext.storage.sync-changed", this);
  },

  observe: function (subject, topic, data) {
    Tracker.prototype.observe.call(this, subject, topic, data);

    if (this.ignoreAll) {
      return;
    }

    if (topic !== "ext.storage.sync-changed") {
      return;
    }

    // Single adds, removes and changes are not so important on their
    // own, so let's just increment score a bit.
    this.score += SCORE_INCREMENT_MEDIUM;
  },

  // Override a bunch of methods which don't do anything for us.
  // This is a performance hack.
  saveChangedIDs: function() {
  },
  loadChangedIDs: function() {
  },
  ignoreID: function() {
  },
  unignoreID: function() {
  },
  addChangedID: function() {
  },
  removeChangedID: function() {
  },
  clearChangedIDs: function() {
  },
};

/**
 * Utility function to enforce an order of fields when computing an HMAC.
 */
function ciphertextHMAC(keyBundle, id, IV, ciphertext) {
  const hasher = keyBundle.sha256HMACHasher;
  return Utils.bytesAsHex(Utils.digestUTF8(id + IV + ciphertext, hasher));
}

/**
 * A "remote transformer" that the Kinto library will use to
 * encrypt/decrypt records when syncing.
 *
 * This is an "abstract base class". Subclass this and override
 * getKeys() to use it.
 */
class EncryptionRemoteTransformer {
  encode(record) {
    const self = this;
    return Task.spawn(function* () {
      const keyBundle = yield self.getKeys();
      if (record.ciphertext) {
        throw new Error("Attempt to reencrypt??");
      }
      let id = record.id;
      if (!record.id) {
        throw new Error("Record ID is missing or invalid");
      }

      let IV = Svc.Crypto.generateRandomIV();
      let ciphertext = Svc.Crypto.encrypt(JSON.stringify(record),
                                          keyBundle.encryptionKeyB64, IV);
      let hmac = ciphertextHMAC(keyBundle, id, IV, ciphertext);
      const encryptedResult = {ciphertext, IV, hmac, id};
      if (record.hasOwnProperty("last_modified")) {
        encryptedResult.last_modified = record.last_modified;
      }
      return encryptedResult;
    });
  }

  decode(record) {
    const self = this;
    return Task.spawn(function* () {
      if (!record.ciphertext) {
        // This can happen for tombstones if a record is deleted.
        if (record.deleted) {
          return record;
        }
        throw new Error("No ciphertext: nothing to decrypt?");
      }
      const keyBundle = yield self.getKeys();
      // Authenticate the encrypted blob with the expected HMAC
      let computedHMAC = ciphertextHMAC(keyBundle, record.id, record.IV, record.ciphertext);

      if (computedHMAC != record.hmac) {
        Utils.throwHMACMismatch(record.hmac, computedHMAC);
      }

      // Handle invalid data here. Elsewhere we assume that cleartext is an object.
      let cleartext = Svc.Crypto.decrypt(record.ciphertext,
                                         keyBundle.encryptionKeyB64, record.IV);
      let jsonResult = JSON.parse(cleartext);
      if (!jsonResult || typeof jsonResult !== "object") {
        throw new Error("Decryption failed: result is <" + jsonResult + ">, not an object.");
      }

      // Verify that the encrypted id matches the requested record's id.
      // This should always be true, because we compute the HMAC over
      // the original record's ID, and that was verified already (above).
      if (jsonResult.id != record.id) {
        throw new Error("Record id mismatch: " + jsonResult.id + " != " + record.id);
      }

      if (record.hasOwnProperty("last_modified")) {
        jsonResult.last_modified = record.last_modified;
      }

      return jsonResult;
    });
  }

  /**
   * Retrieve keys to use during encryption.
   *
   * Returns a Promise<KeyBundle>.
   */
  getKeys() {
    throw new Error("override getKeys in a subclass");
  }
}
// You can inject this
EncryptionRemoteTransformer.prototype._fxaService = fxAccounts;

/**
 * An EncryptionRemoteTransformer that provides a keybundle derived
 * from the user's kB, suitable for encrypting a keyring.
 */
class KeyRingEncryptionRemoteTransformer extends EncryptionRemoteTransformer {
  getKeys() {
    const self = this;
    return Task.spawn(function* () {
      const user = yield self._fxaService.getSignedInUser();
      // FIXME: we should permit this if the user is self-hosting
      // their storage
      if (!user) {
        throw new Error("user isn't signed in to FxA; can't sync");
      }

      if (!user.kB) {
        throw new Error("user doesn't have kB");
      }

      let kB = Utils.hexToBytes(user.kB);

      let keyMaterial = CryptoUtils.hkdf(kB, undefined,
                                       "identity.mozilla.com/picl/v1/chrome.storage.sync", 2*32);
      let bundle = new BulkKeyBundle();
      // [encryptionKey, hmacKey]
      bundle.keyPair = [keyMaterial.slice(0, 32), keyMaterial.slice(32, 64)];
      return bundle;
    });
  }
  // Pass through the kbHash field from the unencrypted record. If
  // encryption fails, we can use this to try to detect whether we are
  // being compromised or if the record here was encoded with a
  // different kB.
  encode(record) {
    const encodePromise = super.encode(record);
    return Task.spawn(function* () {
      const encoded = yield encodePromise;
      encoded.kbHash = record.kbHash;
      return encoded;
    });
  }

  decode(record) {
    const decodePromise = super.decode(record);
    return Task.spawn(function* () {
      try {
        return yield decodePromise;
      } catch (e) {
        if (Utils.isHMACMismatch(e)) {
          const currentKBHash = yield ExtensionStorageSync.getKBHash();
          if (record.kbHash != currentKBHash) {
            // Some other client encoded this with a kB that we don't
            // have access to.
            KeyRingEncryptionRemoteTransformer.throwOutdatedKB(currentKBHash, record.kbHash);
          }
        }
        throw e;
      }
    });
  }

  // Generator and discriminator for KB-is-outdated exceptions.
  static throwOutdatedKB(shouldBe, is) {
    throw new Error(`kB hash on record is outdated: should be ${shouldBe}, is ${is}`);
  }

  static isOutdatedKB(exc) {
    const kbMessage = "kB hash on record is outdated: ";
    return exc && exc.message && exc.message.indexOf &&
      (exc.message.indexOf(kbMessage) == 0);
  }
}
