/*
Copyright 2019 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import {EventEmitter} from 'events';
import logger from '../logger';
import olmlib from './olmlib';
import { randomString } from '../randomstring';
import { keyFromPassphrase } from './key_passphrase';
import { encodeRecoveryKey } from './recoverykey';
import { pkVerify } from './olmlib';

export const SECRET_STORAGE_ALGORITHM_V1 = "m.secret_storage.v1.curve25519-aes-sha2";

/**
 * Implements Secure Secret Storage and Sharing (MSC1946)
 * @module crypto/SecretStorage
 */
export default class SecretStorage extends EventEmitter {
    constructor(baseApis, cryptoCallbacks, crossSigningInfo) {
        super();
        this._baseApis = baseApis;
        this._cryptoCallbacks = cryptoCallbacks;
        this._crossSigningInfo = crossSigningInfo;
        this._requests = {};
        this._incomingRequests = {};
    }

    getDefaultKeyId() {
        const defaultKeyEvent = this._baseApis.getAccountData(
            'm.secret_storage.default_key',
        );
        if (!defaultKeyEvent) return null;
        return defaultKeyEvent.getContent().key;
    }

    setDefaultKeyId(keyId) {
        return new Promise((resolve) => {
            const listener = (ev) => {
                if (
                    ev.getType() === 'm.secret_storage.default_key' &&
                    ev.getContent().key === keyId
                ) {
                    this._baseApis.removeListener('accountData', listener);
                    resolve();
                }
            };
            this._baseApis.on('accountData', listener);

            this._baseApis.setAccountData(
                'm.secret_storage.default_key',
                { key: keyId },
            );
        });
    }

    /**
     * Add a key for encrypting secrets.
     *
     * @param {string} algorithm the algorithm used by the key.
     * @param {object} opts the options for the algorithm.  The properties used
     *     depend on the algorithm given.  This object may be modified to pass
     *     information back about the key.
     * @param {string} [keyId] the ID of the key.  If not given, a random
     *     ID will be generated.
     *
     * @return {string} the ID of the key
     */
    async addKey(algorithm, opts, keyId) {
        const keyData = {algorithm};

        if (!opts) opts = {};

        if (opts.name) {
            keyData.name = opts.name;
        }

        switch (algorithm) {
        case SECRET_STORAGE_ALGORITHM_V1:
        {
            const decryption = new global.Olm.PkDecryption();
            try {
                if (opts.passphrase) {
                    const key = await keyFromPassphrase(opts.passphrase);
                    keyData.passphrase = {
                        algorithm: "m.pbkdf2",
                        iterations: key.iterations,
                        salt: key.salt,
                    };
                    opts.encodedkey = encodeRecoveryKey(key.key);
                    keyData.pubkey = decryption.init_with_private_key(key.key);
                } else if (opts.privkey) {
                    keyData.pubkey = decryption.init_with_private_key(opts.privkey);
                    opts.encodedkey = encodeRecoveryKey(opts.privkey);
                } else {
                    keyData.pubkey = decryption.generate_key();
                    opts.encodedkey = encodeRecoveryKey(decryption.get_private_key());
                }
            } finally {
                decryption.free();
            }
            break;
        }
        default:
            throw new Error(`Unknown key algorithm ${opts.algorithm}`);
        }

        if (!keyId) {
            do {
                keyId = randomString(32);
            } while (this._baseApis.getAccountData(`m.secret_storage.key.${keyId}`));
        }

        await this._crossSigningInfo.signObject(keyData, 'master');

        await this._baseApis.setAccountData(
            `m.secret_storage.key.${keyId}`, keyData,
        );

        return keyId;
    }

    // TODO: need a function to get all the secret keys

    /**
     * Store an encrypted secret on the server
     *
     * @param {string} name The name of the secret
     * @param {string} secret The secret contents.
     * @param {Array} keys The IDs of the keys to use to encrypt the secret
     *     or null/undefined to use the default key.
     */
    async store(name, secret, keys) {
        const encrypted = {};

        if (!keys) {
            const defaultKeyId = this.getDefaultKeyId();
            if (!defaultKeyId) {
                throw new Error("No keys specified and no default key present");
            }
            keys = [defaultKeyId];
        }

        if (keys.length === 0) {
            throw new Error("Zero keys given to encrypt with!");
        }

        for (const keyId of keys) {
            // get key information from key storage
            const keyInfo = this._baseApis.getAccountData(
                "m.secret_storage.key." + keyId,
            );
            if (!keyInfo) {
                throw new Error("Unknown key: " + keyId);
            }
            const keyInfoContent = keyInfo.getContent();

            // check signature of key info
            pkVerify(
                keyInfoContent,
                this._crossSigningInfo.getId('master'),
                this._crossSigningInfo.userId,
            );

            // encrypt secret, based on the algorithm
            switch (keyInfoContent.algorithm) {
            case SECRET_STORAGE_ALGORITHM_V1:
            {
                const encryption = new global.Olm.PkEncryption();
                try {
                    encryption.set_recipient_key(keyInfoContent.pubkey);
                    encrypted[keyId] = encryption.encrypt(secret);
                } finally {
                    encryption.free();
                }
                break;
            }
            default:
                logger.warn("unknown algorithm for secret storage key " + keyId
                            + ": " + keyInfoContent.algorithm);
                // do nothing if we don't understand the encryption algorithm
            }
        }

        // save encrypted secret
        await this._baseApis.setAccountData(name, {encrypted});
    }

    /**
     * Get a secret from storage.
     *
     * @param {string} name the name of the secret
     *
     * @return {string} the contents of the secret
     */
    async get(name) {
        const secretInfo = this._baseApis.getAccountData(name);
        if (!secretInfo) {
            return;
        }

        const secretContent = secretInfo.getContent();

        if (!secretContent.encrypted) {
            throw new Error("Content is not encrypted!");
        }

        // get possible keys to decrypt
        const keys = {};
        for (const keyId of Object.keys(secretContent.encrypted)) {
            // get key information from key storage
            const keyInfo = this._baseApis.getAccountData(
                "m.secret_storage.key." + keyId,
            ).getContent();
            const encInfo = secretContent.encrypted[keyId];
            switch (keyInfo.algorithm) {
            case SECRET_STORAGE_ALGORITHM_V1:
                if (keyInfo.pubkey && encInfo.ciphertext && encInfo.mac
                    && encInfo.ephemeral) {
                    keys[keyId] = keyInfo;
                }
                break;
            default:
                // do nothing if we don't understand the encryption algorithm
            }
        }

        let keyId;
        let decryption;
        try {
            // fetch private key from app
            [keyId, decryption] = await this._getSecretStorageKey(keys);

            // decrypt secret
            const encInfo = secretContent.encrypted[keyId];
            switch (keys[keyId].algorithm) {
            case SECRET_STORAGE_ALGORITHM_V1:
                return decryption.decrypt(
                    encInfo.ephemeral, encInfo.mac, encInfo.ciphertext,
                );
            }
        } finally {
            if (decryption) decryption.free();
        }
    }

    /**
     * Check if a secret is stored on the server.
     *
     * @param {string} name the name of the secret
     * @param {boolean} checkKey check if the secret is encrypted by a trusted key
     *
     * @return {boolean} whether or not the secret is stored
     */
    isStored(name, checkKey) {
        // check if secret exists
        const secretInfo = this._baseApis.getAccountData(name);
        if (!secretInfo) {
            return false;
        }

        if (checkKey === undefined) checkKey = true;

        const secretContent = secretInfo.getContent();

        if (!secretContent.encrypted) {
            return false;
        }

        // check if secret is encrypted by a known/trusted secret and
        // encryption looks sane
        for (const keyId of Object.keys(secretContent.encrypted)) {
            // get key information from key storage
            const keyInfo = this._baseApis.getAccountData(
                "m.secret_storage.key." + keyId,
            ).getContent();
            const encInfo = secretContent.encrypted[keyId];
            if (checkKey) {
                pkVerify(
                    keyInfo,
                    this._crossSigningInfo.getId('master'),
                    this._crossSigningInfo.userId,
                );
            }
            switch (keyInfo.algorithm) {
            case SECRET_STORAGE_ALGORITHM_V1:
                if (keyInfo.pubkey && encInfo.ciphertext && encInfo.mac
                    && encInfo.ephemeral) {
                    return true;
                }
                break;
            default:
                // do nothing if we don't understand the encryption algorithm
            }
        }
        return false;
    }

    /**
     * Request a secret from another device
     *
     * @param {string} name the name of the secret to request
     * @param {string[]} devices the devices to request the secret from
     *
     * @return {string} the contents of the secret
     */
    request(name, devices) {
        const requestId = this._baseApis.makeTxnId();

        const requestControl = this._requests[requestId] = {
            devices,
        };
        const promise = new Promise((resolve, reject) => {
            requestControl.resolve = resolve;
            requestControl.reject = reject;
        });
        const cancel = (reason) => {
            // send cancellation event
            const cancelData = {
                action: "request_cancellation",
                requesting_device_id: this._baseApis.deviceId,
                request_id: requestId,
            };
            const toDevice = {};
            for (const device of devices) {
                toDevice[device] = cancelData;
            }
            this._baseApis.sendToDevice("m.secret.request", {
                [this._baseApis.getUserId()]: toDevice,
            });

            // and reject the promise so that anyone waiting on it will be
            // notified
            requestControl.reject(new Error(reason || "Cancelled"));
        };

        // send request to devices
        const requestData = {
            name,
            action: "request",
            requesting_device_id: this._baseApis.deviceId,
            request_id: requestId,
        };
        const toDevice = {};
        for (const device of devices) {
            toDevice[device] = requestData;
        }
        this._baseApis.sendToDevice("m.secret.request", {
            [this._baseApis.getUserId()]: toDevice,
        });

        return {
            request_id: requestId,
            promise,
            cancel,
        };
    }

    async _onRequestReceived(event) {
        const sender = event.getSender();
        const content = event.getContent();
        if (sender !== this._baseApis.getUserId()
            || !(content.name && content.action
                 && content.requesting_device_id && content.request_id)) {
            // ignore requests from anyone else, for now
            return;
        }
        const deviceId = content.requesting_device_id;
        // check if it's a cancel
        if (content.action === "request_cancellation") {
            if (this._incomingRequests[deviceId]
                && this._incomingRequests[deviceId][content.request_id]) {
                logger.info("received request cancellation for secret (" + sender
                            + ", " + deviceId + ", " + content.request_id + ")");
                this.baseApis.emit("crypto.secrets.requestCancelled", {
                    user_id: sender,
                    device_id: deviceId,
                    request_id: content.request_id,
                });
            }
        } else if (content.action === "request") {
            if (deviceId === this._baseApis.deviceId) {
                // no point in trying to send ourself the secret
                return;
            }

            // check if we have the secret
            logger.info("received request for secret (" + sender
                        + ", " + deviceId + ", " + content.request_id + ")");
            if (!this._cryptoCallbacks.onSecretRequested) {
                return;
            }
            const secret = await this._cryptoCallbacks.onSecretRequested({
                user_id: sender,
                device_id: deviceId,
                request_id: content.request_id,
                name: content.name,
                device_trust: this._baseApis.checkDeviceTrust(sender, deviceId),
            });
            if (secret) {
                const payload = {
                    type: "m.secret.send",
                    content: {
                        request_id: content.request_id,
                        secret: secret,
                    },
                };
                const encryptedContent = {
                    algorithm: olmlib.OLM_ALGORITHM,
                    sender_key: this._baseApis._crypto._olmDevice.deviceCurve25519Key,
                    ciphertext: {},
                };
                await olmlib.ensureOlmSessionsForDevices(
                    this._baseApis._crypto._olmDevice,
                    this._baseApis,
                    {
                        [sender]: [
                            await this._baseApis.getStoredDevice(sender, deviceId),
                        ],
                    },
                );
                await olmlib.encryptMessageForDevice(
                    encryptedContent.ciphertext,
                    this._baseApis.getUserId(),
                    this._baseApis.deviceId,
                    this._baseApis._crypto._olmDevice,
                    sender,
                    this._baseApis._crypto.getStoredDevice(sender, deviceId),
                    payload,
                );
                const contentMap = {
                    [sender]: {
                        [deviceId]: encryptedContent,
                    },
                };

                this._baseApis.sendToDevice("m.room.encrypted", contentMap);
            }
        }
    }

    _onSecretReceived(event) {
        if (event.getSender() !== this._baseApis.getUserId()) {
            // we shouldn't be receiving secrets from anyone else, so ignore
            // because someone could be trying to send us bogus data
            return;
        }
        const content = event.getContent();
        logger.log("got secret share for request ", content.request_id);
        const requestControl = this._requests[content.request_id];
        if (requestControl) {
            // make sure that the device that sent it is one of the devices that
            // we requested from
            const deviceInfo = this._baseApis._crypto._deviceList.getDeviceByIdentityKey(
                olmlib.OLM_ALGORITHM,
                event.getSenderKey(),
            );
            if (!deviceInfo) {
                logger.log(
                    "secret share from unknown device with key", event.getSenderKey(),
                );
                return;
            }
            if (!requestControl.devices.includes(deviceInfo.deviceId)) {
                logger.log("unsolicited secret share from device", deviceInfo.deviceId);
                return;
            }

            requestControl.resolve(content.secret);
        }
    }

    async _getSecretStorageKey(keys) {
        if (!this._cryptoCallbacks.getSecretStorageKey) {
            throw new Error("No getSecretStorageKey callback supplied");
        }

        const returned = await Promise.resolve(
            this._cryptoCallbacks.getSecretStorageKey({keys}),
        );

        if (!returned) {
            throw new Error("getSecretStorageKey callback returned falsey");
        }
        if (returned.length < 2) {
            throw new Error("getSecretStorageKey callback returned invalid data");
        }

        const [keyId, privateKey] = returned;
        if (!keys[keyId]) {
            throw new Error("App returned unknown key from getSecretStorageKey!");
        }

        switch (keys[keyId].algorithm) {
            case SECRET_STORAGE_ALGORITHM_V1:
            {
                const decryption = new global.Olm.PkDecryption();
                let pubkey;
                try {
                    pubkey = decryption.init_with_private_key(privateKey);
                } catch (e) {
                    decryption.free();
                    throw new Error("getSecretStorageKey callback returned invalid key");
                }
                if (pubkey !== keys[keyId].pubkey) {
                    decryption.free();
                    throw new Error(
                        "getSecretStorageKey callback returned incorrect key",
                    );
                }
                return [keyId, decryption];
            }
            default:
                throw new Error("Unknown key type: " + keys[keyId].algorithm);
        }
    }
}
