From bfdc8383b1186fffe448e3aaa340338fef39ad74 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Mon, 8 Jan 2024 09:12:54 +0100 Subject: [PATCH] Prep crypto work --- common/crypto.ts | 136 +++++++++++++++ common/json.test.ts | 30 +++- common/json.ts | 51 +++++- common/spaces/encrypted_space_primitives.ts | 180 ++++---------------- 4 files changed, 245 insertions(+), 152 deletions(-) diff --git a/common/crypto.ts b/common/crypto.ts index ee90a48..542f674 100644 --- a/common/crypto.ts +++ b/common/crypto.ts @@ -10,3 +10,139 @@ export function simpleHash(s: string): number { } return hash; } + +export function base32Encode(data: Uint8Array): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let result = ""; + let bits = 0; + let value = 0; + for (const byte of data) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + result += alphabet[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) { + result += alphabet[(value << (5 - bits)) & 31]; + } + return result; +} + +export function base32Decode(data: string): Uint8Array { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + const result = new Uint8Array(Math.floor(data.length * 5 / 8)); + let bits = 0; + let value = 0; + let index = 0; + for (const char of data) { + value = (value << 5) | alphabet.indexOf(char); + bits += 5; + if (bits >= 8) { + result[index++] = (value >>> (bits - 8)) & 255; + bits -= 8; + } + } + return result; +} + +export async function deriveKeyFromPassword( + salt: Uint8Array, + password: string, +): Promise { + const baseKey = new TextEncoder().encode(password); + const importedKey = await window.crypto.subtle.importKey( + "raw", + baseKey, + { name: "PBKDF2" }, + false, + ["deriveKey"], + ); + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt, + iterations: 10000, + hash: "SHA-256", + }, + importedKey, + { + name: "AES-GCM", + length: 256, + }, + true, + ["encrypt", "decrypt"], + ); +} + +/** + * Encrypts using AES-GCM and prepends the IV to the ciphertext + * @param key + * @param message + * @returns + */ +export async function encryptAES( + key: CryptoKey, + message: Uint8Array, +): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await window.crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + message, + ); + return appendBuffer(iv, new Uint8Array(ciphertext)); +} + +/** + * Decrypts using AES-GCM and expects the IV to be prepended to the ciphertext + * @param key + * @param data + * @returns + */ +export async function decryptAES( + key: CryptoKey, + data: Uint8Array, +): Promise { + const iv = data.slice(0, 12); + const ciphertext = data.slice(12); + const decrypted = await window.crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + ciphertext, + ); + return new Uint8Array(decrypted); +} + +export function generateSalt(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(16)); +} + +export async function exportKey(key: CryptoKey): Promise { + const arrayBuffer = await window.crypto.subtle.exportKey("raw", key); + return new Uint8Array(arrayBuffer); +} + +export function importKey(key: Uint8Array): Promise { + return window.crypto.subtle.importKey( + "raw", + key, + { name: "AES-GCM" }, + true, + ["encrypt", "decrypt"], + ); +} + +function appendBuffer(buffer1: Uint8Array, buffer2: Uint8Array): Uint8Array { + const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength); + tmp.set(new Uint8Array(buffer1), 0); + tmp.set(new Uint8Array(buffer2), buffer1.byteLength); + return tmp; +} diff --git a/common/json.test.ts b/common/json.test.ts index 183e0f6..d29fd99 100644 --- a/common/json.test.ts +++ b/common/json.test.ts @@ -1,7 +1,7 @@ import { assertEquals } from "../test_deps.ts"; -import { traverseAndRewriteJSON } from "./json.ts"; +import { decodeBSON, encodeBSON, traverseAndRewriteJSON } from "./json.ts"; -Deno.test("traverseAndRewrite should recursively traverse and rewrite object properties", () => { +Deno.test("traverseAndRewrite", () => { const bufArray = new Uint8Array([1, 2, 3]); const obj = { foo: "bar", @@ -35,3 +35,29 @@ Deno.test("traverseAndRewrite should recursively traverse and rewrite object pro special: bufArray, }); }); + +Deno.test("BSON encoding", () => { + // Test some primitives + assertEquals(decodeBSON(encodeBSON("test")), "test"); + assertEquals(decodeBSON(encodeBSON([1, 2, 3])), [1, 2, 3]); + assertEquals(decodeBSON(encodeBSON(true)), true); + assertEquals(decodeBSON(encodeBSON(false)), false); + assertEquals(decodeBSON(encodeBSON(null)), null); + assertEquals(decodeBSON(encodeBSON(0)), 0); + + assertEquals(decodeBSON(encodeBSON(undefined)), undefined); + + const blob = new Uint8Array([1, 2, 3]); + assertEquals(decodeBSON(encodeBSON(blob)), blob); + + // Then move to more advanced wrapped content + const obj = { + foo: "bar", + list: ["hello", { sup: "world" }], + nested: { + baz: "qux", + }, + bin: blob, + }; + assertEquals(decodeBSON(encodeBSON(obj)), obj); +}); diff --git a/common/json.ts b/common/json.ts index 05ad691..13ae1ad 100644 --- a/common/json.ts +++ b/common/json.ts @@ -1,3 +1,50 @@ +import { BSON } from "https://esm.sh/bson@6.2.0"; + +// BSON doesn't support top-level primitives, so we need to wrap them in an object +const topLevelValueKey = "$_tl"; + +// BSON doesn't support undefined, so we need to encode it as a "magic" string +const undefinedPlaceHolder = "$_undefined_$"; + +/** + * BSON encoder, but also supporting "edge cases" like encoding strings, numbers, etc. + * @param obj + * @returns + */ +export function encodeBSON(obj: any): Uint8Array { + if ( + obj === undefined || obj === null || + !(typeof obj === "object" && obj.constructor === Object) + ) { + obj = { [topLevelValueKey]: obj }; + } + obj = traverseAndRewriteJSON(obj, (val) => { + if (val === undefined) { + return undefinedPlaceHolder; + } + return val; + }); + return BSON.serialize(obj); +} + +export function decodeBSON(data: Uint8Array): any { + let result = BSON.deserialize(data); + // For whatever reason the BSON library doesn't unwrap binary blobs automatically + result = traverseAndRewriteJSON(result, (val) => { + if (typeof val?.value === "function") { + return val.value(); + } else if (val === undefinedPlaceHolder) { + return undefined; + } + return val; + }); + if (Object.hasOwn(result, topLevelValueKey)) { + return result[topLevelValueKey]; + } else { + return result; + } +} + /** * Traverses and rewrites an object recursively. * @@ -13,8 +60,8 @@ export function traverseAndRewriteJSON( obj = rewrite(obj); // Recurse down if this is an array or a "plain object" if ( - obj && Array.isArray(obj) || - (typeof obj === "object" && obj.constructor === Object) + obj && (Array.isArray(obj) || + (typeof obj === "object" && obj.constructor === Object)) ) { const keys = Object.keys(obj); for (let i = 0; i < keys.length; i++) { diff --git a/common/spaces/encrypted_space_primitives.ts b/common/spaces/encrypted_space_primitives.ts index 30be47f..36ff8ee 100644 --- a/common/spaces/encrypted_space_primitives.ts +++ b/common/spaces/encrypted_space_primitives.ts @@ -1,4 +1,14 @@ 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"; @@ -51,7 +61,7 @@ export class EncryptedSpacePrimitives implements SpacePrimitives { if (this.spaceSalt) { throw new Error("Space already initialized"); } - this.spaceSalt = this.generateSalt(); + this.spaceSalt = generateSalt(); await this.wrapped.writeFile(saltFile, this.spaceSalt); await this.createKey(password); } @@ -65,15 +75,18 @@ export class EncryptedSpacePrimitives implements SpacePrimitives { 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 this.deriveKeyFromPassword(password); + const keyEncryptionKey = await deriveKeyFromPassword( + this.spaceSalt!, + password, + ); const encryptedKeyFileName = await this.encryptPath( keyEncryptionKey, keyPath, ); try { - this.masterKey = await this.importKey( - await this.decryptAES( + this.masterKey = await importKey( + await decryptAES( keyEncryptionKey, (await this.wrapped.readFile( encryptedKeyFileName, @@ -102,7 +115,10 @@ export class EncryptedSpacePrimitives implements SpacePrimitives { } private async createKey(password: string): Promise { - const keyEncryptionKey = await this.deriveKeyFromPassword(password); + const keyEncryptionKey = await deriveKeyFromPassword( + this.spaceSalt!, + password, + ); this.encryptedKeyFileName = await this.encryptPath( keyEncryptionKey, keyPath, @@ -111,9 +127,9 @@ export class EncryptedSpacePrimitives implements SpacePrimitives { // And write it await this.wrapped.writeFile( this.encryptedKeyFileName, - await this.encryptAES( + await encryptAES( keyEncryptionKey, - await this.exportKey(this.masterKey), + await exportKey(this.masterKey), ), ); } @@ -123,7 +139,7 @@ export class EncryptedSpacePrimitives implements SpacePrimitives { throw new Error("No key loaded"); } const oldPasswordKeyFileName = await this.encryptPath( - await this.deriveKeyFromPassword(oldPassword), + await deriveKeyFromPassword(this.spaceSalt!, oldPassword), keyPath, ); @@ -139,7 +155,10 @@ export class EncryptedSpacePrimitives implements SpacePrimitives { } // First derive an encryption key solely used for encrypting the key file from the user's password - const keyEncryptionKey = await this.deriveKeyFromPassword(newPasword); + const keyEncryptionKey = await deriveKeyFromPassword( + this.spaceSalt!, + newPasword, + ); this.encryptedKeyFileName = await this.encryptPath( keyEncryptionKey, @@ -148,9 +167,9 @@ export class EncryptedSpacePrimitives implements SpacePrimitives { // And write it await this.wrapped.writeFile( this.encryptedKeyFileName, - await this.encryptAES( + await encryptAES( keyEncryptionKey, - await this.exportKey(this.masterKey), + await exportKey(this.masterKey), ), ); @@ -162,98 +181,6 @@ export class EncryptedSpacePrimitives implements SpacePrimitives { return name.startsWith("_plug/"); } - private generateSalt(): Uint8Array { - return crypto.getRandomValues(new Uint8Array(16)); - } - - private async exportKey(key: CryptoKey): Promise { - const arrayBuffer = await window.crypto.subtle.exportKey("raw", key); - return new Uint8Array(arrayBuffer); - } - - private importKey(key: Uint8Array): Promise { - return window.crypto.subtle.importKey( - "raw", - key, - { name: "AES-GCM" }, - true, - ["encrypt", "decrypt"], - ); - } - - private async deriveKeyFromPassword( - password: string, - ): Promise { - const baseKey = new TextEncoder().encode(password); - const importedKey = await window.crypto.subtle.importKey( - "raw", - baseKey, - { name: "PBKDF2" }, - false, - ["deriveKey"], - ); - return crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt: this.spaceSalt!, - iterations: 10000, - hash: "SHA-256", - }, - importedKey, - { - name: "AES-GCM", - length: 256, - }, - true, - ["encrypt", "decrypt"], - ); - } - - /** - * Encrypts using AES-GCM and prepends the IV to the ciphertext - * @param key - * @param message - * @returns - */ - private async encryptAES( - key: CryptoKey, - message: Uint8Array, - ): Promise { - const iv = crypto.getRandomValues(new Uint8Array(12)); - const ciphertext = await window.crypto.subtle.encrypt( - { - name: "AES-GCM", - iv: iv, - }, - key, - message, - ); - return appendBuffer(iv, new Uint8Array(ciphertext)); - } - - /** - * Decrypts using AES-GCM and expects the IV to be prepended to the ciphertext - * @param key - * @param data - * @returns - */ - async decryptAES( - key: CryptoKey, - data: Uint8Array, - ): Promise { - const iv = data.slice(0, 12); - const ciphertext = data.slice(12); - const decrypted = await window.crypto.subtle.decrypt( - { - name: "AES-GCM", - iv: iv, - }, - key, - ciphertext, - ); - return new Uint8Array(decrypted); - } - /** * Left pads a string with zeros to a length of 32, encrypts it using AES-GCM and returns the base32 encoded ciphertext * @param key @@ -359,7 +286,7 @@ export class EncryptedSpacePrimitives implements SpacePrimitives { await this.encryptPath(this.masterKey!, name), ); return { - data: await this.decryptAES(this.masterKey!, data), + data: await decryptAES(this.masterKey!, data), meta: { ...meta, name, @@ -378,7 +305,7 @@ export class EncryptedSpacePrimitives implements SpacePrimitives { } const newMeta = await this.wrapped.writeFile( await this.encryptPath(this.masterKey!, name), - await this.encryptAES(this.masterKey!, data), + await encryptAES(this.masterKey!, data), selfUpdate, meta, ); @@ -410,46 +337,3 @@ function removePadding(str: string, paddingChar: string): string { } return str.substring(0, endIndex + 1); } - -function base32Encode(data: Uint8Array): string { - const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - let result = ""; - let bits = 0; - let value = 0; - for (const byte of data) { - value = (value << 8) | byte; - bits += 8; - while (bits >= 5) { - result += alphabet[(value >>> (bits - 5)) & 31]; - bits -= 5; - } - } - if (bits > 0) { - result += alphabet[(value << (5 - bits)) & 31]; - } - return result; -} - -function base32Decode(data: string): Uint8Array { - const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - const result = new Uint8Array(Math.floor(data.length * 5 / 8)); - let bits = 0; - let value = 0; - let index = 0; - for (const char of data) { - value = (value << 5) | alphabet.indexOf(char); - bits += 5; - if (bits >= 8) { - result[index++] = (value >>> (bits - 8)) & 255; - bits -= 8; - } - } - return result; -} - -function appendBuffer(buffer1: Uint8Array, buffer2: Uint8Array): Uint8Array { - const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength); - tmp.set(new Uint8Array(buffer1), 0); - tmp.set(new Uint8Array(buffer2), buffer1.byteLength); - return tmp; -}