Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(encryption): Implement e2e encryption using encryptedStorage composable #363

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"unstorage": "pnpm jiti src/cli"
},
"dependencies": {
"@noble/ciphers": "^0.4.1",
"anymatch": "^3.1.3",
"chokidar": "^3.5.3",
"destr": "^2.0.2",
Expand All @@ -53,7 +54,8 @@
"mri": "^1.2.0",
"node-fetch-native": "^1.4.1",
"ofetch": "^1.3.3",
"ufo": "^1.3.2"
"ufo": "^1.3.2",
"uncrypto": "^0.1.3"
},
"devDependencies": {
"@azure/app-configuration": "^1.5.0",
Expand Down
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

93 changes: 93 additions & 0 deletions src/_utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { getRandomValues } from "uncrypto";
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
import { siv } from "@noble/ciphers/aes";

type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
type Promisified<T> = Promise<Awaited<T>>;

Expand Down Expand Up @@ -75,3 +79,92 @@ export function deserializeRaw(value: any) {
checkBufferSupport();
return Buffer.from(value.slice(BASE64_PREFIX.length), "base64");
}

// Encryption

// Use only for GCM-SIV, due to nonce-misuse-resistance. We need deterministic keys.
const predefinedSivNonce = "ThtnxLK9eCF4OLMg";
const encryptionPrefix = "$enc:";

export interface StorageValueEnvelope {
nonce: string;
encryptedValue: string;
}

export function encryptStorageValue(
storageValue: any,
key: string,
raw?: boolean
): StorageValueEnvelope {
const cryptoKey = genBytesFromBase64(key);
const nonce = getRandomValues(new Uint8Array(24));
const chacha = xchacha20poly1305(cryptoKey, nonce);
const encryptedValue = chacha.encrypt(
raw ? storageValue : new TextEncoder().encode(storageValue)
);
return {
encryptedValue: genBase64FromBytes(new Uint8Array(encryptedValue)),
nonce: genBase64FromBytes(nonce),
};
}

export function decryptStorageValue<T>(
storageValue: StorageValueEnvelope,
key: string,
raw?: boolean
): T {
const { encryptedValue, nonce } = storageValue;
const cryptoKey = genBytesFromBase64(key);
const chacha = xchacha20poly1305(cryptoKey, genBytesFromBase64(nonce));
const decryptedValue = chacha.decrypt(genBytesFromBase64(encryptedValue));
return raw
? (decryptedValue as T)
: (new TextDecoder().decode(decryptedValue) as T);
}

export function encryptStorageKey(storageKey: string, key: string) {
const cryptoKey = genBytesFromBase64(key);
const gcmSiv = siv(cryptoKey, genBytesFromBase64(predefinedSivNonce));
const encryptedKey = gcmSiv.encrypt(
new Uint8Array(new TextEncoder().encode(storageKey))
);
return encryptionPrefix + genBase64FromBytes(encryptedKey, true);
}

export function decryptStorageKey(encryptedKey: string, key: string) {
const cryptoKey = genBytesFromBase64(key);
const gcmSiv = siv(cryptoKey, genBytesFromBase64(predefinedSivNonce));
const decryptedKey = gcmSiv.decrypt(
genBytesFromBase64(encryptedKey.slice(encryptionPrefix.length), true)
);
return new TextDecoder().decode(decryptedKey);
}

// Base64 utilities - Waiting for https://github.com/unjs/knitwork/pull/83 // TODO: Replace with knitwork imports as soon as PR is merged

function genBytesFromBase64(input: string, urlSafe?: boolean) {
if (urlSafe) {
input = input.replace(/-/g, "+").replace(/_/g, "/");
const paddingLength = input.length % 4;
if (paddingLength === 2) {
input += "==";
} else if (paddingLength === 3) {
input += "=";
}
}
return Uint8Array.from(
globalThis.atob(input),
(c) => c.codePointAt(0) as number
);
}

function genBase64FromBytes(input: Uint8Array, urlSafe?: boolean) {
if (urlSafe) {
return globalThis
.btoa(String.fromCodePoint(...input))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
return globalThis.btoa(String.fromCodePoint(...input));
}
39 changes: 39 additions & 0 deletions src/drivers/encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createRequiredError, defineDriver } from "./utils";
import type { Driver } from "../types";
import { encryptedStorage } from "../utils";
import { createStorage } from "../storage";

export interface EncryptedStorageOptions {
/**
* Driver to wrap for encrypted storage.
* @required
*/
driver: Driver;
/**
* Encryption key to use. Must be base64 encoded 32 bytes (256 bit) long key.
* @required
*/
encryptionKey: string;
/**
* Whether to encrypt keys as well. Defaults to false.
* @default false
*/
keyEncryption?: boolean;
}

const DRIVER_NAME = "encryption";

export default defineDriver((opts: EncryptedStorageOptions) => {
if (!opts.encryptionKey) {
throw createRequiredError(DRIVER_NAME, "encryptionKey");
}
return {
name: DRIVER_NAME,
options: opts,
...encryptedStorage(
createStorage({ driver: opts.driver }),
opts.encryptionKey,
opts.keyEncryption
),
};
});
4 changes: 2 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ export interface Storage<T extends StorageValue = StorageValue> {
opts?: TransactionOptions
) => Promise<void>;
/** @experimental */
setItems: (
items: { key: string; value: string; options?: TransactionOptions }[],
setItems: <U extends Partial<StorageValue> = StorageValue>(
items: { key: string; value: U; options?: TransactionOptions }[],
commonOptions?: TransactionOptions
) => Promise<void>;
/** @experimental See https://github.com/unjs/unstorage/issues/142 */
Expand Down
156 changes: 156 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
import destr from "destr";
import {
StorageValueEnvelope,
decryptStorageKey,
decryptStorageValue,
encryptStorageKey,
encryptStorageValue,
stringify,
} from "./_utils";
import type { Storage, StorageValue } from "./types";

type StorageKeys = Array<keyof Storage>;
Expand Down Expand Up @@ -42,7 +51,154 @@ export function prefixStorage<T extends StorageValue>(
return nsStorage;
}

export function encryptedStorage<T extends StorageValue>(
storage: Storage<T>,
encryptionKey: string,
encryptKeys?: boolean
): Storage<T> {
const encStorage: Storage = { ...storage };

encStorage.hasItem = (key, ...args) =>
storage.hasItem(
encryptKeys ? encryptStorageKey(normalizeKey(key), encryptionKey) : key,
...args
);

encStorage.getItem = async (key, ...args) => {
if (encryptKeys) {
key = encryptStorageKey(normalizeKey(key), encryptionKey);
}
const value = await storage.getItem(key, ...args);
return value
? destr(decryptStorageValue(value as StorageValueEnvelope, encryptionKey))
: null;
};

encStorage.getItems = async (items, commonOptions) => {
let encryptedItems;
if (encryptKeys) {
const encryptedKeyItems = items.map((item) => {
const isStringItem = typeof item === "string";
const key = encryptStorageKey(
normalizeKey(isStringItem ? item : item.key),
encryptionKey
);
const options =
isStringItem || !item.options
? commonOptions
: { ...commonOptions, ...item.options };

return { key, options };
});
encryptedItems = await storage.getItems(encryptedKeyItems, commonOptions);
} else {
encryptedItems = await storage.getItems(items, commonOptions);
}
return encryptedItems.map((encryptedItem) => {
const { value, key, ...rest } = encryptedItem;
const decryptedValue = decryptStorageValue(
value as StorageValueEnvelope,
encryptionKey
) as StorageValue;
return {
value: decryptedValue,
key: encryptKeys
? decryptStorageKey(normalizeKey(key), encryptionKey)
: key,
...rest,
};
});
};

encStorage.getItemRaw = async (key, ...args) => {
const value = await storage.getItem(key, ...args);
return value
? decryptStorageValue(value as StorageValueEnvelope, encryptionKey, true)
: null;
};

// eslint-disable-next-line require-await
encStorage.setItem = async (key, value, ...args) => {
if (encryptKeys) {
key = encryptStorageKey(normalizeKey(key), encryptionKey);
}
const encryptedValue = encryptStorageValue(stringify(value), encryptionKey);
return storage.setItem(key, encryptedValue as T, ...args);
};

// eslint-disable-next-line require-await
encStorage.setItems = async (items, ...args) => {
const encryptedItems = items.map((item) => {
const { value, key, ...rest } = item;
const encryptedValue: StorageValueEnvelope = encryptStorageValue(
stringify(value),
encryptionKey
);
return {
value: encryptedValue,
key: encryptKeys
? encryptStorageKey(normalizeKey(key), encryptionKey)
: key,
...rest,
};
});
return storage.setItems<StorageValueEnvelope>(encryptedItems, ...args);
};

// eslint-disable-next-line require-await
encStorage.setItemRaw = async (key, value, ...args) => {
const encryptedValue = encryptStorageValue(value, encryptionKey, true);
return storage.setItem(key, encryptedValue as T, ...args);
};

encStorage.removeItem = (key, ...args) =>
storage.removeItem(
encryptKeys ? encryptStorageKey(normalizeKey(key), encryptionKey) : key,
...args
);

encStorage.setMeta = (key, ...args) =>
storage.setMeta(
encryptKeys ? encryptStorageKey(normalizeKey(key), encryptionKey) : key,
...args
);

encStorage.getMeta = (key, ...args) =>
storage.getMeta(
encryptKeys ? encryptStorageKey(normalizeKey(key), encryptionKey) : key,
...args
);

encStorage.removeMeta = (key, ...args) =>
storage.removeMeta(
encryptKeys ? encryptStorageKey(normalizeKey(key), encryptionKey) : key,
...args
);

encStorage.getKeys = async (base, ...args) => {
if (encryptKeys) {
const keys = await storage.getKeys("", ...args);
const decryptedKeys = keys.map((key) =>
decryptStorageKey(key, encryptionKey)
);
if (base) {
return decryptedKeys.filter(
(key) => key.startsWith(base!) && !key.endsWith("$")
);
}
return decryptedKeys.filter((key) => !key.endsWith("$"));
}
return storage.getKeys(base, ...args);
};

return encStorage;
}

export function normalizeKey(key?: string) {
// Don't normalize encrypted keys
if (key?.startsWith("$enc:")) {
return key;
}
if (!key) {
return "";
}
Expand Down
2 changes: 1 addition & 1 deletion test/drivers/azure-storage-blob.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { describe, beforeAll, afterAll } from "vitest";
import driver from "../../src/drivers/azure-storage-blob";
import { testDriver } from "./utils";
import { BlobServiceClient } from "@azure/storage-blob";
Expand Down
2 changes: 1 addition & 1 deletion test/drivers/azure-storage-table.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { describe, beforeAll, afterAll } from "vitest";
import driver from "../../src/drivers/azure-storage-table";
import { testDriver } from "./utils";
import { TableClient } from "@azure/data-tables";
Expand Down
Loading