mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 17:37:29 +02:00
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
151 lines
4.3 KiB
TypeScript
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.');
|
|
}
|
|
}
|