refactor: consolidate API-key crypto into src/lib/crypto with compat tests
Encrypt (Options) and decrypt (background) now share one module; wire format unchanged. Tests include fixtures proving values encrypted by the old inline Options code still decrypt, plus tamper/wrong-key cases. The module documents the honest threat model (key co-located with ciphertext = obfuscation, not encryption). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
71
src/lib/crypto.ts
Normal file
71
src/lib/crypto.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// API-key at-rest obfuscation, shared by Options (encrypt) and the background
|
||||
// worker (decrypt). Consolidates the previously split implementations; the
|
||||
// wire format is unchanged so keys stored by older builds still decrypt:
|
||||
//
|
||||
// encKey = base64(32-byte secretbox key)
|
||||
// apiKeyEnc = base64(24-byte nonce || secretbox ciphertext)
|
||||
//
|
||||
// THREAT MODEL (be honest about it): the secretbox key lives in the same
|
||||
// chrome.storage.local as the ciphertext, so this protects against casual
|
||||
// inspection only — anyone who can read the extension's storage (profile
|
||||
// disk access, another privileged process) can also read the key and
|
||||
// decrypt. Without a backend or a user passphrase there is no stronger
|
||||
// at-rest story for a browser extension; UI copy must not over-promise.
|
||||
|
||||
import nacl from 'tweetnacl';
|
||||
|
||||
const NONCE_LENGTH = 24;
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
export function base64ToBytes(b64: string): Uint8Array {
|
||||
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
export function generateEncKey(): Uint8Array {
|
||||
return nacl.randomBytes(32);
|
||||
}
|
||||
|
||||
// Returns the stored secretbox key, creating and persisting one on first use.
|
||||
export async function getOrCreateEncKey(): Promise<Uint8Array> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get(['encKey'], (result) => {
|
||||
if (result.encKey) {
|
||||
resolve(base64ToBytes(result.encKey as string));
|
||||
} else {
|
||||
const key = generateEncKey();
|
||||
chrome.storage.local.set({ encKey: bytesToBase64(key) });
|
||||
resolve(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Encrypts a plaintext API key -> base64(nonce || ciphertext).
|
||||
export function encryptApiKey(plaintext: string, key: Uint8Array): string {
|
||||
const nonce = nacl.randomBytes(NONCE_LENGTH);
|
||||
// Uint8Array.from re-wraps in the current realm — TextEncoder can hand back a
|
||||
// foreign-realm array (e.g. under jsdom) that fails tweetnacl's instanceof check.
|
||||
const encoded = Uint8Array.from(new TextEncoder().encode(plaintext));
|
||||
const encrypted = nacl.secretbox(encoded, nonce, key);
|
||||
const combined = new Uint8Array(nonce.length + encrypted.length);
|
||||
combined.set(nonce);
|
||||
combined.set(encrypted, nonce.length);
|
||||
return bytesToBase64(combined);
|
||||
}
|
||||
|
||||
// Decrypts base64(nonce || ciphertext) with the base64 key.
|
||||
// Returns null on any tamper/mismatch (secretbox authentication failure).
|
||||
export function decryptApiKey(encKeyB64: string, apiKeyEncB64: string): string | null {
|
||||
const key = base64ToBytes(encKeyB64);
|
||||
const combined = base64ToBytes(apiKeyEncB64);
|
||||
const nonce = combined.slice(0, NONCE_LENGTH);
|
||||
const cipher = combined.slice(NONCE_LENGTH);
|
||||
const decrypted = nacl.secretbox.open(cipher, nonce, key);
|
||||
if (!decrypted) return null;
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
Reference in New Issue
Block a user