Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/vault/src/utils/persistence.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Storage } from "@plasmohq/storage"
import { SecureStorage } from "@plasmohq/storage/secure"
import superjson from "superjson"
import type { StateStorage } from "zustand/middleware"
import { PalladSecureStorage } from "./secure-storage"

superjson.registerCustom<Buffer, number[]>(
{
Expand All @@ -18,7 +18,7 @@ const sessionData = new Storage({
area: "session",
})

const secureStorage = new SecureStorage({
const secureStorage = new PalladSecureStorage({
area: "local",
})

Expand Down
202 changes: 202 additions & 0 deletions packages/vault/src/utils/secure-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**
* PalladSecureStorage: A modified version of SecureStorage from PlasmoHQ.
* Source: https://github.com/PlasmoHQ/storage/blob/02c6aeaf631ef71ac939be13a86f4f68fde84447/src/secure.ts
* Modifications:
* - Added a custom `u8ToBase64` function to enhance encryption compatibility.
* - Removed functions unnecessary for our use case.
* - Some other minor changes
* Original code licensed under MIT; modifications adhere to the same license.
*/

import { BaseStorage } from "@plasmohq/storage"

const { crypto } = globalThis

const u8ToHex = (a: ArrayBufferLike) =>
Array.from(new Uint8Array(a), (v) => v.toString(16).padStart(2, "0")).join("")

const u8ToBase64 = (a: ArrayBuffer | Uint8Array) => {
const chunkSize = 10000
const uint8Array = a instanceof ArrayBuffer ? new Uint8Array(a) : a
let str = ""
for (let i = 0; i < uint8Array.byteLength; i += chunkSize) {
str += String.fromCharCode.apply(null, uint8Array.slice(i, i + chunkSize))
}
return globalThis.btoa(str)
}

const base64ToU8 = (base64: string) =>
Uint8Array.from(globalThis.atob(base64), (c) => c.charCodeAt(0))

const DEFAULT_ITERATIONS = 147_000
const DEFAULT_SALT_SIZE = 16
const DEFAULT_IV_SIZE = 32
const DEFAULT_NS_SIZE = 8

export const DEFAULT_NS_SEPARATOR = "|:|"

/**
* ALPHA API: This API is still in development and may change at any time.
*/
export class PalladSecureStorage extends BaseStorage {
encoder = new TextEncoder()
decoder = new TextDecoder()

keyFx = "PBKDF2"
hashAlgo = "SHA-256"
cipherMode = "AES-GCM"
cipherSize = 256

iterations: number
saltSize: number
ivSize: number

get prefixSize() {
return this.saltSize + this.ivSize
}

private passwordKeyVar: CryptoKey
private get passwordKey() {
if (!this.passwordKeyVar) {
throw new Error("Password not set, please first call setPassword.")
}
return this.passwordKeyVar
}

setPassword = async (
password: string,
{
iterations = DEFAULT_ITERATIONS,
saltSize = DEFAULT_SALT_SIZE,
ivSize = DEFAULT_IV_SIZE,
namespace = "",
nsSize = DEFAULT_NS_SIZE,
nsSeparator = DEFAULT_NS_SEPARATOR,
} = {},
) => {
this.iterations = iterations
this.saltSize = saltSize
this.ivSize = ivSize

const passwordBuffer = this.encoder.encode(password)
this.passwordKeyVar = await crypto.subtle.importKey(
"raw",
passwordBuffer,
{ name: this.keyFx },
false, // Not exportable
["deriveKey"],
)

if (!namespace) {
const hashBuffer = await crypto.subtle.digest(
this.hashAlgo,
passwordBuffer,
)

this.keyNamespace = `${u8ToHex(hashBuffer).slice(-nsSize)}${nsSeparator}`
} else {
this.keyNamespace = `${namespace}${nsSeparator}`
}
}

/**
*
* @param boxBase64 A box contains salt, iv and encrypted data
* @returns decrypted data
*/
decrypt = async (boxBase64: string) => {
const passKey = this.passwordKey
const boxBuffer = base64ToU8(boxBase64)

const salt = boxBuffer.slice(0, this.saltSize)
const iv = boxBuffer.slice(this.saltSize, this.prefixSize)
const encryptedDataBuffer = boxBuffer.slice(this.prefixSize)
const aesKey = await this.deriveKey(salt, passKey, ["decrypt"])

const decryptedDataBuffer = await crypto.subtle.decrypt(
{
name: this.cipherMode,
iv,
},
aesKey,
encryptedDataBuffer,
)
return this.decoder.decode(decryptedDataBuffer)
}

encrypt = async (rawData: string) => {
const passKey = this.passwordKey
const salt = crypto.getRandomValues(new Uint8Array(this.saltSize))
const iv = crypto.getRandomValues(new Uint8Array(this.ivSize))
const aesKey = await this.deriveKey(salt, passKey, ["encrypt"])

const encryptedDataBuffer = new Uint8Array(
await crypto.subtle.encrypt(
{
name: this.cipherMode,
iv,
},
aesKey,
this.encoder.encode(rawData),
),
)

const boxBuffer = new Uint8Array(
this.prefixSize + encryptedDataBuffer.byteLength,
)

boxBuffer.set(salt, 0)
boxBuffer.set(iv, this.saltSize)
boxBuffer.set(encryptedDataBuffer, this.prefixSize)

const boxBase64 = u8ToBase64(boxBuffer)
return boxBase64
}

get = async <T = string>(key: string) => {
const nsKey = this.getNamespacedKey(key)
const boxBase64 = await this.rawGet(nsKey)
return this.parseValue<T>(boxBase64)
}

set = async (key: string, rawValue: any) => {
const nsKey = this.getNamespacedKey(key)
const value = this.serde.serializer(rawValue)
const boxBase64 = await this.encrypt(value)
return await this.rawSet(nsKey, boxBase64)
}

remove = async (key: string) => {
const nsKey = this.getNamespacedKey(key)
return await this.rawRemove(nsKey)
}

protected parseValue = async <T>(boxBase64: string | null | undefined) => {
if (boxBase64 !== undefined && boxBase64 !== null) {
const rawValue = await this.decrypt(boxBase64)
return this.serde.deserializer<T>(rawValue)
}
return undefined
}

deriveKey = (
salt: Uint8Array,
passwordKey: CryptoKey,
keyUsage: KeyUsage[],
) =>
crypto.subtle.deriveKey(
{
name: this.keyFx,
hash: this.hashAlgo,
salt,
iterations: this.iterations,
},
passwordKey,
{
name: this.cipherMode,
length: this.cipherSize,
},
false,
keyUsage,
)
}