1
0
silverbullet/common/spaces/encrypted_space_primitives.ts
2024-01-08 09:12:54 +01:00

340 lines
9.0 KiB
TypeScript

import { FileMeta } from "../../plug-api/types.ts";
import {
base32Decode,
base32Encode,
decryptAES,
deriveKeyFromPassword,
encryptAES,
exportKey,
generateSalt,
importKey,
} from "../crypto.ts";
import { SpacePrimitives } from "./space_primitives.ts";
export const encryptedFileExt = ".crypt";
export const keyPath = "KEY";
export const saltFile = "salt.crypt";
/**
* This class adds an (AES) based encryption layer on top of another SpacePrimitives implementation.
* It encrypts all file names and file contents.
* It uses a key file (default named _KEY) to store the encryption key, this file is encrypted with a key derived from the user's password.
* The reason to keep the actualy encryption key in a file is to allow the user to change their password without having to re-encrypt all files.
* Important note: FileMeta's size will reflect the underlying encrypted size, not the original size
*/
export class EncryptedSpacePrimitives implements SpacePrimitives {
private masterKey?: CryptoKey;
private encryptedKeyFileName?: string;
spaceSalt?: Uint8Array;
constructor(
private wrapped: SpacePrimitives,
) {
}
/**
* Checks if the space is initialized by loading the salt file.
* @returns true if the space was initialized, false if it was not initialized yet
*/
async init(salt?: Uint8Array | undefined | null): Promise<boolean> {
if (salt) {
this.spaceSalt = salt;
return true;
}
try {
this.spaceSalt = (await this.wrapped.readFile(saltFile)).data;
return true;
} catch (e: any) {
if (e.message === "Not found") {
console.warn("Space not initialized");
return false;
}
throw e;
}
}
/**
* Setup a fresh space with a new salt and master encryption key derived from a password
* @param password
*/
async setup(password: string): Promise<void> {
if (this.spaceSalt) {
throw new Error("Space already initialized");
}
this.spaceSalt = generateSalt();
await this.wrapped.writeFile(saltFile, this.spaceSalt);
await this.createKey(password);
}
/**
* Loads the encryption key from the master key based on the user's password
* @param password the user's password
*/
async login(password: string): Promise<void> {
if (!this.spaceSalt) {
throw new Error("Space not initialized");
}
// First derive an encryption key solely used for encrypting the key file from the user's password
const keyEncryptionKey = await deriveKeyFromPassword(
this.spaceSalt!,
password,
);
const encryptedKeyFileName = await this.encryptPath(
keyEncryptionKey,
keyPath,
);
try {
this.masterKey = await importKey(
await decryptAES(
keyEncryptionKey,
(await this.wrapped.readFile(
encryptedKeyFileName,
)).data,
),
);
this.encryptedKeyFileName = encryptedKeyFileName;
} catch (e: any) {
if (e.message === "Not found") {
throw new Error("Incorrect password");
}
console.trace();
throw e;
}
}
private generateKey(): Promise<CryptoKey> {
return window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 256,
},
true,
["encrypt", "decrypt"],
);
}
private async createKey(password: string): Promise<void> {
const keyEncryptionKey = await deriveKeyFromPassword(
this.spaceSalt!,
password,
);
this.encryptedKeyFileName = await this.encryptPath(
keyEncryptionKey,
keyPath,
);
this.masterKey = await this.generateKey();
// And write it
await this.wrapped.writeFile(
this.encryptedKeyFileName,
await encryptAES(
keyEncryptionKey,
await exportKey(this.masterKey),
),
);
}
async updatePassword(oldPassword: string, newPasword: string): Promise<void> {
if (!this.masterKey) {
throw new Error("No key loaded");
}
const oldPasswordKeyFileName = await this.encryptPath(
await deriveKeyFromPassword(this.spaceSalt!, oldPassword),
keyPath,
);
// Check if the old password is correct
try {
await this.wrapped.getFileMeta(oldPasswordKeyFileName);
} catch (e: any) {
if (e.message === "Not found") {
throw new Error("Incorrect password");
} else {
throw e;
}
}
// First derive an encryption key solely used for encrypting the key file from the user's password
const keyEncryptionKey = await deriveKeyFromPassword(
this.spaceSalt!,
newPasword,
);
this.encryptedKeyFileName = await this.encryptPath(
keyEncryptionKey,
keyPath,
);
// And write it
await this.wrapped.writeFile(
this.encryptedKeyFileName,
await encryptAES(
keyEncryptionKey,
await exportKey(this.masterKey),
),
);
// Then delete the old key file based on the old password
await this.wrapped.deleteFile(oldPasswordKeyFileName);
}
isUnencryptedPath(name: string) {
return name.startsWith("_plug/");
}
/**
* Left pads a string with zeros to a length of 32, encrypts it using AES-GCM and returns the base32 encoded ciphertext
* @param key
* @param path
* @returns
*/
async encryptPath(
key: CryptoKey,
path: string,
): Promise<string> {
if (!this.spaceSalt) {
throw new Error("Space not initialized");
}
if (this.isUnencryptedPath(path)) {
return path;
}
path = path.padEnd(32, "\0");
const encodedMessage = new TextEncoder().encode(path);
const ciphertext = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: this.spaceSalt,
},
key,
encodedMessage,
);
const encodedPath = base32Encode(new Uint8Array(ciphertext));
// console.log(new TextDecoder().decode(ciphertext));
return encodedPath.slice(0, 3) + "/" + encodedPath.slice(3) +
encryptedFileExt;
}
private async decryptPath(
key: CryptoKey,
encryptedPath: string,
): Promise<string> {
if (!this.spaceSalt) {
throw new Error("Space not initialized");
}
if (this.isUnencryptedPath(encryptedPath)) {
return encryptedPath;
}
if (!encryptedPath.endsWith(encryptedFileExt)) {
throw new Error("Invalid encrypted path");
}
// Remove the extension and slashes
encryptedPath = encryptedPath.slice(0, -encryptedFileExt.length).replaceAll(
"/",
"",
);
// console.log("To decrypt", encryptedPath);
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: this.spaceSalt,
},
key,
base32Decode(encryptedPath),
);
// Decode the buffer and remove the padding
return removePadding(new TextDecoder().decode(decrypted), "\0");
}
async fetchFileList(): Promise<FileMeta[]> {
const files = await this.wrapped.fetchFileList();
// console.log(files);
return Promise.all(
files.filter((fileMeta) =>
fileMeta.name !== this.encryptedKeyFileName &&
fileMeta.name !== saltFile
)
.map(async (fileMeta) => {
return {
...fileMeta,
name: await this.decryptPath(this.masterKey!, fileMeta.name),
};
}),
);
}
async getFileMeta(name: string): Promise<FileMeta> {
if (this.isUnencryptedPath(name)) {
return this.wrapped.getFileMeta(name);
}
const fileMeta = await this.wrapped.getFileMeta(
await this.encryptPath(this.masterKey!, name),
);
return {
...fileMeta,
name,
};
}
async readFile(name: string): Promise<{ data: Uint8Array; meta: FileMeta }> {
if (this.isUnencryptedPath(name)) {
return this.wrapped.readFile(name);
}
const { data, meta } = await this.wrapped.readFile(
await this.encryptPath(this.masterKey!, name),
);
return {
data: await decryptAES(this.masterKey!, data),
meta: {
...meta,
name,
},
};
}
async writeFile(
name: string,
data: Uint8Array,
selfUpdate?: boolean | undefined,
meta?: FileMeta | undefined,
): Promise<FileMeta> {
if (this.isUnencryptedPath(name)) {
return this.wrapped.writeFile(name, data, selfUpdate, meta);
}
const newMeta = await this.wrapped.writeFile(
await this.encryptPath(this.masterKey!, name),
await encryptAES(this.masterKey!, data),
selfUpdate,
meta,
);
return {
...newMeta,
name,
};
}
async deleteFile(name: string): Promise<void> {
if (this.isUnencryptedPath(name)) {
return this.wrapped.deleteFile(name);
}
return this.wrapped.deleteFile(
await this.encryptPath(this.masterKey!, name),
);
}
}
function removePadding(str: string, paddingChar: string): string {
// let startIndex = 0;
// while (startIndex < str.length && str[startIndex] === paddingChar) {
// startIndex++;
// }
// return str.substring(startIndex);
let endIndex = str.length - 1;
while (endIndex >= 0 && str[endIndex] === paddingChar) {
endIndex--;
}
return str.substring(0, endIndex + 1);
}