Source: encrypt/indexeddb-group-manager-db.js

/**
 * Copyright (C) 2015-2016 Regents of the University of California.
 * @author: Jeff Thompson <jefft0@remap.ucla.edu>
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 * A copy of the GNU Lesser General Public License is in the file COPYING.
 */

// Don't require modules since this is meant for the browser, not Node.js.

/**
 * IndexedDbGroupManagerDb extends GroupManagerDb to implement the storage of
 * data used by the GroupManager using the browser's IndexedDB service.
 * Create an IndexedDbGroupManagerDb to use the given IndexedDB database name.
 * @param {string} databaseName IndexedDB database name.
 * @note This class is an experimental feature. The API may change.
 * @constructor
 */
var IndexedDbGroupManagerDb = function IndexedDbGroupManagerDb(databaseName)
{
  GroupManagerDb.call(this);

  this.database = new Dexie(databaseName);
  this.database.version(1).stores({
    // "scheduleId" is the schedule ID, auto incremented // number
    // "scheduleName" is the schedule name, unique // string
    // "schedule" is the TLV-encoded schedule // Uint8Array
    schedules: "++scheduleId, &scheduleName",

    // "memberNameUri" is the member name URI // string
    //   (Note: In SQLite3, the member name index is the TLV encoded bytes, but
    //   we can't index on a byte array in IndexedDb.)
    //   (Note: The SQLite3 table also has an auto-incremented member ID primary
    //   key, but is not used so we omit it to simplify.)
    // "memberName" is the TLV-encoded member name (same as memberNameUri // Uint8Array
    // "scheduleId" is the schedule ID, linked to the schedules table // number
    //   (Note: The SQLite3 table has a foreign key to the schedules table with
    //   cascade update and delete, but we have to handle it manually.)
    // "keyName" is the TLV-encoded key name // Uint8Array
    // "publicKey" is the encoded key bytes // Uint8Array
    members: "memberNameUri, scheduleId"
  });
  this.database.open();
};

IndexedDbGroupManagerDb.prototype = new GroupManagerDb();
IndexedDbGroupManagerDb.prototype.name = "IndexedDbGroupManagerDb";

////////////////////////////////////////////////////// Schedule management.

/**
 * Check if there is a schedule with the given name.
 * @param {string} name The name of the schedule.
 * @param {boolean} useSync (optional) If true then return a rejected promise
 * since this only supports async code.
 * @return {Promise} A promise that returns true if there is a schedule (else
 * false), or that is rejected with GroupManagerDb.Error for a database error.
 */
IndexedDbGroupManagerDb.prototype.hasSchedulePromise = function(name, useSync)
{
  if (useSync)
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.hasSchedulePromise is only supported for async")));

  return this.getScheduleIdPromise_(name)
  .then(function(scheduleId) {
    return Promise.resolve(scheduleId != -1);
  });
};

/**
 * List all the names of the schedules.
 * @param {boolean} useSync (optional) If true then return a rejected promise
 * since this only supports async code.
 * @return {Promise} A promise that returns a new array of string with the names
 * of all schedules, or that is rejected with GroupManagerDb.Error for a
 * database error.
 */
IndexedDbGroupManagerDb.prototype.listAllScheduleNamesPromise = function(useSync)
{
  if (useSync)
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.listAllScheduleNamesPromise is only supported for async")));

  var list = [];
  return this.database.schedules.each(function(entry) {
    list.push(entry.scheduleName);
  })
  .then(function() {
    return Promise.resolve(list);
  })
  .catch(function(ex) {
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.listAllScheduleNamesPromise: Error: " + ex)));
  });
};

/**
 * Get a schedule with the given name.
 * @param {string} name The name of the schedule.
 * @param {boolean} useSync (optional) If true then return a rejected promise
 * since this only supports async code.
 * @return {Promise} A promise that returns a new Schedule object, or that is
 * rejected with GroupManagerDb.Error if the schedule does not exist or other
 * database error.
 */
IndexedDbGroupManagerDb.prototype.getSchedulePromise = function(name, useSync)
{
  if (useSync)
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.getSchedulePromise is only supported for async")));

  var thisManager = this;
  // Use getScheduleIdPromise_ to handle the search on the non-primary key.
  return this.getScheduleIdPromise_(name)
  .then(function(scheduleId) {
    if (scheduleId != -1) {
      return thisManager.database.schedules.get(scheduleId)
      .then(function(entry) {
        // We expect entry to be found, and don't expect an error decoding.
        var schedule = new Schedule();
        schedule.wireDecode(new Blob(entry.schedule, false));
        return Promise.resolve(schedule);
      })
      .catch(function(ex) {
        return Promise.reject(new GroupManagerDb.Error(new Error
          ("IndexedDbGroupManagerDb.getSchedulePromise: Error: " + ex)));
      });
    }
    else
      return Promise.reject(new GroupManagerDb.Error(new Error
        ("IndexedDbGroupManagerDb.getSchedulePromise: Cannot get the result from the database")));
  });
};

/**
 * For each member using the given schedule, get the name and public key DER
 * of the member's key.
 * @param {string} name The name of the schedule.
 * @param {boolean} useSync (optional) If true then return a rejected promise
 * since this only supports async code.
 * @return {Promise} A promise that returns a new array of object (where
 * "keyName" is the Name of the public key and "publicKey" is the Blob of the
 * public key DER), or that is rejected with GroupManagerDb.Error for a database
 * error. Note that the member's identity name is keyName.getPrefix(-1). If the
 * schedule name is not found, the list is empty.
 */
IndexedDbGroupManagerDb.prototype.getScheduleMembersPromise = function
  (name, useSync)
{
  if (useSync)
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.getScheduleMembersPromise is only supported for async")));

  var list = [];
  var thisManager = this;
  // There is only one matching schedule ID, so we can just look it up instead
  // of doing a more complicated join.
  return this.getScheduleIdPromise_(name)
  .then(function(scheduleId) {
    if (scheduleId == -1)
      // Return the empty list.
      return Promise.resolve(list);

    var onEntryError = null;
    return thisManager.database.members.where("scheduleId").equals(scheduleId)
    .each(function(entry) {
      try {
        var keyName = new Name();
        keyName.wireDecode(new Blob(entry.keyName, false), TlvWireFormat.get());

        list.push({ keyName: keyName, publicKey: new Blob(entry.publicKey, false) });
      } catch (ex) {
        // We don't expect this to happen.
        onEntryError = new GroupManagerDb.Error(new Error
          ("IndexedDbGroupManagerDb.getScheduleMembersPromise: Error decoding name: " + ex));
      }
    })
    .then(function() {
      if (onEntryError)
        // We got an error decoding.
        return Promise.reject(onEntryError);
      else
        return Promise.resolve(list);
    }, function(ex) {
      return Promise.reject(new GroupManagerDb.Error(new Error
        ("IndexedDbGroupManagerDb.getScheduleMembersPromise: Error: " + ex)));
    });
  });
};

/**
 * Add a schedule with the given name.
 * @param {string} name The name of the schedule. The name cannot be empty.
 * @param {Schedule} schedule The Schedule to add.
 * @param {boolean} useSync (optional) If true then return a rejected promise
 * since this only supports async code.
 * @return {Promise} A promise that fulfills when the schedule is added, or that
 * is rejected with GroupManagerDb.Error if a schedule with the same name
 * already exists, if the name is empty, or other database error.
 */
IndexedDbGroupManagerDb.prototype.addSchedulePromise = function
  (name, schedule, useSync)
{
  if (useSync)
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.addSchedulePromise is only supported for async")));

  if (name.length == 0)
    return Promise.reject(new GroupManagerDb.Error
      ("IndexedDbGroupManagerDb.addSchedulePromise: The schedule name cannot be empty"));

  // Add rejects if the primary key already exists.
  return this.database.schedules.add
    ({ scheduleName: name, schedule: schedule.wireEncode().buf() })
  .catch(function(ex) {
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.addContentKeyPromise: Error: " + ex)));
  });
};

/**
 * Delete the schedule with the given name. Also delete members which use this
 * schedule. If there is no schedule with the name, then do nothing.
 * @param {string} name The name of the schedule.
 * @param {boolean} useSync (optional) If true then return a rejected promise
 * since this only supports async code.
 * @return {Promise} A promise that fulfills when the schedule is deleted (or
 * there is no such schedule), or that is rejected with GroupManagerDb.Error for
 * a database error.
 */
IndexedDbGroupManagerDb.prototype.deleteSchedulePromise = function
  (name, useSync)
{
  if (useSync)
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.deleteSchedulePromise is only supported for async")));

  var scheduleId;
  var thisManager = this;
  return this.getScheduleIdPromise_(name)
  .then(function(localScheduleId) {
    scheduleId = localScheduleId;

    // Get the members which use this schedule.
    return thisManager.database.members.where("scheduleId").equals(scheduleId).toArray();
  })
  .then(function(membersEntries) {
    // Delete the members.
    var promises = membersEntries.map(function(entry) {
      return thisManager.database.members.delete(entry.memberNameUri);
    });
    return Promise.all(promises);
  })
  .then(function() {
    // Now delete the schedule.
    return thisManager.database.schedules.delete(scheduleId);
  })
  .catch(function(ex) {
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.deleteSchedulePromise: Error: " + ex)));
  });
};

/**
 * Rename a schedule with oldName to newName.
 * @param {string} oldName The name of the schedule to be renamed.
 * @param {string} newName The new name of the schedule. The name cannot be empty.
 * @param {boolean} useSync (optional) If true then return a rejected promise
 * since this only supports async code.
 * @return {Promise} A promise that fulfills when the schedule is renamed, or
 * that is rejected with GroupManagerDb.Error if a schedule with newName already
 * exists, if the schedule with oldName does not exist, if newName is empty, or
 * other database error.
 */
IndexedDbGroupManagerDb.prototype.renameSchedulePromise = function
  (oldName, newName, useSync)
{
  if (useSync)
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.renameSchedulePromise is only supported for async")));

  if (newName.length == 0)
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.renameSchedule: The schedule newName cannot be empty")));

  var thisManager = this;
  return this.getScheduleIdPromise_(oldName)
  .then(function(scheduleId) {
    if (scheduleId == -1)
      return Promise.reject(new GroupManagerDb.Error(new Error
        ("IndexedDbGroupManagerDb.renameSchedule: The schedule oldName does not exist")));

    return thisManager.database.schedules.update
      (scheduleId, { scheduleName: newName })
    .catch(function(ex) {
      return Promise.reject(new GroupManagerDb.Error(new Error
        ("IndexedDbGroupManagerDb.renameSchedulePromise: Error: " + ex)));
    });
  });
};

/**
 * Update the schedule with name and replace the old object with the given
 * schedule. Otherwise, if no schedule with name exists, a new schedule
 * with name and the given schedule will be added to database.
 * @param {string} name The name of the schedule. The name cannot be empty.
 * @param {Schedule} schedule The Schedule to update or add.
 * @param {boolean} useSync (optional) If true then return a rejected promise
 * since this only supports async code.
 * @return {Promise} A promise that fulfills when the schedule is updated, or
 * that is rejected with GroupManagerDb.Error if the name is empty, or other
 * database error.
 */
IndexedDbGroupManagerDb.prototype.updateSchedulePromise = function
  (name, schedule, useSync)
{
  if (useSync)
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.updateSchedulePromise is only supported for async")));

  var thisManager = this;
  return this.getScheduleIdPromise_(name)
  .then(function(scheduleId) {
    if (scheduleId == -1)
      return thisManager.addSchedulePromise(name, schedule);

    return thisManager.database.schedules.update
      (scheduleId, { schedule: schedule.wireEncode().buf() })
    .catch(function(ex) {
      return Promise.reject(new GroupManagerDb.Error(new Error
        ("IndexedDbGroupManagerDb.updateSchedulePromise: Error: " + ex)));
    });
  });
};

////////////////////////////////////////////////////// Member management.

/**
 * Check if there is a member with the given identity name.
 * @param {Name} identity The member's identity name.
 * @param {boolean} useSync (optional) If true then return a rejected promise
 * since this only supports async code.
 * @return {Promise} A promise that returns true if there is a member (else
 * false), or that is rejected with GroupManagerDb.Error for a database error.
 */
IndexedDbGroupManagerDb.prototype.hasMemberPromise = function(identity, useSync)
{
  if (useSync)
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.hasMemberPromise is only supported for async")));

  return this.database.members.get(identity.toUri())
  .then(function(entry) {
    return Promise.resolve(entry != undefined);
  })
  .catch(function(ex) {
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.hasMemberPromise: Error: " + ex)));
  });
};

/**
 * List all the members.
 * @param {boolean} useSync (optional) If true then return a rejected promise
 * since this only supports async code.
 * @return {Promise} A promise that returns a new array of Name with the names
 * of all members, or that is rejected with GroupManagerDb.Error for a
 * database error.
 */
IndexedDbGroupManagerDb.prototype.listAllMembersPromise = function(useSync)
{
  if (useSync)
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.listAllMembersPromise is only supported for async")));

  var list = [];
  var onEntryError = null;
  return this.database.members.each(function(entry) {
    try {
      var identity = new Name();
      identity.wireDecode(new Blob(entry.memberName, false), TlvWireFormat.get());
      list.push(identity);
    } catch (ex) {
      // We don't expect this to happen.
      onEntryError = new GroupManagerDb.Error(new Error
        ("IndexedDbGroupManagerDb.listAllMembersPromise: Error decoding name: " + ex));
    }
  })
  .then(function() {
    if (onEntryError)
      // We got an error decoding.
      return Promise.reject(onEntryError);
    else
      return Promise.resolve(list);
  }, function(ex) {
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.listAllMembersPromise: Error: " + ex)));
  });
};

/**
 * Get the name of the schedule for the given member's identity name.
 * @param {Name} identity The member's identity name.
 * @param {boolean} useSync (optional) If true then return a rejected promise
 * since this only supports async code.
 * @return {Promise} A promise that returns the string schedule name, or that is
 * rejected with GroupManagerDb.Error if there's no member with the given
 * identity name in the database, or other database error.
 */
IndexedDbGroupManagerDb.prototype.getMemberSchedulePromise = function
  (identity, useSync)
{
  if (useSync)
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.getMemberSchedulePromise is only supported for async")));

  var thisManager = this;
  return this.database.members.get(identity.toUri())
  .then(function(membersEntry) {
    if (!membersEntry)
      throw new Error("The member identity name does not exist in the database");

    return thisManager.database.schedules.get(membersEntry.scheduleId);
  })
  .then(function(schedulesEntry) {
    if (!schedulesEntry)
      throw new Error
        ("The schedule ID for the member identity name does not exist in the database");

    return Promise.resolve(schedulesEntry.scheduleName);
  })
  .catch(function(ex) {
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.getScheduleIdPromise_: Error: " + ex)));
  });
};

/**
 * Add a new member with the given key named keyName into a schedule named
 * scheduleName. The member's identity name is keyName.getPrefix(-1).
 * @param {string} scheduleName The schedule name.
 * @param {Name} keyName The name of the key.
 * @param {Blob} key A Blob of the public key DER.
 * @param {boolean} useSync (optional) If true then return a rejected promise
 * since this only supports async code.
 * @return {Promise} A promise that fulfills when the member is added, or that
 * is rejected with GroupManagerDb.Error if there's no schedule named
 * scheduleName, if the member's identity name already exists, or other database
 * error.
 */
IndexedDbGroupManagerDb.prototype.addMemberPromise = function
  (scheduleName, keyName, key, useSync)
{
  if (useSync)
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.addMemberPromise is only supported for async")));

  var thisManager = this;
  return this.getScheduleIdPromise_(scheduleName)
  .then(function(scheduleId) {
    if (scheduleId == -1)
      return Promise.reject(new GroupManagerDb.Error(new Error
        ("IndexedDbGroupManagerDb.addMemberPromise: The schedule does not exist")));

    // Needs to be changed in the future.
    var memberName = keyName.getPrefix(-1);

    // Add rejects if the primary key already exists.
    return thisManager.database.members.add
      ({ memberNameUri: memberName.toUri(),
         memberName: memberName.wireEncode(TlvWireFormat.get()).buf(),
         scheduleId: scheduleId,
         keyName: keyName.wireEncode(TlvWireFormat.get()).buf(),
         publicKey: key.buf() })
    .catch(function(ex) {
      return Promise.reject(new GroupManagerDb.Error(new Error
        ("IndexedDbGroupManagerDb.addMemberPromise: Error: " + ex)));
    });
  });
};

/**
 * Change the name of the schedule for the given member's identity name.
 * @param {Name} identity The member's identity name.
 * @param {string} scheduleName The new schedule name.
 * @param {boolean} useSync (optional) If true then return a rejected promise
 * since this only supports async code.
 * @return {Promise} A promise that fulfills when the member is updated, or that
 * is rejected with GroupManagerDb.Error if there's no member with the given
 * identity name in the database, or there's no schedule named scheduleName, or
 * other database error.
 */
IndexedDbGroupManagerDb.prototype.updateMemberSchedulePromise = function
  (identity, scheduleName, useSync)
{
  if (useSync)
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.updateMemberSchedulePromise is only supported for async")));

  var thisManager = this;
  return this.getScheduleIdPromise_(scheduleName)
  .then(function(scheduleId) {
    if (scheduleId == -1)
      return Promise.reject(new GroupManagerDb.Error(new Error
        ("IndexedDbGroupManagerDb.updateMemberSchedulePromise: The schedule does not exist")));

    return thisManager.database.members.update
      (identity.toUri(), { scheduleId: scheduleId })
    .catch(function(ex) {
      return Promise.reject(new GroupManagerDb.Error(new Error
        ("IndexedDbGroupManagerDb.updateMemberSchedulePromise: Error: " + ex)));
    });
  });
};

/**
 * Delete a member with the given identity name. If there is no member with
 * the identity name, then do nothing.
 * @param {Name} identity The member's identity name.
 * @param {boolean} useSync (optional) If true then return a rejected promise
 * since this only supports async code.
 * @return {Promise} A promise that fulfills when the member is deleted (or
 * there is no such member), or that is rejected with GroupManagerDb.Error for a
 * database error.
 */
IndexedDbGroupManagerDb.prototype.deleteMemberPromise = function
  (identity, useSync)
{
  if (useSync)
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.deleteMemberPromise is only supported for async")));

  return this.database.members.delete(identity.toUri())
  .catch(function(ex) {
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.deleteMemberPromise: Error: " + ex)));
  });
};

/**
 * Get the ID for the schedule.
 * @param {string} name The schedule name.
 * @return {Promise} A promise that returns the ID (or -1 if not found), or that
 * is rejected with GroupManagerDb.Error for a database error.
 */
IndexedDbGroupManagerDb.prototype.getScheduleIdPromise_ = function(name)
{
  // The scheduleName is not the primary key, so use 'where' instead of 'get'.
  var id = -1;
  return this.database.schedules.where("scheduleName").equals(name)
  .each(function(entry) {
    id = entry.scheduleId;
  })
  .then(function() {
    return Promise.resolve(id);
  })
  .catch(function(ex) {
    return Promise.reject(new GroupManagerDb.Error(new Error
      ("IndexedDbGroupManagerDb.getScheduleIdPromise_: Error: " + ex)));
  });
};