Files
archy/aiui/packages/app/src/utils/crypto.ts
T

149 lines
3.7 KiB
TypeScript
Raw Normal View History

2026-08-12 10:55:50 +00:00
/**
* AES-256-GCM encryption utilities using Web Crypto API.
* PBKDF2 key derivation with 100K+ iterations.
*/
const PBKDF2_ITERATIONS = 100_000
const SALT_LENGTH = 16
const IV_LENGTH = 12
export async function generateSalt(): Promise<Uint8Array> {
return crypto.getRandomValues(new Uint8Array(SALT_LENGTH))
}
export async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
const enc = new TextEncoder()
const keyMaterial = await crypto.subtle.importKey(
'raw',
enc.encode(password),
'PBKDF2',
false,
['deriveKey'],
)
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
)
}
export interface EncryptedPayload {
ciphertext: ArrayBuffer
iv: Uint8Array
}
export async function encrypt(data: string, key: CryptoKey): Promise<EncryptedPayload> {
const enc = new TextEncoder()
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
enc.encode(data),
)
return { ciphertext, iv }
}
export async function decrypt(
ciphertext: ArrayBuffer,
iv: Uint8Array,
key: CryptoKey,
): Promise<string> {
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext,
)
return new TextDecoder().decode(plaintext)
}
/**
* Convenience: encrypt a string and return a storable format.
* Returns base64-encoded JSON with iv + ciphertext.
*/
export async function encryptToString(data: string, key: CryptoKey): Promise<string> {
const { ciphertext, iv } = await encrypt(data, key)
const combined = new Uint8Array(iv.length + ciphertext.byteLength)
combined.set(iv)
combined.set(new Uint8Array(ciphertext), iv.length)
return bufferToBase64(combined)
}
/**
* Convenience: decrypt a base64-encoded encrypted string.
*/
export async function decryptFromString(encoded: string, key: CryptoKey): Promise<string> {
const combined = base64ToBuffer(encoded)
const iv = combined.slice(0, IV_LENGTH)
const ciphertext = combined.slice(IV_LENGTH)
return decrypt(ciphertext.buffer, iv, key)
}
function bufferToBase64(buffer: Uint8Array): string {
let binary = ''
for (let i = 0; i < buffer.length; i++) {
binary += String.fromCharCode(buffer[i])
}
return btoa(binary)
}
function base64ToBuffer(base64: string): Uint8Array {
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
}
/**
* Check if encryption should be enabled.
* Disabled in dev mode with VITE_DISABLE_CRYPTO=true, or when
* Web Crypto API is unavailable (HTTP on non-localhost origins).
*/
export function isCryptoEnabled(): boolean {
try {
if (import.meta.env.VITE_DISABLE_CRYPTO === 'true') return false
// crypto.subtle is only available in secure contexts (HTTPS or localhost)
if (typeof globalThis.crypto?.subtle === 'undefined') return false
return true
} catch {
return false
}
}
/**
* Session key management — held in memory only.
*/
let sessionKey: CryptoKey | null = null
let sessionSalt: Uint8Array | null = null
export function setSessionKey(key: CryptoKey, salt: Uint8Array): void {
sessionKey = key
sessionSalt = salt
}
export function getSessionKey(): CryptoKey | null {
return sessionKey
}
export function getSessionSalt(): Uint8Array | null {
return sessionSalt
}
export function clearSessionKey(): void {
sessionKey = null
sessionSalt = null
}
export function hasSessionKey(): boolean {
return sessionKey !== null
}