mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-28 00:06:50 +00:00
103 lines
3.5 KiB
TypeScript
103 lines
3.5 KiB
TypeScript
export interface EncryptedPayload {
|
|
format: "dbx-encrypted";
|
|
version: 1;
|
|
salt: string;
|
|
iv: string;
|
|
data: string;
|
|
}
|
|
|
|
export interface PlainConfigPayload {
|
|
format: "dbx-config";
|
|
version: 1;
|
|
connections: unknown[];
|
|
}
|
|
|
|
const PBKDF2_ITERATIONS = 100_000;
|
|
export const CONFIG_CRYPTO_UNAVAILABLE = "crypto_unavailable";
|
|
|
|
function getCrypto(): Crypto {
|
|
const runtimeCrypto = globalThis.crypto;
|
|
if (!runtimeCrypto?.getRandomValues || !runtimeCrypto.subtle) {
|
|
throw new Error(CONFIG_CRYPTO_UNAVAILABLE);
|
|
}
|
|
return runtimeCrypto;
|
|
}
|
|
|
|
function getSubtleCrypto(): SubtleCrypto {
|
|
const subtle = globalThis.crypto?.subtle;
|
|
if (!subtle) throw new Error(CONFIG_CRYPTO_UNAVAILABLE);
|
|
return subtle;
|
|
}
|
|
|
|
async function deriveKey(passphrase: string, salt: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {
|
|
const encoder = new TextEncoder();
|
|
const subtle = getSubtleCrypto();
|
|
const baseKey = await subtle.importKey("raw", encoder.encode(passphrase), "PBKDF2", false, ["deriveKey"]);
|
|
return subtle.deriveKey({ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, baseKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
}
|
|
|
|
function toBase64(data: ArrayBuffer | Uint8Array<ArrayBuffer>): string {
|
|
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
let binary = "";
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
function fromBase64(base64: string): Uint8Array<ArrayBuffer> {
|
|
const binary = atob(base64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
export async function encryptConfig(json: string, passphrase: string): Promise<EncryptedPayload> {
|
|
const runtimeCrypto = getCrypto();
|
|
const salt = runtimeCrypto.getRandomValues(new Uint8Array(16));
|
|
const iv = runtimeCrypto.getRandomValues(new Uint8Array(12));
|
|
const key = await deriveKey(passphrase, salt);
|
|
const encoded = new TextEncoder().encode(json);
|
|
const ciphertext = await runtimeCrypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
|
|
return {
|
|
format: "dbx-encrypted",
|
|
version: 1,
|
|
salt: toBase64(salt),
|
|
iv: toBase64(iv),
|
|
data: toBase64(ciphertext),
|
|
};
|
|
}
|
|
|
|
export async function decryptConfig(payload: EncryptedPayload, passphrase: string): Promise<string> {
|
|
const salt = fromBase64(payload.salt);
|
|
const iv = fromBase64(payload.iv);
|
|
const ciphertext = fromBase64(payload.data);
|
|
let key: CryptoKey;
|
|
try {
|
|
key = await deriveKey(passphrase, salt);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === CONFIG_CRYPTO_UNAVAILABLE) {
|
|
const { isTauriRuntime } = await import("@/lib/tauriRuntime");
|
|
if (!isTauriRuntime(globalThis)) {
|
|
const { decryptConfig: decryptConfigOnBackend } = await import("@/lib/api");
|
|
return decryptConfigOnBackend(payload, passphrase);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
try {
|
|
const plaintext = await getSubtleCrypto().decrypt({ name: "AES-GCM", iv }, key, ciphertext);
|
|
return new TextDecoder().decode(plaintext);
|
|
} catch {
|
|
throw new Error("wrong_passphrase");
|
|
}
|
|
}
|
|
|
|
export function isEncryptedConfig(data: unknown): data is EncryptedPayload {
|
|
if (typeof data !== "object" || data === null) return false;
|
|
const obj = data as Record<string, unknown>;
|
|
return obj.format === "dbx-encrypted" && obj.version === 1 && typeof obj.salt === "string" && typeof obj.iv === "string" && typeof obj.data === "string";
|
|
}
|