Initial commit

This commit is contained in:
2026-04-08 11:55:27 +01:00
commit 470a1c15b8
36 changed files with 4932 additions and 0 deletions

View File

@@ -0,0 +1,182 @@
import type { EncryptionEnvelope } from "@obsidian-sync/sync-protocol";
export interface VaultKeyHandle {
keyId: string;
key: CryptoKey;
}
export interface GeneratedVaultKey extends VaultKeyHandle {
exportedKey: string;
}
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
function getCryptoApi(): Crypto {
if (!globalThis.crypto?.subtle) {
throw new Error("Web Crypto API is unavailable in this environment.");
}
return globalThis.crypto;
}
function bytesToBase64(bytes: Uint8Array): string {
let output = "";
for (let index = 0; index < bytes.length; index += 3) {
const first = bytes[index] ?? 0;
const second = bytes[index + 1] ?? 0;
const third = bytes[index + 2] ?? 0;
const combined = (first << 16) | (second << 8) | third;
output += BASE64_ALPHABET[(combined >> 18) & 63];
output += BASE64_ALPHABET[(combined >> 12) & 63];
output += index + 1 < bytes.length ? BASE64_ALPHABET[(combined >> 6) & 63] : "=";
output += index + 2 < bytes.length ? BASE64_ALPHABET[combined & 63] : "=";
}
return output;
}
function base64ToBytes(value: string): Uint8Array {
const normalized = value.replace(/\s+/g, "");
const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
const outputLength = Math.floor((normalized.length * 3) / 4) - padding;
const bytes = new Uint8Array(outputLength);
let byteIndex = 0;
for (let index = 0; index < normalized.length; index += 4) {
const first = BASE64_ALPHABET.indexOf(normalized[index] ?? "A");
const second = BASE64_ALPHABET.indexOf(normalized[index + 1] ?? "A");
const thirdChar = normalized[index + 2] ?? "=";
const fourthChar = normalized[index + 3] ?? "=";
const third = thirdChar === "=" ? 0 : BASE64_ALPHABET.indexOf(thirdChar);
const fourth = fourthChar === "=" ? 0 : BASE64_ALPHABET.indexOf(fourthChar);
const combined = (first << 18) | (second << 12) | (third << 6) | fourth;
bytes[byteIndex] = (combined >> 16) & 255;
byteIndex += 1;
if (thirdChar !== "=" && byteIndex < outputLength + 1) {
bytes[byteIndex] = (combined >> 8) & 255;
byteIndex += 1;
}
if (fourthChar !== "=" && byteIndex < outputLength + 1) {
bytes[byteIndex] = combined & 255;
byteIndex += 1;
}
}
return bytes;
}
function toOwnedBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
const buffer = new ArrayBuffer(bytes.byteLength);
const view = new Uint8Array(buffer);
view.set(bytes);
return view;
}
function normalizeBytes(value: ArrayBuffer | Uint8Array): Uint8Array<ArrayBuffer> {
return value instanceof Uint8Array ? toOwnedBytes(value) : toOwnedBytes(new Uint8Array(value));
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return toOwnedBytes(bytes).buffer;
}
export async function generateVaultKey(keyId = globalThis.crypto.randomUUID()): Promise<GeneratedVaultKey> {
const cryptoApi = getCryptoApi();
const key = await cryptoApi.subtle.generateKey(
{
name: "AES-GCM",
length: 256
},
true,
["encrypt", "decrypt"]
);
const rawKey = await cryptoApi.subtle.exportKey("raw", key);
return {
keyId,
key,
exportedKey: bytesToBase64(new Uint8Array(rawKey))
};
}
export async function importVaultKey(exportedKey: string, keyId: string): Promise<VaultKeyHandle> {
const cryptoApi = getCryptoApi();
const rawKey = base64ToBytes(exportedKey);
const key = await cryptoApi.subtle.importKey(
"raw",
toArrayBuffer(rawKey),
{
name: "AES-GCM",
length: 256
},
true,
["encrypt", "decrypt"]
);
return {
keyId,
key
};
}
export async function encryptBytes(plaintext: ArrayBuffer | Uint8Array, vaultKey: VaultKeyHandle): Promise<EncryptionEnvelope> {
const cryptoApi = getCryptoApi();
const iv = cryptoApi.getRandomValues(new Uint8Array(12));
const ciphertext = await cryptoApi.subtle.encrypt(
{
name: "AES-GCM",
iv
},
vaultKey.key,
normalizeBytes(plaintext)
);
return {
algorithm: "AES-GCM-256",
keyId: vaultKey.keyId,
iv: bytesToBase64(iv),
ciphertext: bytesToBase64(new Uint8Array(ciphertext))
};
}
export async function decryptBytes(envelope: EncryptionEnvelope, vaultKey: VaultKeyHandle): Promise<ArrayBuffer> {
const cryptoApi = getCryptoApi();
const plaintext = await cryptoApi.subtle.decrypt(
{
name: "AES-GCM",
iv: toOwnedBytes(base64ToBytes(envelope.iv))
},
vaultKey.key,
toOwnedBytes(base64ToBytes(envelope.ciphertext))
);
return plaintext.slice(0);
}
export async function encryptText(plaintext: string, vaultKey: VaultKeyHandle): Promise<EncryptionEnvelope> {
return encryptBytes(new TextEncoder().encode(plaintext), vaultKey);
}
export async function decryptText(envelope: EncryptionEnvelope, vaultKey: VaultKeyHandle): Promise<string> {
return new TextDecoder().decode(await decryptBytes(envelope, vaultKey));
}
async function computeHash(bytes: Uint8Array): Promise<string> {
const cryptoApi = getCryptoApi();
const digest = await cryptoApi.subtle.digest("SHA-256", toOwnedBytes(bytes));
return bytesToBase64(new Uint8Array(digest));
}
export async function computeTextHash(value: string): Promise<string> {
return computeHash(new TextEncoder().encode(value));
}
export async function computeBinaryHash(value: ArrayBuffer | Uint8Array): Promise<string> {
return computeHash(normalizeBytes(value));
}