mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
refactor: simplify crypto utils
This commit is contained in:
@@ -17,7 +17,7 @@ describe("deriveKeyBundle", () => {
|
|||||||
|
|
||||||
expect(masterKey.type).toBe("secret");
|
expect(masterKey.type).toBe("secret");
|
||||||
expect(masterKey).toBeInstanceOf(CryptoKey);
|
expect(masterKey).toBeInstanceOf(CryptoKey);
|
||||||
expect(authHash).toHaveLength(64); // SHA-256 hex
|
expect(authHash).toHaveLength(64);
|
||||||
expect(typeof authHash).toBe("string");
|
expect(typeof authHash).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ describe("extractSharingKey", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("extracted key should decrypt the ciphertext produced by encryptLetter", async () => {
|
it("extracted key should decrypt the ciphertext produced by encryptLetter", async () => {
|
||||||
const plaintext = "hello from the owner";
|
const plaintext = "hello";
|
||||||
const encrypted = await utils.encryptLetter(plaintext, masterKey);
|
const encrypted = await utils.encryptLetter(plaintext, masterKey);
|
||||||
|
|
||||||
const extracted = await utils.extractSharingKey(
|
const extracted = await utils.extractSharingKey(
|
||||||
|
|||||||
+114
-59
@@ -7,6 +7,7 @@ export interface EncryptedLetter {
|
|||||||
export interface EncryptedLetterMetadata {
|
export interface EncryptedLetterMetadata {
|
||||||
encrypted_content: string;
|
encrypted_content: string;
|
||||||
encrypted_dek: string;
|
encrypted_dek: string;
|
||||||
|
sharingKey?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EncryptedImageUpload {
|
export interface EncryptedImageUpload {
|
||||||
@@ -21,58 +22,87 @@ interface SealedEnvelope {
|
|||||||
sharingKey: string;
|
sharingKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// we use a class here to keep track of instantiations (use 1 and the same DEK per letter content and metadata)
|
||||||
|
// TODO: try refactoring into a pure function for consistency
|
||||||
export class CryptoUtils {
|
export class CryptoUtils {
|
||||||
private dek: CryptoKey = {} as CryptoKey;
|
private dek!: CryptoKey;
|
||||||
private static readonly PBKDF2_ITERATIONS = 100_000;
|
private static readonly PBKDF2_ITERATIONS = 600_000;
|
||||||
private static readonly AES_GCM = { name: "AES-GCM", length: 256 };
|
// NOTE: https://www.w3.org/TR/webcrypto/#aes-gcm
|
||||||
|
private static readonly AES_ALGO = { name: "AES-GCM", length: 256 };
|
||||||
|
private static readonly IV_BYTE_LENGTH = 12;
|
||||||
|
|
||||||
// Generates a fresh Data Encryption Key (DEK)
|
// NOTE: this MUST be called once, per letter, for all operations in a session to a fresh Data Encryption Key (DEK)
|
||||||
async initialize() {
|
async initialize() {
|
||||||
this.dek = await crypto.subtle.generateKey(CryptoUtils.AES_GCM, true, [
|
this.dek = await crypto.subtle.generateKey(CryptoUtils.AES_ALGO, true, [
|
||||||
"encrypt",
|
"encrypt",
|
||||||
"decrypt",
|
"decrypt",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// base64 conversion for transit
|
private toBase64 = (buffer: Uint8Array): string => {
|
||||||
toBase64 = (buf: Uint8Array): string =>
|
// convert buffer to raw string
|
||||||
btoa(buf.reduce((s, b) => s + String.fromCharCode(b), ""));
|
let binaryFileString = "";
|
||||||
|
for (let i = 0; i < buffer.byteLength; i++) {
|
||||||
|
binaryFileString += String.fromCharCode(buffer[i]);
|
||||||
|
}
|
||||||
|
return btoa(binaryFileString);
|
||||||
|
};
|
||||||
|
|
||||||
fromBase64 = (b64: string): Uint8Array<ArrayBuffer> => {
|
private fromBase64 = (b64String: string): Uint8Array<ArrayBuffer> => {
|
||||||
const str = atob(b64);
|
const decodedString = atob(b64String);
|
||||||
const arr = new Uint8Array(str.length);
|
const arr = new Uint8Array(decodedString.length);
|
||||||
for (let i = 0; i < str.length; i++) arr[i] = str.charCodeAt(i);
|
for (let i = 0; i < decodedString.length; i++)
|
||||||
|
arr[i] = decodedString.charCodeAt(i);
|
||||||
return arr;
|
return arr;
|
||||||
};
|
};
|
||||||
|
|
||||||
// bundle IV + data into a single base64 string
|
// Required structure: [12 bytes IV][Cipher text][16 bytes Auth Tag]
|
||||||
packWithIv = (iv: Uint8Array, data: ArrayBuffer): string => {
|
// NOTE: Web Crypto API auto appends the auth tag, so we focus on IV and cipher
|
||||||
const packed = new Uint8Array(iv.length + data.byteLength);
|
private packWithIv = (iv: Uint8Array, ciphertext: ArrayBuffer): string => {
|
||||||
packed.set(iv);
|
// create a buffer large enough to hold both iv and cipher text (12 + x bytes)
|
||||||
packed.set(new Uint8Array(data), iv.length);
|
const combinedPayload = new Uint8Array(
|
||||||
return this.toBase64(packed);
|
CryptoUtils.IV_BYTE_LENGTH + ciphertext.byteLength,
|
||||||
|
);
|
||||||
|
|
||||||
|
// place the iv at the start
|
||||||
|
combinedPayload.set(iv, 0);
|
||||||
|
|
||||||
|
// place the ciphertext after the iv
|
||||||
|
combinedPayload.set(new Uint8Array(ciphertext), CryptoUtils.IV_BYTE_LENGTH);
|
||||||
|
|
||||||
|
// convert the buffer to Base64 for transit
|
||||||
|
return this.toBase64(combinedPayload);
|
||||||
};
|
};
|
||||||
|
|
||||||
unpackWithIv = (
|
// For decryption: extracts the IV and the data from the base64 string, easy because we know the size of iv already.
|
||||||
b64: string,
|
private unpackWithIv = (
|
||||||
): [Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>] => {
|
encodedString: string,
|
||||||
const buf = this.fromBase64(b64);
|
): { iv: Uint8Array<ArrayBuffer>; ciphertext: Uint8Array<ArrayBuffer> } => {
|
||||||
return [new Uint8Array(buf.buffer, 0, 12), new Uint8Array(buf.buffer, 12)];
|
// decode from base64 to array buffer
|
||||||
|
const fullBuffer = this.fromBase64(encodedString);
|
||||||
|
|
||||||
|
// extract first 12 bytes for iv
|
||||||
|
const iv = fullBuffer.slice(0, CryptoUtils.IV_BYTE_LENGTH);
|
||||||
|
// extract rest for cipher text
|
||||||
|
const ciphertext = fullBuffer.slice(CryptoUtils.IV_BYTE_LENGTH);
|
||||||
|
|
||||||
|
return { iv: new Uint8Array(iv), ciphertext: new Uint8Array(ciphertext) };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derives a Key Bundle (MasterKey + AuthHash) from a password + email.
|
* Derive a key bundle (Masterkey + authHash) from email + (plain) password combo
|
||||||
|
* WHY?: This is much secure than relying on server to hash and store the password. Also ensures absolute 0 knowledge
|
||||||
*/
|
*/
|
||||||
public static async deriveKeyBundle(
|
public static async deriveKeyBundle(
|
||||||
password: string,
|
password: string,
|
||||||
email: string,
|
email: string,
|
||||||
): Promise<{ masterKey: CryptoKey; authHash: string }> {
|
): Promise<{ masterKey: CryptoKey; authHash: string }> {
|
||||||
const enc = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const salt = enc.encode(email.toLowerCase());
|
const salt = encoder.encode(email.toLowerCase());
|
||||||
|
|
||||||
const baseKey = await crypto.subtle.importKey(
|
const baseKey = await crypto.subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
enc.encode(password),
|
encoder.encode(password),
|
||||||
"PBKDF2",
|
"PBKDF2",
|
||||||
false,
|
false,
|
||||||
["deriveBits", "deriveKey"],
|
["deriveBits", "deriveKey"],
|
||||||
@@ -89,53 +119,67 @@ export class CryptoUtils {
|
|||||||
512,
|
512,
|
||||||
);
|
);
|
||||||
|
|
||||||
// first 256 bits for MasterKey, last 256 bits for AuthHash
|
// first 256 bits for masterkey, last 256 bits for authHash (password sent in REST)
|
||||||
const masterKeyBytes = masterSeed.slice(0, 32);
|
const masterKeyBytes = masterSeed.slice(0, 32);
|
||||||
const authHashBytes = masterSeed.slice(32, 64);
|
const authHashBytes = masterSeed.slice(32, 64);
|
||||||
|
|
||||||
// Create the MasterKey for client-side encryption
|
// Create the masterkey for client-side encryption
|
||||||
const masterKey = await crypto.subtle.importKey(
|
const masterKey = await crypto.subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
masterKeyBytes,
|
masterKeyBytes,
|
||||||
CryptoUtils.AES_GCM,
|
CryptoUtils.AES_ALGO,
|
||||||
false,
|
false,
|
||||||
["encrypt", "decrypt", "wrapKey", "unwrapKey"],
|
["encrypt", "decrypt", "wrapKey", "unwrapKey"],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create the hex AuthHash for server-side verification
|
// convert bytes in to hex string
|
||||||
const authHash = Array.from(new Uint8Array(authHashBytes))
|
let authHash = "";
|
||||||
.map((b) => b.toString(16).padStart(2, "0"))
|
const authHashBuffer = new Uint8Array(authHashBytes);
|
||||||
.join("");
|
|
||||||
|
for (let i = 0; i < authHashBuffer.byteLength; i++) {
|
||||||
|
// we force every bytes converted to string to be min 2 chars (otherwise 00 0a will be just a and not "000a")
|
||||||
|
authHash += authHashBuffer[i].toString(16).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
return { masterKey, authHash };
|
return { masterKey, authHash };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Envelope Encryption - Decryption
|
||||||
|
* WHY?: for guest access where we don't have to share the masterkey just the dek.
|
||||||
|
* This way, raw dek never leaves browser (db stores the encrypted version)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// encrypt the plaintext with a DEK and then encrypt (wrap) that DEK with the user's masterkey.
|
||||||
private async sealEnvelope(
|
private async sealEnvelope(
|
||||||
input: Uint8Array,
|
input: Uint8Array,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<SealedEnvelope> {
|
): Promise<SealedEnvelope> {
|
||||||
|
if (!this.dek) {
|
||||||
|
throw new Error("DEK is not available (forgot to .initialize()?)");
|
||||||
|
}
|
||||||
const plainBytes = new Uint8Array(input);
|
const plainBytes = new Uint8Array(input);
|
||||||
const contentIV = crypto.getRandomValues(new Uint8Array(12));
|
const contentIv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
const dekIV = crypto.getRandomValues(new Uint8Array(12));
|
const dekIv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
|
||||||
const ciphertext = await crypto.subtle.encrypt(
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
{ name: "AES-GCM", iv: contentIV },
|
{ name: CryptoUtils.AES_ALGO.name, iv: contentIv },
|
||||||
this.dek,
|
this.dek,
|
||||||
plainBytes,
|
plainBytes,
|
||||||
);
|
);
|
||||||
|
|
||||||
// wrap the DEK with the Master Key (for self/owner access)
|
// wrap the DEK with the Master Key (for self access)
|
||||||
const wrappedDek = await crypto.subtle.wrapKey("raw", this.dek, masterKey, {
|
const wrappedDek = await crypto.subtle.wrapKey("raw", this.dek, masterKey, {
|
||||||
name: "AES-GCM",
|
name: CryptoUtils.AES_ALGO.name,
|
||||||
iv: dekIV,
|
iv: dekIv,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export raw DEK for the share URL (recipient access, no master key needed)
|
// export raw DEK for the share URL (recipient access, no master key needed)
|
||||||
const rawDek = await crypto.subtle.exportKey("raw", this.dek);
|
const rawDek = await crypto.subtle.exportKey("raw", this.dek);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encryptedContent: this.packWithIv(contentIV, ciphertext),
|
encryptedContent: this.packWithIv(contentIv, ciphertext),
|
||||||
encrypted_dek: this.packWithIv(dekIV, wrappedDek),
|
encrypted_dek: this.packWithIv(dekIv, wrappedDek),
|
||||||
sharingKey: this.toBase64(new Uint8Array(rawDek)),
|
sharingKey: this.toBase64(new Uint8Array(rawDek)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -146,20 +190,21 @@ export class CryptoUtils {
|
|||||||
encrypted_dek: string,
|
encrypted_dek: string,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<Uint8Array<ArrayBuffer>> {
|
): Promise<Uint8Array<ArrayBuffer>> {
|
||||||
const [dekIv, wrappedDek] = this.unpackWithIv(encrypted_dek);
|
const { iv: dekIv, ciphertext: wrappedDek } =
|
||||||
|
this.unpackWithIv(encrypted_dek);
|
||||||
const dek = await crypto.subtle.unwrapKey(
|
const dek = await crypto.subtle.unwrapKey(
|
||||||
"raw",
|
"raw",
|
||||||
wrappedDek,
|
wrappedDek,
|
||||||
masterKey,
|
masterKey,
|
||||||
{ name: "AES-GCM", iv: dekIv },
|
{ name: CryptoUtils.AES_ALGO.name, iv: dekIv },
|
||||||
CryptoUtils.AES_GCM,
|
CryptoUtils.AES_ALGO,
|
||||||
false,
|
false,
|
||||||
["decrypt"],
|
["decrypt"],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [contentIv, ciphertext] = this.unpackWithIv(encryptedContent);
|
const { iv: contentIv, ciphertext } = this.unpackWithIv(encryptedContent);
|
||||||
const plainBytes = await crypto.subtle.decrypt(
|
const plainBytes = await crypto.subtle.decrypt(
|
||||||
{ name: "AES-GCM", iv: contentIv },
|
{ name: CryptoUtils.AES_ALGO.name, iv: contentIv },
|
||||||
dek,
|
dek,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
);
|
);
|
||||||
@@ -175,14 +220,14 @@ export class CryptoUtils {
|
|||||||
const dek = await crypto.subtle.importKey(
|
const dek = await crypto.subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
dekBytes,
|
dekBytes,
|
||||||
CryptoUtils.AES_GCM,
|
CryptoUtils.AES_ALGO,
|
||||||
false,
|
false,
|
||||||
["decrypt"],
|
["decrypt"],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [contentIv, ciphertext] = this.unpackWithIv(encryptedContent);
|
const { iv: contentIv, ciphertext } = this.unpackWithIv(encryptedContent);
|
||||||
const plainBytes = await crypto.subtle.decrypt(
|
const plainBytes = await crypto.subtle.decrypt(
|
||||||
{ name: "AES-GCM", iv: contentIv },
|
{ name: CryptoUtils.AES_ALGO.name, iv: contentIv },
|
||||||
dek,
|
dek,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
);
|
);
|
||||||
@@ -199,6 +244,7 @@ export class CryptoUtils {
|
|||||||
): Promise<EncryptedLetter> {
|
): Promise<EncryptedLetter> {
|
||||||
const { encryptedContent, encrypted_dek, sharingKey } =
|
const { encryptedContent, encrypted_dek, sharingKey } =
|
||||||
await this.sealEnvelope(new TextEncoder().encode(plaintext), masterKey);
|
await this.sealEnvelope(new TextEncoder().encode(plaintext), masterKey);
|
||||||
|
|
||||||
return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
|
return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +257,7 @@ export class CryptoUtils {
|
|||||||
encrypted_dek,
|
encrypted_dek,
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return new TextDecoder().decode(bytes);
|
return new TextDecoder().decode(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,18 +269,20 @@ export class CryptoUtils {
|
|||||||
encrypted_content,
|
encrypted_content,
|
||||||
sharingKey,
|
sharingKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return new TextDecoder().decode(bytes);
|
return new TextDecoder().decode(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async encryptMetadata(
|
public async encryptMetadata(
|
||||||
metadata: Record<string, any>,
|
metadata: Record<string, any>,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<EncryptedLetter> {
|
): Promise<EncryptedLetterMetadata> {
|
||||||
const { encryptedContent, encrypted_dek, sharingKey } =
|
const { encryptedContent, encrypted_dek, sharingKey } =
|
||||||
await this.sealEnvelope(
|
await this.sealEnvelope(
|
||||||
new TextEncoder().encode(JSON.stringify(metadata)),
|
new TextEncoder().encode(JSON.stringify(metadata)),
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
|
return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +295,7 @@ export class CryptoUtils {
|
|||||||
encrypted_metadata.encrypted_dek,
|
encrypted_metadata.encrypted_dek,
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return JSON.parse(new TextDecoder().decode(bytes));
|
return JSON.parse(new TextDecoder().decode(bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +307,7 @@ export class CryptoUtils {
|
|||||||
encrypted_content,
|
encrypted_content,
|
||||||
sharingKey,
|
sharingKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return JSON.parse(new TextDecoder().decode(bytes));
|
return JSON.parse(new TextDecoder().decode(bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,12 +334,13 @@ export class CryptoUtils {
|
|||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
|
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
|
||||||
const bytes = await this.openEnvelope(
|
const plainBytes = await this.openEnvelope(
|
||||||
this.toBase64(encryptedBytes),
|
this.toBase64(encryptedBytes),
|
||||||
encrypted_dek,
|
encrypted_dek,
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
return URL.createObjectURL(new Blob([bytes]));
|
|
||||||
|
return URL.createObjectURL(new Blob([plainBytes]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async decryptImageWithSharingKey(
|
public async decryptImageWithSharingKey(
|
||||||
@@ -296,28 +348,31 @@ export class CryptoUtils {
|
|||||||
sharingKey: string,
|
sharingKey: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
|
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
|
||||||
const bytes = await this.openEnvelopeWithSharingKey(
|
const plainBytes = await this.openEnvelopeWithSharingKey(
|
||||||
this.toBase64(encryptedBytes),
|
this.toBase64(encryptedBytes),
|
||||||
sharingKey,
|
sharingKey,
|
||||||
);
|
);
|
||||||
return URL.createObjectURL(new Blob([bytes]));
|
|
||||||
|
return URL.createObjectURL(new Blob([plainBytes]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-derives the sharing key (raw DEK) on demand (browser only, not sent to server).
|
// derive raw DEK on demand (browser only, not sent to server) for guest access
|
||||||
public async extractSharingKey(
|
public async extractSharingKey(
|
||||||
encrypted_dek: string,
|
encrypted_dek: string,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const [dekIv, wrappedDek] = this.unpackWithIv(encrypted_dek);
|
const { iv: dekIv, ciphertext: wrappedDek } =
|
||||||
|
this.unpackWithIv(encrypted_dek);
|
||||||
const rawDek = await crypto.subtle.unwrapKey(
|
const rawDek = await crypto.subtle.unwrapKey(
|
||||||
"raw",
|
"raw",
|
||||||
wrappedDek,
|
wrappedDek,
|
||||||
masterKey,
|
masterKey,
|
||||||
{ name: "AES-GCM", iv: dekIv },
|
{ name: CryptoUtils.AES_ALGO.name, iv: dekIv },
|
||||||
CryptoUtils.AES_GCM,
|
CryptoUtils.AES_ALGO,
|
||||||
true,
|
true,
|
||||||
["decrypt"],
|
["decrypt"],
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.toBase64(
|
return this.toBase64(
|
||||||
new Uint8Array(await crypto.subtle.exportKey("raw", rawDek)),
|
new Uint8Array(await crypto.subtle.exportKey("raw", rawDek)),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user