Files
notesnook/apps/web/src/interfaces/storage.ts

281 lines
7.5 KiB
TypeScript

/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU 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 General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { IStorage } from "@notesnook/core";
import {
IndexedDBKVStore,
LocalStorageKVStore,
MemoryKVStore,
IKVStore
} from "./key-value";
import { NNCrypto } from "./nncrypto";
import type {
Cipher,
SerializedKey,
SerializedKeyPair
} from "@notesnook/crypto";
import { isFeatureSupported } from "../utils/feature-check";
import { IKeyStore } from "./key-store";
import { User } from "@notesnook/core";
import * as openpgp from "openpgp";
import { strings } from "@notesnook/intl";
type EncryptedKey = { iv: Uint8Array; cipher: BufferSource };
export type DatabasePersistence = "memory" | "db";
const APP_SALT = "oVzKtazBo7d8sb7TBvY9jw";
export class NNStorage implements IStorage {
database: IKVStore;
constructor(
name: string,
private readonly keyStore: () => IKeyStore | null = () => null,
persistence: DatabasePersistence = "db"
) {
this.database =
persistence === "memory"
? new MemoryKVStore()
: isFeatureSupported("indexedDB")
? new IndexedDBKVStore(name, "keyvaluepairs")
: new LocalStorageKVStore();
}
async migrate() {
if (!this.keyStore) return;
const user = await this.read<User>("user");
if (!user) return;
const key = await this._getCryptoKey(`_uk_@${user.email}`);
if (!key) return;
await this.database.deleteMany([
`_uk_@${user.email}`,
`_uk_@${user.email}@_k`
]);
await this.keyStore()?.setValue("userEncryptionKey", key);
}
read<T>(key: string): Promise<T | undefined> {
if (!key) return Promise.resolve(undefined);
return this.database.get(key);
}
readMulti<T>(keys: string[]): Promise<[string, T][]> {
if (keys.length <= 0) return Promise.resolve([]);
return this.database.getMany(keys.sort());
}
writeMulti<T>(entries: [string, T][]) {
return this.database.setMany(entries);
}
write<T>(key: string, data: T) {
return this.database.set(key, data);
}
remove(key: string) {
return this.database.delete(key);
}
removeMulti(keys: string[]) {
return this.database.deleteMany(keys);
}
clear() {
return this.database.clear();
}
getAllKeys() {
return this.database.keys();
}
async deriveCryptoKey(credentials: SerializedKey) {
if (!this.keyStore) throw new Error("No key store found!");
const { password, salt } = credentials;
if (!password) throw new Error("Invalid data provided to deriveCryptoKey.");
const keyData = await NNCrypto.exportKey(password, salt);
if (!keyData.key) throw new Error("Invalid key.");
await this.keyStore()?.setValue("userEncryptionKey", keyData.key);
}
async getCryptoKey(): Promise<string | undefined> {
if (!this.keyStore) throw new Error("No key store found!");
return this.keyStore()?.getValue("userEncryptionKey");
}
async generateCryptoKey(
password: string,
salt?: string
): Promise<SerializedKey> {
if (!password)
throw new Error("Invalid data provided to generateCryptoKey.");
return await NNCrypto.exportKey(password, salt);
}
async generatePGPKeyPair(): Promise<SerializedKeyPair> {
const keys = await openpgp.generateKey({
userIDs: [{ name: "NN", email: "NN@NN.NN" }]
});
return { publicKey: keys.publicKey, privateKey: keys.privateKey };
}
async validatePGPKeyPair(keys: SerializedKeyPair): Promise<{
isValid: boolean;
message: string;
}> {
try {
const privateKey = await openpgp.readPrivateKey({
armoredKey: keys.privateKey
});
if (!privateKey.isDecrypted()) {
return {
isValid: false,
message: strings.pgpPrivateKeyProtected()
};
}
const dummyData = JSON.stringify({
favorite: true,
title: "Hello world"
});
const publicKey = await openpgp.readKey({ armoredKey: keys.publicKey });
const encrypted = await openpgp.encrypt({
message: await openpgp.createMessage({ text: dummyData }),
encryptionKeys: publicKey
});
const message = await openpgp.readMessage({ armoredMessage: encrypted });
const decrypted = await openpgp.decrypt({
message,
decryptionKeys: privateKey
});
const isValid = decrypted.data === dummyData;
return {
isValid,
message: isValid ? "" : strings.invalidPgpKeyPair()
};
} catch (e) {
console.error("PGP key pair validation error:", e);
return {
isValid: false,
message: strings.invalidPgpKeyPair()
};
}
}
async hash(password: string, email: string): Promise<string> {
return await NNCrypto.hash(password, `${APP_SALT}${email}`);
}
encrypt(key: SerializedKey, plainText: string): Promise<Cipher<"base64">> {
return NNCrypto.encrypt(key, plainText, "text", "base64");
}
encryptMulti(
key: SerializedKey,
items: string[]
): Promise<Cipher<"base64">[]> {
return NNCrypto.encryptMulti(key, items, "text", "base64");
}
decrypt(key: SerializedKey, cipherData: Cipher<"base64">): Promise<string> {
cipherData.format = "base64";
return NNCrypto.decrypt(key, cipherData, "text");
}
decryptMulti(
key: SerializedKey,
items: Cipher<"base64">[]
): Promise<string[]> {
items.forEach((c) => (c.format = "base64"));
return NNCrypto.decryptMulti(key, items, "text");
}
async decryptPGPMessage(
privateKeyArmored: string,
encryptedMessage: string
): Promise<string> {
const message = await openpgp.readMessage({
armoredMessage: encryptedMessage
});
const privateKey = await openpgp.readPrivateKey({
armoredKey: privateKeyArmored
});
const decrypted = await openpgp.decrypt({
message,
decryptionKeys: privateKey
});
return decrypted.data;
}
/**
* @deprecated
*/
private async _getCryptoKey(name: string) {
if (isFeatureSupported("indexedDB") && window?.crypto?.subtle) {
const pbkdfKey = await this.read<CryptoKey>(name);
const cipheredKey = await this.read<EncryptedKey | string>(`${name}@_k`);
if (typeof cipheredKey === "string") return cipheredKey;
if (!pbkdfKey || !cipheredKey) return;
return await aesDecrypt(pbkdfKey, cipheredKey);
} else {
const key = await this.read<string>(`${name}@_k`);
if (!key) return;
return key;
}
}
// noop
generateCryptoKeyFallback(
password: string,
salt?: string
): Promise<SerializedKey> {
return this.generateCryptoKey(password, salt);
}
// noop
async deriveCryptoKeyFallback(): Promise<void> {}
}
const dec = new TextDecoder();
async function aesDecrypt(
cryptoKey: CryptoKey,
data: EncryptedKey
): Promise<string> {
const { iv, cipher } = data;
const plainText = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: iv
},
cryptoKey,
cipher
);
return dec.decode(plainText);
}