storage/index.js

// vim: ts=4:sw=4:expandtab

/**
 * @module storage
 */

const util = require('../util');
const libsignal = require('libsignal');
const process = require('process');
exports.backing = require('./backing');

const defaultBacking = process.env.RELAY_STORAGE_BACKING || 'fs';
const defaultLabel = process.env.RELAY_STORAGE_LABEL || 'default';

const stateNS = 'state';
const sessionNS = 'session';
const preKeyNS = 'prekey';
const signedPreKeyNS = 'signedprekey';
const identityKeyNS = 'identitykey';
const blockedNS = 'blocked';


let _backing;
let _Backing;
let _label = defaultLabel;


function encode(data) {
    const o = {};
    if (data instanceof Buffer) {
        o.type = 'buffer';
        o.data = data.toString('base64');
    } else if (data instanceof ArrayBuffer) {
        throw TypeError("ArrayBuffer not supported");
    } else if (data instanceof Uint8Array) {
        o.type = 'uint8array';
        o.data = Buffer.from(data).toString('base64');
    } else {
        o.data = data;
    }
    return JSON.stringify(o);
}

function decode(obj) {
    const o = JSON.parse(obj);
    if (o.type) {
        if (o.type === 'buffer') {
            return Buffer.from(o.data, 'base64');
        } else if (o.type === 'uint8array') {
            return Uint8Array.from(Buffer.from(o.data, 'base64'));
        } else {
            throw TypeError("Unsupported type: " + o.type);
        }
    } else {
        return o.data;
    }
}


/**
 * Initialize the current {@link module:storage/backing~StorageInterface}
 */
exports.initialize = () => _backing.initialize();


/**
 * Get a value from the current {@link module:storage/backing~StorageInterface}
 *
 * @param {string} ns - Namespace for the store.
 * @param {string} key
 * @param {*} [defaultValue] - Value to return if key is not present.
 * @returns {*} Decoded value from the current
 *              {@link module:storage/backing~StorageInterface}
 */
exports.get = async (ns, key, defaultValue) => {
    let data;
    try {
        data = await _backing.get(ns, key);
    } catch(e) {
        if (e instanceof ReferenceError) {
            return defaultValue;
        } else {
            throw e;
        }
    }
    return data && decode(data);
};


/**
 * Set a value in the current {@link module:storage/backing~StorageInterface}.
 *
 * @param {string} ns - Namespace for the store.
 * @param {string} key
 * @param {*} value
 */
exports.set = (ns, key, value) => _backing.set(ns, key, encode(value));


/**
 * Test if a key is present in the current {@link module:storage/backing~StorageInterface}.
 *
 * @param {string} ns - Namespace for the store.
 * @param {string} key
 * @returns {boolean} - True if the key is present.
 */
exports.has = (ns, key, value) => _backing.has(ns, key);


/**
 * Remove an entry from the current {@link module:storage/backing~StorageInterface}.
 *
 * @param {string} ns - Namespace for the store.
 * @param {string} key
 */
exports.remove = (ns, key) => _backing.remove(ns, key);


/**
 * Scan the {@link module:storage/backing~StorageInterface} for keys.
 *
 * @param {string} ns - Namespace for the store.
 * @param {RegExp} [re] - Regular expression filter.
 * @returns {string[]} - Array of matching keys.
 */
exports.keys = (ns, re) => _backing.keys(ns, re);


/**
 * Shutdown the current {@link module:storage/backing~StorageInterface}.
 */
exports.shutdown = () => _backing.shutdown();


/**
 * Get a global state value from the {@link module:storage/backing~StorageInterface}.
 *
 * @param {string} key
 * @param {*} [defaultValue] - Value to return if key is not present.
 * @returns {*}
 */
exports.getState = async function(key, defaultValue) {
    return await exports.get(stateNS, key, defaultValue);
};


/**
 * Set a global state value in the {@link module:storage/backing~StorageInterface}.
 *
 * @param {string} key
 * @param {*} value
 */
exports.putState = async function(key, value) {
    return await exports.set(stateNS, key, value);
};


/**
 * Remove a value from the state store.
 * @param {string} key
 */
exports.removeState = async function(key) {
    return await _backing.remove(stateNS, key);
};


/**
 * @returns {KeyPair} The current user's identity key pair.
 */
exports.getOurIdentity = async function() {
    return {
        pubKey: await exports.getState('ourIdentityKey.pub'),
        privKey: await exports.getState('ourIdentityKey.priv')
    };
};


/**
 * @param {KeyPair} keyPair - New identity key pair for current user.
 */
exports.saveOurIdentity = async function(keyPair) {
    await exports.putState('ourIdentityKey.pub', keyPair.pubKey);
    await exports.putState('ourIdentityKey.priv', keyPair.privKey);
};


/**
 * Remove the current user's identity key pair.
 */
exports.removeOurIdentity = async function() {
    await exports.removeState('ourIdentityKey.pub');
    await exports.removeState('ourIdentityKey.priv');
};


/**
 * @returns {?number} The current user's registration identifier.
 */
exports.getOurRegistrationId = async function() {
    return await exports.getState('registrationId');
};


/**
 * Get a prekey pair for the current user.
 *
 * @param {number} keyId
 * @returns {?KeyPair}
 */
exports.loadPreKey = async function(keyId) {
    if (!await _backing.has(preKeyNS, keyId + '.pub')) {
        return;
    }
    return {
        pubKey: await exports.get(preKeyNS, keyId + '.pub'),
        privKey: await exports.get(preKeyNS, keyId + '.priv')
    };
};


/**
 * Store a new prekey pair for the current user.
 *
 * @param {number} keyId
 * @param {KeyPair} keyPair
 */
exports.storePreKey = async function(keyId, keyPair) {
    await exports.set(preKeyNS, keyId + '.priv', keyPair.privKey);
    await exports.set(preKeyNS, keyId + '.pub', keyPair.pubKey);
};


/**
 * Remove a prekey pair for the current user.
 *
 * @param {number} keyId
 */
exports.removePreKey = async function(keyId) {
    try {
        await _backing.remove(preKeyNS, keyId + '.pub');
        await _backing.remove(preKeyNS, keyId + '.priv');
    } finally {
        // Avoid circular require..
        const hub = require('../hub');
        const signal = await hub.SignalClient.factory();
        await signal.refreshPreKeys();
    }
};


/**
 * Get a signed prekey pair for the current user.
 *
 * @param {number} keyId
 * @returns {?KeyPair}
 */
exports.loadSignedPreKey = async function(keyId) {
    if (!await _backing.has(signedPreKeyNS, keyId + '.pub')) {
        return;
    }
    return {
        pubKey: await exports.get(signedPreKeyNS, keyId + '.pub'),
        privKey: await exports.get(signedPreKeyNS, keyId + '.priv')
    };
};


/**
 * Store a new signed prekey pair for the current user.
 *
 * @param {number} keyId
 * @param {KeyPair} keyPair
 */
exports.storeSignedPreKey = async function(keyId, keyPair) {
    await exports.set(signedPreKeyNS, keyId + '.priv', keyPair.privKey);
    await exports.set(signedPreKeyNS, keyId + '.pub', keyPair.pubKey);
};


/**
 * Remove a signed prekey pair for the current user.
 *
 * @param {number} keyId
 */
exports.removeSignedPreKey = async function(keyId) {
    await _backing.remove(signedPreKeyNS, keyId + '.pub');
    await _backing.remove(signedPreKeyNS, keyId + '.priv');
};


/**
 * Load a signal cipher session for a peer.
 *
 * @param {EncodedUserAddress} encodedAddr
 * @returns {?libsignal.SessionRecord}
 */
exports.loadSession = async function(encodedAddr) {
    if (encodedAddr === null || encodedAddr === undefined) {
        throw new Error("Tried to get session for undefined/null addr");
    }
    const data = await exports.get(sessionNS, encodedAddr);
    if (data !== undefined) {
        return libsignal.SessionRecord.deserialize(data);
    }
};


/**
 * Store a signal cipher session for a peer.
 *
 * @param {EncodedUserAddress} encodedAddr
 * @returns {libsignal.SessionRecord} record
 */
exports.storeSession = async function(encodedAddr, record) {
    if (encodedAddr === null || encodedAddr === undefined) {
        throw new Error("Tried to set session for undefined/null addr");
    }
    await exports.set(sessionNS, encodedAddr, record.serialize());
};


/**
 * Remove a signal session cipher record for a peer.
 *
 * @param {EncodedUserAddress} encodedAddr
 */
exports.removeSession = async function(encodedAddr) {
    await _backing.remove(sessionNS, encodedAddr);
};


/**
 * Remove all signal session cipher records for a peer.
 *
 * @param {string} addr - UUID of peer.
 */
exports.removeAllSessions = async function _removeAllSessions(addr) {
    if (addr === null || addr === undefined) {
        throw new Error("Tried to remove sessions for undefined/null addr");
    }
    for (const x of await _backing.keys(sessionNS, new RegExp(addr + '\\..*'))) {
        await _backing.remove(sessionNS, x);
    }
};


/**
 * Clear all signal session cipher records.
 */
exports.clearSessionStore = async function() {
    for (const x of await _backing.keys(sessionNS)) {
        await _backing.remove(sessionNS, x);
    }
};


/**
 * Determine if a peer's public identity key matches our records.
 *
 * @param {string} identifier - Address of peer
 * @param {Buffer} publicKey - Public key to test.
 * @returns {boolean}
 */
exports.isTrustedIdentity = async function(identifier, publicKey) {
    if (!identifier) {
        throw new TypeError("`identifier` required");
    }
    if (!(publicKey instanceof Buffer)) {
        throw new TypeError("publicKey must be Buffer");
    }
    const trustedIdentityKey = await exports.loadIdentity(identifier);
    if (!trustedIdentityKey) {
        console.warn("WARNING: Implicit trust of peer:", identifier);
        await exports.saveIdentity(identifier, publicKey);
    }
    return !trustedIdentityKey || trustedIdentityKey.equals(publicKey);
};


/**
 * Load our last known identity key for a peer.
 *
 * @param {string} identifier - Address of peer
 * @returns {?Buffer} Public identity key for peer
 */
exports.loadIdentity = async function(identifier) {
    if (!identifier) {
        throw new Error("Tried to get identity key for undefined/null key");
    }
    const addr = util.unencodeAddr(identifier)[0];
    return await exports.get(identityKeyNS, addr);
};


/**
 * Store a new trusted public identity key for a peer.
 *
 * @param {string} identifier - Address of peer
 * @param {Buffer} publicKey - Public identity key for peer
 */
exports.saveIdentity = async function(identifier, publicKey) {
    if (!identifier) {
        throw new TypeError("`identifier` required");
    }
    if (!(publicKey instanceof Buffer)) {
        throw new TypeError("publicKey must be Buffer");
    }
    const addr = util.unencodeAddr(identifier)[0];
    const oldPublicKey = await this.loadIdentity(addr);
    if (oldPublicKey && !oldPublicKey.equals(publicKey)) {
        console.warn("Changing trusted identity key for:", addr);
        await exports.removeAllSessions(addr);
    }
    await exports.set(identityKeyNS, addr, publicKey);
};


/**
 * Remove the current trusted public identity key for a peer.
 *
 * @params {string} identifier - Address of peer
 */
exports.removeIdentity = async function(identifier) {
    const addr = util.unencodeAddr(identifier)[0];
    await _backing.remove(identityKeyNS, addr);
    await exports.removeAllSessions(addr);
};


/**
 * Get the current known list of device IDs for a peer.
 *
 * @params {string} addr - Address of peer
 * @returns {number[]}
 */
exports.getDeviceIds = async function(addr) {
    if (addr === null || addr === undefined) {
        throw new Error("Tried to get device ids for undefined/null addr");
    }
    const idents = await _backing.keys(sessionNS, new RegExp(addr + '\\..*'));
    return Array.from(idents).map(x => Number(x.split('.')[1]));
};


/**
 * Indicates if an address is considered to be "blocked".  Generally this means
 * message handling will be aborted for this address.
 *
 * @param {string} addr - Address of peer.
 * @returns {boolean}
 */
exports.isBlocked = async function(addr) {
    return await _backing.has(blockedNS, addr);
};


function getBackingClass(name) {
    return {
        redis: exports.backing.RedisBacking,
        postgres: exports.backing.PostgresBacking,
        fs: exports.backing.FSBacking
    }[name];
}

/**
 * Set the default {@link module:storage/backing~StorageInterface} to use for
 * further storage operations.
 *
 * @param {(module:storage/backing~StorageInterface|string)} Backing - Class or string label.
 */
exports.setBacking = function(Backing) {
    if (typeof Backing === 'string') {
        Backing = getBackingClass(Backing);
    }
    if (!Backing) {
        throw new TypeError("Invalid storage backing: " + Backing);
    }
    _Backing = Backing;
    _backing = new Backing(_label);
};


/**
 * Set the label to use within the current
 * {@link module:storage/backing~StorageInterface}.  This is an ideal way of
 * partitioning a single store for multiple users.  E.g sharing the
 * same database instance for more than one librelay based application.
 *
 * @param {string} label
 */
exports.setLabel = function(label) {
    _label = label;
    _backing = new _Backing(label);
};


exports.setBacking(defaultBacking);