1
0

Prep crypto work

This commit is contained in:
Zef Hemel 2024-01-08 09:12:54 +01:00
parent 373e048245
commit bfdc8383b1
4 changed files with 245 additions and 152 deletions

View File

@ -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<CryptoKey> {
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<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
const arrayBuffer = await window.crypto.subtle.exportKey("raw", key);
return new Uint8Array(arrayBuffer);
}
export function importKey(key: Uint8Array): Promise<CryptoKey> {
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;
}

View File

@ -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);
});

View File

@ -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++) {

View File

@ -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<void> {
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<Uint8Array> {
const arrayBuffer = await window.crypto.subtle.exportKey("raw", key);
return new Uint8Array(arrayBuffer);
}
private importKey(key: Uint8Array): Promise<CryptoKey> {
return window.crypto.subtle.importKey(
"raw",
key,
{ name: "AES-GCM" },
true,
["encrypt", "decrypt"],
);
}
private async deriveKeyFromPassword(
password: string,
): Promise<CryptoKey> {
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<Uint8Array> {
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<Uint8Array> {
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;
}