Source: keychain/master-key-manager.ts

/**
 * @author Erik Haßlmeyer <erik.hasslmeyer@refinio.net>
 * @copyright REFINIO GmbH 2022
 * @license CC-BY-NC-SA-2.5; portions MIT License
 * @version 0.0.1
 */

import type {Salt, SymmetricKey} from '../crypto/encryption';
import {
    createRandomSalt,
    createSymmetricKey,
    deriveSymmetricKeyFromSecret,
    ensureSalt,
    ensureSymmetricKey,
    symmetricDecryptWithEmbeddedNonce,
    symmetricEncryptAndEmbedNonce
} from '../crypto/encryption';
import {createError} from '../errors';
import {STORAGE} from '../storage-base-common';
import {readPrivateBinaryRaw, writePrivateBinaryRaw} from '../system/storage-base';
import {deleteFile} from '../system/storage-base-delete-file';

/**
 * This class encapsulates the master key in such a way that it is harder to be leaked.
 *
 * The reason why it is a class and not a simple module is, so that we can move it to another
 * file without exposing the functions to other modules except the keychain. Other files can use
 * it, but they won't be able to decrypt stuff because they don't have access to the object that
 * holds the real master key.
 *
 * The master key is stored in a file that is encrypted. The encryption is done by another key
 * that is derived from the secret that is supplied by the user. This derivation needs a salt,
 * that also stored in a file. So the loading process of the master key works like this:
 * 1) load the salt file
 * 2) derive a symmetric encryption key from the secret and the salt
 * 3) load the master key file
 * 4) decrypt the master key with the derived symmetric key and store it in memory until it is
 * unloaded
 */
export class MasterKeyManager {
    #masterKey: SymmetricKey | null = null;
    readonly #masterKeyFileName: string;
    readonly #saltFileName: string;

    /**
     * Constructs a new master key manager.
     *
     * @param {string} masterKeyFileName - File that stores the encrypted master key
     * @param {string} saltFileName - File that stores the salt for deriving the encryption key
     * from the secret
     */
    constructor(masterKeyFileName: string, saltFileName: string) {
        this.#masterKeyFileName = masterKeyFileName;
        this.#saltFileName = saltFileName;
    }

    // ######## loading / unloading of master key ########

    /**
     * Loads the stored master key or create a new one if none was previously created.
     *
     * This will calculate a derived key from the secret and then:
     * - master-key file missing: create a new master-key + file encrypted with this derived key
     * - master-key file exists: load the master-key from file and decrypt it with this derived key
     *
     * Function will throw if the secret does not match the already existing master-key file.
     *
     * @param {string} secret
     * @returns {Promise<void>}
     */
    public async loadOrCreateMasterKey(secret: string): Promise<void> {
        if (this.#masterKey !== null) {
            throw createError('KEYMKM-HASKEY');
        }

        try {
            this.#masterKey = await MasterKeyManager.loadAndDecodeMasterKey(
                secret,
                this.#masterKeyFileName,
                this.#saltFileName
            );
        } catch (e) {
            if (e.name !== 'FileNotFoundError') {
                throw e;
            }

            const masterKey = createSymmetricKey();
            await MasterKeyManager.writeAndEncodeMasterKey(
                secret,
                masterKey,
                this.#masterKeyFileName,
                this.#saltFileName
            );
            this.#masterKey = masterKey;
        }
    }

    /**
     * Purges the memory from memory.
     */
    public unloadMasterKey(): void {
        if (this.#masterKey === null) {
            return;
        }

        this.#masterKey.fill(0);
        this.#masterKey = null;
    }

    /**
     * Ensures, that the master is loaded, if not it throws.
     */
    public ensureMasterKeyLoaded(): void {
        if (this.#masterKey === null) {
            throw createError('KEYMKM-NOKEY');
        }
    }

    /**
     * Changes the secret needed to unlock the master-key.
     *
     * This can be done with or without a loaded master key. Throws if the oldSecret is wrong.
     *
     * @param {string} oldSecret
     * @param {string} newSecret
     * @returns {Promise<void>}
     */
    public async changeSecret(oldSecret: string, newSecret: string): Promise<void> {
        const masterKey = await MasterKeyManager.loadAndDecodeMasterKey(
            oldSecret,
            this.#masterKeyFileName,
            this.#saltFileName
        );
        await MasterKeyManager.writeAndEncodeMasterKey(
            newSecret,
            masterKey,
            this.#masterKeyFileName,
            this.#saltFileName
        );
    }

    // ######## encryption / decryption with master key ########

    /**
     * Encrypt data with the master key.
     *
     * Only works if the master key was previously set.
     *
     * @param {Uint8Array} data
     * @returns {Uint8Array}
     */
    public encryptDataWithMasterKey(data: Uint8Array): Uint8Array {
        if (this.#masterKey === null) {
            throw createError('KEYMKM-NOKEYENC');
        }

        return symmetricEncryptAndEmbedNonce(data, this.#masterKey);
    }

    /**
     * Decrypt data with the master key.
     *
     * Only works if the master key was previously set.
     *
     * @param {Uint8Array} cypherAndNonce - The data to decrypt
     * @returns {Uint8Array}
     */
    public decryptDataWithMasterKey(cypherAndNonce: Uint8Array): Uint8Array {
        if (this.#masterKey === null) {
            throw createError('KEYMKM-NOKEYDEC');
        }

        return symmetricDecryptWithEmbeddedNonce(cypherAndNonce, this.#masterKey);
    }

    // ######## private section ########

    private static async writeAndEncodeMasterKey(
        secret: string,
        masterKey: SymmetricKey,
        masterKeyFileName: string,
        saltFileName: string
    ): Promise<void> {
        const salt = await MasterKeyManager.createSaltFile(saltFileName);
        const derivedKey = await deriveSymmetricKeyFromSecret(secret, salt);
        const masterKeyEncrypted = symmetricEncryptAndEmbedNonce(masterKey, derivedKey);
        await deleteFile(masterKeyFileName, STORAGE.PRIVATE);
        await writePrivateBinaryRaw(masterKeyFileName, masterKeyEncrypted.buffer);
    }

    private static async loadAndDecodeMasterKey(
        secret: string,
        masterKeyFileName: string,
        saltFileName: string
    ): Promise<SymmetricKey> {
        const salt = await MasterKeyManager.loadSaltFile(saltFileName);
        const derivedKey = await deriveSymmetricKeyFromSecret(secret, salt);
        const masterKeyEncrypted = new Uint8Array(await readPrivateBinaryRaw(masterKeyFileName));
        return ensureSymmetricKey(
            symmetricDecryptWithEmbeddedNonce(masterKeyEncrypted, derivedKey)
        );
    }

    private static async createSaltFile(saltFileName: string): Promise<Salt> {
        const salt = createRandomSalt();
        await deleteFile(saltFileName, STORAGE.PRIVATE);
        await writePrivateBinaryRaw(saltFileName, salt.buffer);
        return salt;
    }

    private static async loadSaltFile(saltFileName: string): Promise<Salt> {
        const salt = new Uint8Array(await readPrivateBinaryRaw(saltFileName));
        return ensureSalt(salt);
    }
}