Prep crypto work
This commit is contained in:
parent
373e048245
commit
bfdc8383b1
136
common/crypto.ts
136
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<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;
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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++) {
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user