Files
HelixNotes/src/lib/utils/secrets.ts
T
MF bc7c985489 Add encrypted secret blocks (#111)
Adds /secret markdown blocks that store passphrase-protected secrets as helix-secret fences and unlock in the editor. Uses WebCrypto PBKDF2-HMAC-SHA256 with 600k iterations plus AES-256-GCM with salt, nonce, and AAD-bound metadata because it is dependency-free, portable to simple JS/Python decryptors, and supported by browser/webview WebCrypto. Tradeoff: PBKDF2 is not memory-hard like Argon2id/scrypt, so protection depends on strong passphrases and the high iteration count.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/111
2026-06-15 18:47:42 +02:00

151 lines
4.3 KiB
TypeScript

const SECRET_VERSION = 2;
const SECRET_CIPHER = 'AES-256-GCM';
const SECRET_KDF = 'PBKDF2-HMAC-SHA256';
const SECRET_ITERATIONS = 600_000;
const SALT_BYTES = 16;
const NONCE_BYTES = 12;
interface SecretEnvelope {
v: number;
title?: string;
cipher: string;
kdf: string;
iterations: number;
salt: string;
nonce: string;
ct: string;
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function base64ToBytes(value: string, field: string): Uint8Array {
const text = value.trim();
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) {
throw new Error(`Invalid secret ${field}`);
}
const binary = atob(text);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function stableBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy;
}
function authenticatedMetadata(envelope: SecretEnvelope): string {
return JSON.stringify({
v: envelope.v,
title: envelope.title ?? 'Encrypted secret',
cipher: envelope.cipher,
kdf: envelope.kdf,
iterations: envelope.iterations,
salt: envelope.salt,
nonce: envelope.nonce,
});
}
function envelopeAad(envelope: SecretEnvelope): Uint8Array {
return encoder.encode(authenticatedMetadata(envelope));
}
function parseEnvelope(payload: string): SecretEnvelope {
let envelope: SecretEnvelope;
try {
envelope = JSON.parse(payload);
} catch {
throw new Error('Invalid secret payload');
}
if (
envelope?.v !== SECRET_VERSION ||
envelope.cipher !== SECRET_CIPHER ||
envelope.kdf !== SECRET_KDF ||
!Number.isInteger(envelope.iterations) ||
envelope.iterations < 100_000 ||
envelope.iterations > 5_000_000
) {
throw new Error('Unsupported secret payload');
}
return envelope;
}
export function readSecretTitle(payload: string): string {
try {
const parsed = JSON.parse(payload);
return typeof parsed?.title === 'string' && parsed.title.trim()
? parsed.title.trim()
: 'Encrypted secret';
} catch {
return 'Encrypted secret';
}
}
async function deriveKey(passphrase: string, salt: Uint8Array, iterations: number): Promise<CryptoKey> {
if (!passphrase) throw new Error('Passphrase is required');
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(passphrase),
'PBKDF2',
false,
['deriveKey'],
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt: stableBytes(salt), iterations },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
}
export async function encryptSecretText(plaintext: string, passphrase: string, title = 'Encrypted secret'): Promise<string> {
if (!plaintext) throw new Error('Secret text is required');
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
const key = await deriveKey(passphrase, salt, SECRET_ITERATIONS);
const envelope: SecretEnvelope = {
v: SECRET_VERSION,
title: title.trim() || 'Encrypted secret',
cipher: SECRET_CIPHER,
kdf: SECRET_KDF,
iterations: SECRET_ITERATIONS,
salt: bytesToBase64(salt),
nonce: bytesToBase64(nonce),
ct: '',
};
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: stableBytes(nonce), additionalData: stableBytes(envelopeAad(envelope)) },
key,
encoder.encode(plaintext),
);
envelope.ct = bytesToBase64(new Uint8Array(encrypted));
return JSON.stringify(envelope, null, 2);
}
export async function decryptSecretText(payload: string, passphrase: string): Promise<string> {
try {
const envelope = parseEnvelope(payload);
const salt = base64ToBytes(envelope.salt, 'salt');
const nonce = base64ToBytes(envelope.nonce, 'nonce');
const ciphertext = base64ToBytes(envelope.ct, 'ciphertext');
const key = await deriveKey(passphrase, salt, envelope.iterations);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: stableBytes(nonce), additionalData: stableBytes(envelopeAad(envelope)) },
key,
stableBytes(ciphertext),
);
return decoder.decode(plaintext);
} catch {
throw new Error('Unable to unlock secret. Check the passphrase or payload.');
}
}