From b8daab2bcd9e184fbe38e143b676703ed8121c24 Mon Sep 17 00:00:00 2001 From: Nicolas Cantu Date: Sun, 28 Dec 2025 22:38:34 +0100 Subject: [PATCH] Remove initializeCookieCleanup function and migration code --- components/CreateAccountModalComponents.tsx | 9 +- components/CreateAccountModalSteps.tsx | 2 +- components/LanguageSelector.tsx | 27 +- components/UnlockAccountModal.tsx | 141 ++++++- hooks/useI18n.ts | 10 +- hooks/useNotifications.ts | 12 +- lib/articleStorage.ts | 15 +- lib/cookieCleanup.ts | 33 ++ lib/keyManagement.ts | 93 ++-- lib/keyManagementBIP39.ts | 255 +++++++++++ lib/keyManagementEncryption.ts | 115 +---- lib/keyManagementRecovery.ts | 36 -- lib/keyManagementStorage.ts | 27 +- lib/keyManagementTwoLevel.ts | 445 ++++++++++++++++++++ lib/nostrAuth.ts | 35 +- lib/notifications.ts | 20 +- pages/_app.tsx | 29 +- 17 files changed, 999 insertions(+), 305 deletions(-) create mode 100644 lib/cookieCleanup.ts create mode 100644 lib/keyManagementBIP39.ts delete mode 100644 lib/keyManagementRecovery.ts create mode 100644 lib/keyManagementTwoLevel.ts diff --git a/components/CreateAccountModalComponents.tsx b/components/CreateAccountModalComponents.tsx index 2ef7919..c87575f 100644 --- a/components/CreateAccountModalComponents.tsx +++ b/components/CreateAccountModalComponents.tsx @@ -4,9 +4,12 @@ export function RecoveryWarning() {

⚠️ Important

- Ces 4 mots-clés sont votre seule façon de récupérer votre compte. + Ces 4 mots-clés sont votre seule façon de récupérer votre compte. Ils ne seront jamais affichés à nouveau.

+

+ Ces mots-clés (dictionnaire BIP39) sont utilisés avec PBKDF2 pour chiffrer une clé de chiffrement (KEK) stockée dans l'API Credentials du navigateur. Cette KEK chiffre ensuite votre clé privée stockée dans IndexedDB (système à deux niveaux). +

Notez-les dans un endroit sûr. Sans ces mots-clés, vous perdrez définitivement l'accès à votre compte.

@@ -80,6 +83,10 @@ export function ImportKeyForm({ className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-sm" rows={4} /> +

+ Après l'import, vous recevrez 4 mots-clés de récupération (dictionnaire BIP39) pour sécuriser votre compte. + Ces mots-clés chiffrent une clé de chiffrement (KEK) stockée dans l'API Credentials, qui chiffre ensuite votre clé privée. +

{error &&

{error}

} diff --git a/components/CreateAccountModalSteps.tsx b/components/CreateAccountModalSteps.tsx index 7f5606f..7fd08e5 100644 --- a/components/CreateAccountModalSteps.tsx +++ b/components/CreateAccountModalSteps.tsx @@ -23,7 +23,7 @@ export function RecoveryStep({ return (
-

Sauvegardez vos mots-clés de récupération

+

Sauvegardez vos 4 mots-clés de récupération

diff --git a/components/LanguageSelector.tsx b/components/LanguageSelector.tsx index 09912b8..fc8e05f 100644 --- a/components/LanguageSelector.tsx +++ b/components/LanguageSelector.tsx @@ -30,19 +30,30 @@ export function LanguageSelector() { const [currentLocale, setCurrentLocale] = useState(getLocale()) useEffect(() => { - // Load saved locale from localStorage - const savedLocale = typeof window !== 'undefined' ? (localStorage.getItem(LOCALE_STORAGE_KEY) as Locale | null) : null - if (savedLocale && (savedLocale === 'fr' || savedLocale === 'en')) { - setLocale(savedLocale) - setCurrentLocale(savedLocale) + // Load saved locale from IndexedDB + const loadLocale = async () => { + try { + const { storageService } = await import('@/lib/storage/indexedDB') + const savedLocale = await storageService.get(LOCALE_STORAGE_KEY, 'app_storage') + if (savedLocale && (savedLocale === 'fr' || savedLocale === 'en')) { + setLocale(savedLocale) + setCurrentLocale(savedLocale) + } + } catch (e) { + console.error('Error loading locale:', e) + } } + void loadLocale() }, []) - const handleLocaleChange = (locale: Locale) => { + const handleLocaleChange = async (locale: Locale) => { setLocale(locale) setCurrentLocale(locale) - if (typeof window !== 'undefined') { - localStorage.setItem(LOCALE_STORAGE_KEY, locale) + try { + const { storageService } = await import('@/lib/storage/indexedDB') + await storageService.set(LOCALE_STORAGE_KEY, locale, 'app_storage') + } catch (e) { + console.error('Error saving locale:', e) } // Force page reload to update all translations window.location.reload() diff --git a/components/UnlockAccountModal.tsx b/components/UnlockAccountModal.tsx index 76cdd95..2dc4a6b 100644 --- a/components/UnlockAccountModal.tsx +++ b/components/UnlockAccountModal.tsx @@ -1,11 +1,121 @@ -import { useState } from 'react' +import { useState, useRef, useEffect } from 'react' import { nostrAuthService } from '@/lib/nostrAuth' +import { getWordSuggestions } from '@/lib/keyManagementBIP39' interface UnlockAccountModalProps { onSuccess: () => void onClose: () => void } +function WordInputWithAutocomplete({ + index, + value, + onChange, + onFocus, + onBlur, +}: { + index: number + value: string + onChange: (value: string) => void + onFocus: () => void + onBlur: () => void +}) { + const [suggestions, setSuggestions] = useState([]) + const [showSuggestions, setShowSuggestions] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(-1) + const inputRef = useRef(null) + const suggestionsRef = useRef(null) + + useEffect(() => { + if (value.length > 0) { + const newSuggestions = getWordSuggestions(value, 5) + setSuggestions(newSuggestions) + setShowSuggestions(newSuggestions.length > 0) + setSelectedIndex(-1) + } else { + setSuggestions([]) + setShowSuggestions(false) + } + }, [value]) + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value.trim().toLowerCase() + onChange(newValue) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : prev)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1)) + } else if (e.key === 'Enter' && selectedIndex >= 0 && suggestions[selectedIndex]) { + e.preventDefault() + onChange(suggestions[selectedIndex] ?? '') + setShowSuggestions(false) + inputRef.current?.blur() + } else if (e.key === 'Escape') { + setShowSuggestions(false) + inputRef.current?.blur() + } + } + + const handleSuggestionClick = (suggestion: string) => { + onChange(suggestion) + setShowSuggestions(false) + inputRef.current?.blur() + } + + return ( +
+ + { + // Delay to allow click on suggestion + setTimeout(() => { + setShowSuggestions(false) + onBlur() + }, 200) + }} + className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-lg text-center" + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + spellCheck="false" + /> + {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion, idx) => ( + + ))} +
+ )} +
+ ) +} + function WordInputs({ words, onWordChange, @@ -13,25 +123,19 @@ function WordInputs({ words: string[] onWordChange: (index: number, value: string) => void }) { + const [, setFocusedIndex] = useState(null) + return (
{words.map((word, index) => ( -
- - onWordChange(index, e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-lg text-center" - autoComplete="off" - autoCapitalize="off" - autoCorrect="off" - spellCheck="false" - /> -
+ onWordChange(index, value)} + onFocus={() => setFocusedIndex(index)} + onBlur={() => setFocusedIndex(null)} + /> ))}
) @@ -148,7 +252,8 @@ export function UnlockAccountModal({ onSuccess, onClose }: UnlockAccountModalPro

Déverrouiller votre compte

- Entrez vos 4 mots-clés de récupération pour déverrouiller votre compte. + Entrez vos 4 mots-clés de récupération (dictionnaire BIP39) pour déverrouiller votre compte. + Ces mots déchiffrent la clé de chiffrement (KEK) stockée dans l'API Credentials, qui déchiffre ensuite votre clé privée.

{error &&

{error}

} diff --git a/hooks/useI18n.ts b/hooks/useI18n.ts index 7073332..9f7cb4a 100644 --- a/hooks/useI18n.ts +++ b/hooks/useI18n.ts @@ -8,8 +8,14 @@ export function useI18n(locale: Locale = 'fr') { useEffect(() => { const load = async () => { try { - // Get saved locale from localStorage or use provided locale - const savedLocale = typeof window !== 'undefined' ? (localStorage.getItem('zapwall-locale') as Locale | null) : null + // Get saved locale from IndexedDB or use provided locale + let savedLocale: Locale | null = null + try { + const { storageService } = await import('@/lib/storage/indexedDB') + savedLocale = await storageService.get('zapwall-locale', 'app_storage') + } catch { + // Fallback to provided locale + } const initialLocale = savedLocale && (savedLocale === 'fr' || savedLocale === 'en') ? savedLocale : locale // Load translations from files in public directory diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts index 5c4f427..264f78e 100644 --- a/hooks/useNotifications.ts +++ b/hooks/useNotifications.ts @@ -14,9 +14,11 @@ export function useNotifications(userPubkey: string | null) { return } - const stored = loadStoredNotifications(userPubkey) - setNotifications(stored) - setLoading(false) + const loadStored = async () => { + const storedNotifications = await loadStoredNotifications(userPubkey) + setNotifications(storedNotifications) + } + void loadStored() }, [userPubkey]) // Subscribe to new notifications @@ -38,8 +40,8 @@ export function useNotifications(userPubkey: string | null) { // Keep only last 100 notifications const trimmed = updated.slice(0, 100) - // Save to localStorage - saveNotifications(userPubkey, trimmed) + // Save to IndexedDB + void saveNotifications(userPubkey, trimmed) return trimmed }) diff --git a/lib/articleStorage.ts b/lib/articleStorage.ts index 2248080..6845bc9 100644 --- a/lib/articleStorage.ts +++ b/lib/articleStorage.ts @@ -20,11 +20,12 @@ interface StoredArticleData { const DEFAULT_EXPIRATION = 30 * 24 * 60 * 60 * 1000 const MASTER_KEY_STORAGE_KEY = 'article_storage_master_key' -function getOrCreateMasterKey(): string { +async function getOrCreateMasterKey(): Promise { if (typeof window === 'undefined') { throw new Error('Storage encryption requires browser environment') } - const existing = localStorage.getItem(MASTER_KEY_STORAGE_KEY) + const { storageService } = await import('./storage/indexedDB') + const existing = await storageService.get(MASTER_KEY_STORAGE_KEY, 'article_storage') if (existing) { return existing } @@ -34,12 +35,12 @@ function getOrCreateMasterKey(): string { binary += String.fromCharCode(b) }) const key = btoa(binary) - localStorage.setItem(MASTER_KEY_STORAGE_KEY, key) + await storageService.set(MASTER_KEY_STORAGE_KEY, key, 'article_storage') return key } -function deriveSecret(articleId: string): string { - const masterKey = getOrCreateMasterKey() +async function deriveSecret(articleId: string): Promise { + const masterKey = await getOrCreateMasterKey() return `${masterKey}:${articleId}` } @@ -60,7 +61,7 @@ export async function storePrivateContent( ): Promise { try { const key = `article_private_content_${articleId}` - const secret = deriveSecret(articleId) + const secret = await deriveSecret(articleId) const data: StoredArticleData = { content, authorPubkey, @@ -98,7 +99,7 @@ export async function getStoredPrivateContent(articleId: string): Promise<{ } | null> { try { const key = `article_private_content_${articleId}` - const secret = deriveSecret(articleId) + const secret = await deriveSecret(articleId) const data = await storageService.get(key, secret) if (!data) { diff --git a/lib/cookieCleanup.ts b/lib/cookieCleanup.ts new file mode 100644 index 0000000..2d7a215 --- /dev/null +++ b/lib/cookieCleanup.ts @@ -0,0 +1,33 @@ +/** + * Cookie cleanup utilities + * Removes all cookies from the domain + */ + +/** + * Delete all cookies for the current domain + */ +export function deleteAllCookies(): void { + if (typeof document === 'undefined') { + return + } + + const cookies = document.cookie.split(';') + + for (let i = 0; i < cookies.length; i += 1) { + const cookie = cookies[i] + if (!cookie) { + continue + } + const eqPos = cookie.indexOf('=') + const name = eqPos > -1 ? cookie.substring(0, eqPos).trim() : cookie.trim() + + if (!name) { + continue + } + + // Delete cookie by setting it to expire in the past + document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/` + document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=${window.location.hostname}` + document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=.${window.location.hostname}` + } +} diff --git a/lib/keyManagement.ts b/lib/keyManagement.ts index d0083d6..b471dee 100644 --- a/lib/keyManagement.ts +++ b/lib/keyManagement.ts @@ -1,17 +1,12 @@ import { nip19, getPublicKey, generateSecretKey } from 'nostr-tools' import { bytesToHex, hexToBytes } from 'nostr-tools/utils' -import { generateRecoveryPhrase } from './keyManagementRecovery' -import { deriveKeyFromPhrase, encryptNsec, decryptNsec } from './keyManagementEncryption' import { - storeAccountFlag, - hasAccountFlag, - removeAccountFlag, - getEncryptedKey, - setEncryptedKey, - getPublicKeys as getStoredPublicKeys, - setPublicKeys, - deleteStoredKeys, -} from './keyManagementStorage' + createAccountTwoLevel, + unlockAccountTwoLevel, + accountExistsTwoLevel, + getPublicKeysTwoLevel, + deleteAccountTwoLevel, +} from './keyManagementTwoLevel' /** * Key management service @@ -66,8 +61,9 @@ export class KeyManagementService { } /** - * Create a new account: generate/import key, encrypt it, and store it + * Create a new account: generate/import key, encrypt it with two-level encryption * Returns the recovery phrase and npub + * Uses two-level encryption: KEK encrypted with recovery phrase, private key encrypted with KEK */ async createAccount(privateKey?: string): Promise<{ recoveryPhrase: string[] @@ -77,92 +73,53 @@ export class KeyManagementService { // Generate or import key pair const keyPair = privateKey ? this.importPrivateKey(privateKey) : this.generateKeyPair() - // Generate recovery phrase - const recoveryPhrase = generateRecoveryPhrase() + // Use two-level encryption system + const result = await createAccountTwoLevel( + keyPair.privateKey, + (secretKey: Uint8Array) => getPublicKey(secretKey), + (publicKey: string) => nip19.npubEncode(publicKey) + ) - // Derive encryption key from recovery phrase - const derivedKey = await deriveKeyFromPhrase(recoveryPhrase) - - // Encrypt the private key - const encryptedNsec = await encryptNsec(derivedKey, keyPair.privateKey) - - // Store encrypted nsec in IndexedDB - await setEncryptedKey(encryptedNsec) - - // Store account flag in browser storage - storeAccountFlag() - - // Store public key separately for quick access - await setPublicKeys(keyPair.publicKey, keyPair.npub) - - return { - recoveryPhrase, - npub: keyPair.npub, - publicKey: keyPair.publicKey, - } + return result } /** * Check if an account exists (encrypted key is stored) */ async accountExists(): Promise { - try { - // Check both the flag and the actual stored key - if (!hasAccountFlag()) { - return false - } - const encrypted = await getEncryptedKey() - return encrypted !== null - } catch { - return false - } + return await accountExistsTwoLevel() } /** * Get the public key and npub if account exists */ async getPublicKeys(): Promise<{ publicKey: string; npub: string } | null> { - return await getStoredPublicKeys() + return await getPublicKeysTwoLevel() } /** * Decrypt and retrieve the private key using recovery phrase + * Uses two-level decryption: decrypt KEK with recovery phrase, then decrypt private key with KEK */ async unlockAccount(recoveryPhrase: string[]): Promise<{ privateKey: string publicKey: string npub: string }> { - // Get encrypted nsec from IndexedDB - const encryptedNsec = await getEncryptedKey() - if (!encryptedNsec) { - throw new Error('No encrypted key found. Please create an account first.') - } + const result = await unlockAccountTwoLevel( + recoveryPhrase, + (secretKey: Uint8Array) => getPublicKey(secretKey), + (publicKey: string) => nip19.npubEncode(publicKey) + ) - // Derive key from recovery phrase - const derivedKey = await deriveKeyFromPhrase(recoveryPhrase) - - // Decrypt the private key - const privateKeyHex = await decryptNsec(derivedKey, encryptedNsec) - - // Verify by computing public key - const secretKey = hexToBytes(privateKeyHex) - const publicKeyHex = getPublicKey(secretKey) - const npub = nip19.npubEncode(publicKeyHex) - - return { - privateKey: privateKeyHex, - publicKey: publicKeyHex, - npub, - } + return result } /** * Delete the account (remove all stored keys) */ async deleteAccount(): Promise { - await deleteStoredKeys() - removeAccountFlag() + await deleteAccountTwoLevel() } } diff --git a/lib/keyManagementBIP39.ts b/lib/keyManagementBIP39.ts new file mode 100644 index 0000000..418ca62 --- /dev/null +++ b/lib/keyManagementBIP39.ts @@ -0,0 +1,255 @@ +/** + * BIP39 word list (2048 words) + * Used for generating 4-word recovery phrases + */ +export const BIP39_WORDLIST = [ + 'abandon', 'ability', 'able', 'about', 'above', 'absent', 'absorb', 'abstract', 'absurd', 'abuse', + 'access', 'accident', 'account', 'accuse', 'achieve', 'acid', 'acoustic', 'acquire', 'across', 'act', + 'action', 'actor', 'actual', 'adapt', 'add', 'addict', 'address', 'adjust', 'admit', 'adult', + 'advance', 'advice', 'aerobic', 'affair', 'afford', 'afraid', 'again', 'age', 'agent', 'agree', + 'ahead', 'aim', 'air', 'airport', 'aisle', 'alarm', 'album', 'alcohol', 'alert', 'alien', + 'all', 'alley', 'allow', 'almost', 'alone', 'alpha', 'already', 'also', 'alter', 'always', + 'amateur', 'amazing', 'among', 'amount', 'amused', 'analyst', 'anchor', 'ancient', 'anger', 'angle', + 'angry', 'animal', 'ankle', 'announce', 'annual', 'another', 'answer', 'antenna', 'antique', 'anxiety', + 'any', 'apart', 'apology', 'appear', 'apple', 'approve', 'april', 'area', 'arena', 'argue', + 'arm', 'armed', 'armor', 'army', 'around', 'arrange', 'arrest', 'arrive', 'arrow', 'art', + 'article', 'artist', 'artwork', 'ask', 'aspect', 'assault', 'asset', 'assist', 'assume', 'asthma', + 'athlete', 'atom', 'attack', 'attend', 'attitude', 'attract', 'auction', 'audit', 'august', 'aunt', + 'author', 'auto', 'autumn', 'average', 'avocado', 'avoid', 'awake', 'aware', 'away', 'awesome', + 'awful', 'awkward', 'axis', 'baby', 'bachelor', 'bacon', 'badge', 'bag', 'balance', 'balcony', + 'ball', 'bamboo', 'banana', 'banner', 'bar', 'barely', 'bargain', 'barrel', 'base', 'basic', + 'basket', 'battle', 'beach', 'bean', 'beauty', 'because', 'become', 'beef', 'before', 'begin', + 'behave', 'behind', 'believe', 'below', 'belt', 'bench', 'benefit', 'best', 'betray', 'better', + 'between', 'beyond', 'bicycle', 'bid', 'bike', 'bind', 'biology', 'bird', 'birth', 'bitter', + 'black', 'blade', 'blame', 'blanket', 'blast', 'bleak', 'bless', 'blind', 'blood', 'blossom', + 'blow', 'blue', 'blur', 'blush', 'board', 'boat', 'body', 'boil', 'bomb', 'bone', + 'bonus', 'book', 'boost', 'border', 'boring', 'borrow', 'boss', 'bottom', 'bounce', 'box', + 'boy', 'bracket', 'brain', 'brand', 'brass', 'brave', 'bread', 'breeze', 'brick', 'bridge', + 'brief', 'bright', 'bring', 'brisk', 'broccoli', 'broken', 'bronze', 'broom', 'brother', 'brown', + 'brush', 'bubble', 'buddy', 'budget', 'buffalo', 'build', 'bulb', 'bulk', 'bullet', 'bundle', + 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'busy', 'butter', 'buyer', 'buzz', + 'cabbage', 'cabin', 'cable', 'cactus', 'cage', 'cake', 'call', 'calm', 'camera', 'camp', + 'can', 'canal', 'cancel', 'candy', 'cannon', 'canoe', 'canvas', 'canyon', 'capable', 'capital', + 'captain', 'car', 'carbon', 'card', 'care', 'career', 'careful', 'careless', 'cargo', 'carpet', + 'carry', 'cart', 'case', 'cash', 'casino', 'cast', 'casual', 'cat', 'catalog', 'catch', + 'category', 'cattle', 'caught', 'cause', 'caution', 'cave', 'ceiling', 'celery', 'cement', 'census', + 'century', 'cereal', 'certain', 'chair', 'chalk', 'champion', 'change', 'chaos', 'chapter', 'charge', + 'chase', 'chat', 'cheap', 'check', 'cheese', 'chef', 'cherry', 'chest', 'chicken', 'chief', + 'child', 'chimney', 'choice', 'choose', 'chronic', 'chuckle', 'chunk', 'churn', 'cigar', 'cinnamon', + 'circle', 'citizen', 'city', 'civil', 'claim', 'clamp', 'clarify', 'claw', 'clay', 'clean', + 'clerk', 'clever', 'click', 'client', 'cliff', 'climb', 'clinic', 'clip', 'clock', 'clog', + 'close', 'cloth', 'cloud', 'clown', 'club', 'clump', 'cluster', 'clutch', 'coach', 'coast', + 'coconut', 'code', 'coffee', 'coil', 'coin', 'collect', 'color', 'column', 'combine', 'come', + 'comfort', 'comic', 'common', 'company', 'concert', 'conduct', 'confirm', 'congress', 'connect', 'consider', + 'control', 'convince', 'cook', 'cool', 'copper', 'copy', 'coral', 'core', 'corn', 'correct', + 'cost', 'cotton', 'couch', 'country', 'couple', 'course', 'cousin', 'cover', 'coyote', 'crack', + 'cradle', 'craft', 'cram', 'crane', 'crash', 'crater', 'crawl', 'crazy', 'cream', 'credit', + 'creek', 'crew', 'cricket', 'crime', 'crisp', 'critic', 'crop', 'cross', 'crouch', 'crowd', + 'crucial', 'cruel', 'cruise', 'crumble', 'crunch', 'crush', 'cry', 'crystal', 'cube', 'culture', + 'cup', 'cupboard', 'curious', 'current', 'curtain', 'curve', 'cushion', 'custom', 'cute', 'cycle', + 'dad', 'damage', 'damp', 'dance', 'danger', 'daring', 'dark', 'dash', 'daughter', 'dawn', + 'day', 'deal', 'debate', 'debris', 'decade', 'december', 'decide', 'decline', 'decorate', 'decrease', + 'deer', 'defense', 'define', 'defy', 'degree', 'delay', 'deliver', 'demand', 'demise', 'denial', + 'dentist', 'deny', 'depart', 'depend', 'deposit', 'depth', 'deputy', 'derive', 'describe', 'desert', + 'design', 'desk', 'despair', 'destroy', 'detail', 'detect', 'develop', 'device', 'devote', 'diagram', + 'dial', 'diamond', 'diary', 'dice', 'diesel', 'diet', 'differ', 'digital', 'dignity', 'dilemma', + 'dinner', 'dinosaur', 'direct', 'dirt', 'disagree', 'discover', 'disease', 'dish', 'dismiss', 'disorder', + 'display', 'distance', 'divert', 'divide', 'divorce', 'dizzy', 'doctor', 'document', 'dog', 'doll', + 'dolphin', 'domain', 'donate', 'donkey', 'donor', 'door', 'dose', 'double', 'dove', 'draft', + 'dragon', 'drama', 'drastic', 'draw', 'dream', 'dress', 'drift', 'drill', 'drink', 'drip', + 'drive', 'drop', 'drum', 'dry', 'duck', 'dumb', 'dune', 'during', 'dust', 'dutch', + 'duty', 'dwarf', 'dynamic', 'eager', 'eagle', 'early', 'earn', 'earth', 'easily', 'east', + 'easy', 'eat', 'echo', 'ecology', 'economy', 'edge', 'edit', 'educate', 'effort', 'egg', + 'eight', 'either', 'elbow', 'elder', 'electric', 'elegant', 'element', 'elephant', 'elevator', 'elite', + 'else', 'embark', 'embody', 'embrace', 'emerge', 'emotion', 'employ', 'empower', 'empty', 'enable', + 'enact', 'end', 'endless', 'endorse', 'enemy', 'energy', 'enforce', 'engage', 'engine', 'enhance', + 'enjoy', 'enlist', 'enough', 'enrich', 'enroll', 'ensure', 'enter', 'entire', 'entry', 'envelope', + 'episode', 'equal', 'equip', 'era', 'erase', 'erode', 'erosion', 'error', 'erupt', 'escape', + 'essay', 'essence', 'estate', 'eternal', 'ethics', 'evidence', 'evil', 'evoke', 'evolve', 'exact', + 'example', 'exceed', 'excellent', 'except', 'exchange', 'excite', 'exclude', 'excuse', 'execute', 'exercise', + 'exhaust', 'exhibit', 'exile', 'exist', 'exit', 'exotic', 'expand', 'expect', 'expire', 'explain', + 'expose', 'express', 'extend', 'extra', 'eye', 'eyebrow', 'fabric', 'face', 'faculty', 'fade', + 'faint', 'faith', 'fall', 'false', 'fame', 'family', 'famous', 'fan', 'fancy', 'fantasy', + 'farm', 'fashion', 'fat', 'fatal', 'father', 'fatigue', 'fault', 'favorite', 'feature', 'february', + 'federal', 'fee', 'feed', 'feel', 'female', 'fence', 'festival', 'fetch', 'fever', 'few', + 'fiber', 'fiction', 'field', 'fierce', 'fifteen', 'fifth', 'fifty', 'fight', 'figure', 'file', + 'film', 'filter', 'final', 'find', 'fine', 'finger', 'finish', 'fire', 'firm', 'first', + 'fiscal', 'fish', 'fit', 'fitness', 'fix', 'flag', 'flame', 'flash', 'flat', 'flavor', + 'flee', 'flight', 'flip', 'float', 'flock', 'floor', 'flower', 'fluid', 'flush', 'fly', + 'foam', 'focus', 'fog', 'foil', 'fold', 'follow', 'food', 'foot', 'force', 'foreign', + 'forest', 'forget', 'fork', 'fortune', 'forum', 'forward', 'fossil', 'foster', 'found', 'fox', + 'fragile', 'frame', 'frequent', 'fresh', 'friend', 'fringe', 'frog', 'front', 'frost', 'frown', + 'frozen', 'fruit', 'fuel', 'fun', 'funny', 'furnace', 'fury', 'future', 'gadget', 'gain', + 'galaxy', 'gallery', 'game', 'gap', 'garage', 'garbage', 'garden', 'garlic', 'garment', 'gas', + 'gasp', 'gate', 'gather', 'gauge', 'gaze', 'general', 'genius', 'genre', 'gentle', 'genuine', + 'gesture', 'ghost', 'giant', 'gift', 'giggle', 'ginger', 'giraffe', 'girl', 'give', 'glad', + 'glance', 'glare', 'glass', 'glide', 'glimpse', 'globe', 'gloom', 'glory', 'glove', 'glow', + 'glue', 'goat', 'goddess', 'gold', 'good', 'goose', 'gorilla', 'gospel', 'gossip', 'govern', + 'gown', 'grab', 'grace', 'grain', 'grant', 'grape', 'grass', 'gravity', 'great', 'green', + 'grid', 'grief', 'grit', 'grocery', 'group', 'grow', 'grunt', 'guard', 'guess', 'guide', + 'guilt', 'guitar', 'gun', 'gym', 'habit', 'hair', 'half', 'hammer', 'hamster', 'hand', + 'happy', 'harbor', 'hard', 'harsh', 'harvest', 'hat', 'have', 'hawk', 'hazard', 'head', + 'health', 'heart', 'heavy', 'hedgehog', 'height', 'hello', 'helmet', 'help', 'hen', 'hero', + 'hidden', 'high', 'hill', 'hint', 'hip', 'hire', 'history', 'hobby', 'hockey', 'hold', + 'hole', 'holiday', 'hollow', 'home', 'honey', 'hood', 'hope', 'horn', 'horror', 'horse', + 'hospital', 'host', 'hotel', 'hour', 'hover', 'hub', 'huge', 'human', 'humble', 'humor', + 'hundred', 'hungry', 'hunt', 'hurdle', 'hurry', 'hurt', 'husband', 'hybrid', 'ice', 'icon', + 'idea', 'identify', 'idle', 'ignore', 'ill', 'illegal', 'illness', 'image', 'imitate', 'immense', + 'immune', 'impact', 'impose', 'improve', 'impulse', 'inch', 'include', 'income', 'increase', 'index', + 'indicate', 'indoor', 'industry', 'infant', 'inflict', 'inform', 'inhale', 'inherit', 'initial', 'inject', + 'injury', 'inmate', 'inner', 'innocent', 'input', 'inquiry', 'insane', 'insect', 'inside', 'inspire', + 'install', 'intact', 'interest', 'into', 'invest', 'invite', 'involve', 'iron', 'island', 'isolate', + 'issue', 'item', 'ivory', 'jacket', 'jaguar', 'jar', 'jazz', 'jealous', 'jeans', 'jelly', + 'jewel', 'job', 'join', 'joke', 'journey', 'joy', 'judge', 'juice', 'jump', 'jungle', + 'junior', 'junk', 'just', 'kangaroo', 'keen', 'keep', 'ketchup', 'key', 'kick', 'kid', + 'kidney', 'kind', 'kingdom', 'kiss', 'kit', 'kitchen', 'kite', 'kitten', 'kiwi', 'knee', + 'knife', 'knock', 'know', 'lab', 'label', 'labor', 'ladder', 'lady', 'lake', 'lamp', + 'language', 'laptop', 'large', 'later', 'latin', 'laugh', 'laundry', 'lava', 'law', 'lawn', + 'lawsuit', 'layer', 'lazy', 'leader', 'leaf', 'learn', 'leave', 'lecture', 'left', 'leg', + 'legal', 'legend', 'leisure', 'lemon', 'lend', 'length', 'lens', 'leopard', 'lesson', 'letter', + 'level', 'liar', 'liberty', 'library', 'license', 'life', 'lift', 'light', 'like', 'limb', + 'limit', 'link', 'lion', 'liquid', 'list', 'little', 'live', 'lizard', 'load', 'loan', + 'lobster', 'local', 'lock', 'logic', 'lonely', 'long', 'loop', 'lottery', 'loud', 'lounge', + 'love', 'low', 'loyal', 'lucky', 'luggage', 'lumber', 'lunar', 'lunch', 'luxury', 'lyrics', + 'machine', 'mad', 'magic', 'magnet', 'maid', 'mail', 'main', 'major', 'make', 'mammal', + 'man', 'manage', 'mandate', 'mango', 'mansion', 'manual', 'maple', 'marble', 'march', 'margin', + 'marine', 'market', 'marriage', 'mask', 'mass', 'master', 'match', 'material', 'math', 'matrix', + 'matter', 'maximum', 'maze', 'meadow', 'mean', 'measure', 'meat', 'mechanic', 'medal', 'media', + 'melody', 'melt', 'member', 'memory', 'mention', 'menu', 'mercy', 'merge', 'merit', 'merry', + 'mesh', 'message', 'metal', 'method', 'middle', 'midnight', 'milk', 'million', 'mimic', 'mind', + 'minimum', 'minor', 'minute', 'miracle', 'mirror', 'misery', 'miss', 'mistake', 'mix', 'mixed', + 'mixture', 'mobile', 'model', 'modify', 'mom', 'moment', 'monitor', 'monkey', 'monster', 'month', + 'moon', 'moral', 'more', 'morning', 'mosquito', 'mother', 'motion', 'motor', 'mountain', 'mouse', + 'move', 'movie', 'much', 'muffin', 'mule', 'multiply', 'muscle', 'museum', 'mushroom', 'music', + 'must', 'mutual', 'myself', 'mystery', 'myth', 'naive', 'name', 'napkin', 'narrow', 'nasty', + 'nation', 'nature', 'near', 'neck', 'need', 'negative', 'neglect', 'neither', 'nephew', 'nerve', + 'nest', 'net', 'network', 'neutral', 'never', 'news', 'next', 'nice', 'night', 'noble', + 'noise', 'nominee', 'noodle', 'normal', 'north', 'nose', 'notable', 'note', 'nothing', 'notice', + 'novel', 'now', 'nuclear', 'number', 'nurse', 'nut', 'oak', 'obey', 'object', 'oblige', + 'obscure', 'observe', 'obtain', 'obvious', 'occur', 'ocean', 'october', 'odor', 'off', 'offer', + 'office', 'often', 'oil', 'okay', 'old', 'olive', 'olympic', 'omit', 'once', 'one', + 'onion', 'online', 'only', 'open', 'opera', 'opinion', 'oppose', 'option', 'orange', 'orbit', + 'orchard', 'order', 'ordinary', 'organ', 'orient', 'original', 'orphan', 'ostrich', 'other', 'outdoor', + 'outer', 'output', 'outside', 'oval', 'oven', 'over', 'own', 'owner', 'oxygen', 'oyster', + 'ozone', 'pact', 'paddle', 'page', 'pair', 'palace', 'palm', 'panda', 'panel', 'panic', + 'panther', 'paper', 'parade', 'parent', 'park', 'parrot', 'party', 'pass', 'patch', 'path', + 'patient', 'patrol', 'pattern', 'pause', 'pave', 'payment', 'peace', 'peanut', 'pear', 'peasant', + 'pelican', 'pen', 'penalty', 'pencil', 'people', 'pepper', 'perfect', 'permit', 'person', 'pet', + 'phone', 'photo', 'phrase', 'physical', 'piano', 'picnic', 'picture', 'piece', 'pig', 'pigeon', + 'pill', 'pilot', 'pink', 'pioneer', 'pipe', 'pistol', 'pitch', 'pizza', 'place', 'planet', + 'plastic', 'plate', 'play', 'please', 'pledge', 'pluck', 'plug', 'plunge', 'poem', 'poet', + 'point', 'polar', 'pole', 'police', 'pond', 'pony', 'pool', 'poor', 'popcorn', 'popular', + 'portion', 'position', 'possible', 'post', 'potato', 'pottery', 'poverty', 'powder', 'power', 'practice', + 'praise', 'predict', 'prefer', 'prepare', 'present', 'pretty', 'prevent', 'price', 'pride', 'primary', + 'print', 'priority', 'prison', 'private', 'prize', 'problem', 'process', 'produce', 'profit', 'program', + 'project', 'promote', 'proof', 'property', 'prosper', 'protect', 'proud', 'provide', 'public', 'pudding', + 'pull', 'pulp', 'pulse', 'pumpkin', 'punch', 'pupil', 'puppy', 'purchase', 'purity', 'purpose', + 'purse', 'push', 'put', 'puzzle', 'pyramid', 'quality', 'quantum', 'quarter', 'question', 'quick', + 'quit', 'quiz', 'quote', 'rabbit', 'raccoon', 'race', 'rack', 'radar', 'radio', 'rail', + 'rain', 'raise', 'rally', 'ramp', 'ranch', 'random', 'range', 'rapid', 'rare', 'rate', + 'rather', 'raven', 'raw', 'razor', 'ready', 'real', 'reason', 'rebel', 'rebuild', 'recall', + 'receive', 'recipe', 'record', 'recycle', 'reduce', 'reflect', 'reform', 'refuse', 'region', 'regret', + 'regular', 'reject', 'relax', 'release', 'relief', 'rely', 'remain', 'remember', 'remind', 'remove', + 'render', 'renew', 'rent', 'reopen', 'repair', 'repeat', 'replace', 'report', 'require', 'rescue', + 'resemble', 'resist', 'resource', 'response', 'result', 'retire', 'retreat', 'return', 'reunion', 'reveal', + 'review', 'reward', 'rhythm', 'rib', 'ribbon', 'rice', 'rich', 'ride', 'ridge', 'rifle', + 'right', 'rigid', 'ring', 'riot', 'rip', 'ripe', 'rise', 'risk', 'ritual', 'rival', + 'river', 'road', 'roast', 'robot', 'robust', 'rocket', 'romance', 'roof', 'rookie', 'room', + 'rose', 'rotate', 'rough', 'round', 'route', 'royal', 'rubber', 'rude', 'rug', 'rule', + 'run', 'runway', 'rural', 'sad', 'saddle', 'sadness', 'safe', 'sail', 'salad', 'salmon', + 'salon', 'salt', 'same', 'sample', 'sand', 'satisfy', 'satoshi', 'sauce', 'sausage', 'save', + 'say', 'scale', 'scan', 'scare', 'scatter', 'scene', 'scheme', 'school', 'science', 'scissors', + 'scorpion', 'scout', 'scrap', 'screen', 'script', 'scrub', 'sea', 'search', 'season', 'seat', + 'second', 'secret', 'section', 'security', 'seed', 'seek', 'segment', 'seize', 'select', 'sell', + 'seminar', 'senior', 'sense', 'sentence', 'series', 'service', 'session', 'settle', 'setup', 'seven', + 'shadow', 'shaft', 'shallow', 'share', 'shed', 'shell', 'sheriff', 'shield', 'shift', 'shine', + 'ship', 'shiver', 'shock', 'shoe', 'shoot', 'shop', 'short', 'shoulder', 'shove', 'shrimp', + 'shrug', 'shuffle', 'shy', 'sibling', 'sick', 'side', 'siege', 'sight', 'sign', 'silent', + 'silk', 'silly', 'silver', 'similar', 'simple', 'since', 'sing', 'siren', 'sister', 'situate', + 'six', 'size', 'skate', 'sketch', 'ski', 'skill', 'skin', 'skirt', 'skull', 'slab', + 'slam', 'sleep', 'slender', 'slice', 'slide', 'slight', 'slim', 'slogan', 'slot', 'slow', + 'slush', 'small', 'smart', 'smile', 'smoke', 'smooth', 'snack', 'snake', 'snap', 'sniff', + 'snow', 'soap', 'soccer', 'social', 'sock', 'soda', 'soft', 'solar', 'soldier', 'solid', + 'solve', 'someone', 'song', 'soon', 'sorry', 'sort', 'soul', 'sound', 'soup', 'source', + 'south', 'space', 'spare', 'spatial', 'spawn', 'speak', 'special', 'speed', 'spell', 'spend', + 'sphere', 'spice', 'spider', 'spike', 'spin', 'spirit', 'split', 'spoil', 'sponsor', 'spoon', + 'sport', 'spot', 'spray', 'spread', 'spring', 'spy', 'square', 'squeeze', 'squirrel', 'stable', + 'stadium', 'staff', 'stage', 'stair', 'stamp', 'stand', 'start', 'state', 'stay', 'steak', + 'steel', 'stem', 'step', 'stereo', 'stick', 'still', 'sting', 'stock', 'stomach', 'stone', + 'stool', 'story', 'stove', 'strategy', 'street', 'strike', 'strong', 'struggle', 'student', 'stuff', + 'stumble', 'style', 'subject', 'submit', 'subway', 'success', 'such', 'sudden', 'suffer', 'sugar', + 'suggest', 'suit', 'summer', 'sun', 'sunny', 'sunset', 'super', 'supply', 'supreme', 'sure', + 'surface', 'surge', 'surprise', 'surround', 'survey', 'suspect', 'sustain', 'swallow', 'swamp', 'swap', + 'swarm', 'swear', 'sweet', 'swift', 'swim', 'swing', 'switch', 'sword', 'symbol', 'symptom', + 'syrup', 'system', 'table', 'tackle', 'tag', 'tail', 'talent', 'talk', 'tank', 'tape', + 'target', 'task', 'taste', 'tattoo', 'taxi', 'teach', 'team', 'tell', 'ten', 'tenant', + 'tennis', 'tent', 'term', 'test', 'text', 'thank', 'that', 'theme', 'then', 'theory', + 'there', 'they', 'thing', 'this', 'thought', 'three', 'thrive', 'throw', 'thumb', 'thunder', + 'ticket', 'tide', 'tiger', 'tilt', 'timber', 'time', 'tiny', 'tip', 'tired', 'tissue', + 'title', 'toast', 'tobacco', 'today', 'toddler', 'toe', 'together', 'toilet', 'token', 'tomato', + 'tomorrow', 'tone', 'tongue', 'tonight', 'tool', 'tooth', 'top', 'topic', 'topple', 'torch', + 'tornado', 'tortoise', 'toss', 'total', 'tourist', 'toward', 'tower', 'town', 'toy', 'track', + 'trade', 'traffic', 'tragic', 'train', 'transfer', 'trap', 'trash', 'travel', 'tray', 'treat', + 'tree', 'trend', 'trial', 'tribe', 'trick', 'trigger', 'trim', 'trip', 'trophy', 'trouble', + 'truck', 'true', 'truly', 'trumpet', 'trust', 'truth', 'try', 'tube', 'tuition', 'tumble', + 'tuna', 'tunnel', 'turkey', 'turn', 'turtle', 'twelve', 'twenty', 'twice', 'twin', 'twist', + 'two', 'type', 'typical', 'ugly', 'umbrella', 'unable', 'unaware', 'uncle', 'uncover', 'under', + 'undo', 'unfair', 'unfold', 'unhappy', 'uniform', 'unique', 'unit', 'universe', 'unknown', 'unlock', + 'until', 'unusual', 'unveil', 'update', 'upgrade', 'uphold', 'upon', 'upper', 'upset', 'urban', + 'urge', 'usage', 'use', 'used', 'useful', 'useless', 'usual', 'utility', 'vacant', 'vacuum', + 'vague', 'valid', 'valley', 'valve', 'van', 'vanish', 'vapor', 'various', 'vast', 'vault', + 'vehicle', 'velvet', 'vendor', 'venture', 'venue', 'verb', 'verify', 'version', 'very', 'vessel', + 'veteran', 'viable', 'vibrant', 'vicious', 'victory', 'video', 'view', 'village', 'vintage', 'violin', + 'virtual', 'virus', 'visa', 'visit', 'visual', 'vital', 'vivid', 'vocal', 'voice', 'void', + 'volcano', 'volume', 'vote', 'voyage', 'wage', 'wagon', 'wait', 'walk', 'wall', 'walnut', + 'want', 'warfare', 'warm', 'warrior', 'wash', 'wasp', 'waste', 'water', 'wave', 'way', + 'wealth', 'weapon', 'weary', 'weather', 'weave', 'web', 'wedding', 'weekend', 'weird', 'welcome', + 'west', 'wet', 'whale', 'what', 'wheat', 'wheel', 'when', 'where', 'whip', 'whisper', + 'wide', 'width', 'wife', 'wild', 'will', 'win', 'window', 'wine', 'wing', 'wink', + 'winner', 'winter', 'wire', 'wisdom', 'wise', 'wish', 'witness', 'wolf', 'woman', 'wonder', + 'wood', 'wool', 'word', 'work', 'world', 'worry', 'worth', 'wrap', 'wreck', 'wrestle', + 'wrist', 'write', 'wrong', 'yard', 'year', 'yellow', 'you', 'young', 'youth', 'zebra', + 'zero', 'zone', 'zoo' +] + +/** + * Generate a random 4-word recovery phrase from BIP39 word list + */ +export function generateRecoveryPhrase(): string[] { + const words: string[] = [] + const random = crypto.getRandomValues(new Uint32Array(4)) + + for (let i = 0; i < 4; i += 1) { + const randomValue = random[i] + if (randomValue === undefined) { + throw new Error('Failed to generate random value') + } + const index = randomValue % BIP39_WORDLIST.length + const word = BIP39_WORDLIST[index] + if (word === undefined) { + throw new Error('Invalid word index') + } + words.push(word) + } + + return words +} + +/** + * Get word suggestions for autocomplete (words starting with prefix) + */ +export function getWordSuggestions(prefix: string, maxResults: number = 10): string[] { + const lowerPrefix = prefix.toLowerCase() + const suggestions: string[] = [] + + for (const word of BIP39_WORDLIST) { + if (word.toLowerCase().startsWith(lowerPrefix)) { + suggestions.push(word) + if (suggestions.length >= maxResults) { + break + } + } + } + + return suggestions +} diff --git a/lib/keyManagementEncryption.ts b/lib/keyManagementEncryption.ts index 3cb7ffb..8842a62 100644 --- a/lib/keyManagementEncryption.ts +++ b/lib/keyManagementEncryption.ts @@ -1,115 +1,8 @@ +/** + * Encrypted payload structure for storing encrypted data + * Used by the two-level encryption system + */ export interface EncryptedPayload { iv: string ciphertext: string } - -const SALT_LENGTH = 32 -const PBKDF2_ITERATIONS = 100000 -const PBKDF2_HASH = 'SHA-256' -const KEY_LENGTH = 32 - -/** - * Derive an encryption key from a recovery phrase using PBKDF2 - */ -export async function deriveKeyFromPhrase(phrase: string[]): Promise { - const phraseString = phrase.join(' ') - const encoder = new TextEncoder() - const password = encoder.encode(phraseString) - - // Generate a deterministic salt from the phrase itself - // This ensures the same phrase always generates the same key - const saltBuffer = await crypto.subtle.digest('SHA-256', password) - const saltArray = new Uint8Array(saltBuffer) - const salt = saltArray.slice(0, SALT_LENGTH) - - // Import password as key material - const keyMaterial = await crypto.subtle.importKey( - 'raw', - password, - 'PBKDF2', - false, - ['deriveBits', 'deriveKey'] - ) - - // Derive key using PBKDF2 - const derivedKey = await crypto.subtle.deriveKey( - { - name: 'PBKDF2', - salt, - iterations: PBKDF2_ITERATIONS, - hash: PBKDF2_HASH, - }, - keyMaterial, - { name: 'AES-GCM', length: KEY_LENGTH * 8 }, - false, - ['encrypt', 'decrypt'] - ) - - return derivedKey -} - -/** - * Encrypt nsec with derived key - */ -export async function encryptNsec(derivedKey: CryptoKey, nsecHex: string): Promise { - const encoder = new TextEncoder() - const data = encoder.encode(nsecHex) - const iv = crypto.getRandomValues(new Uint8Array(12)) - - const encrypted = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - derivedKey, - data - ) - - const encryptedArray = new Uint8Array(encrypted) - - // Convert to base64 for storage - function toBase64(bytes: Uint8Array): string { - let binary = '' - bytes.forEach((b) => { - binary += String.fromCharCode(b) - }) - return btoa(binary) - } - - return { - iv: toBase64(iv), - ciphertext: toBase64(encryptedArray), - } -} - -/** - * Decrypt nsec with derived key - */ -export async function decryptNsec(derivedKey: CryptoKey, payload: EncryptedPayload): Promise { - function fromBase64(value: string): Uint8Array { - const binary = atob(value) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i += 1) { - bytes[i] = binary.charCodeAt(i) - } - return bytes - } - - const iv = fromBase64(payload.iv) - const ciphertext = fromBase64(payload.ciphertext) - - // Ensure iv and ciphertext are proper ArrayBuffer views - const ivBuffer = iv.buffer instanceof ArrayBuffer ? iv.buffer : new ArrayBuffer(iv.byteLength) - const ivView = new Uint8Array(ivBuffer, 0, iv.byteLength) - ivView.set(iv) - - const cipherBuffer = ciphertext.buffer instanceof ArrayBuffer ? ciphertext.buffer : new ArrayBuffer(ciphertext.byteLength) - const cipherView = new Uint8Array(cipherBuffer, 0, ciphertext.byteLength) - cipherView.set(ciphertext) - - const decrypted = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: ivView }, - derivedKey, - cipherView - ) - - const decoder = new TextDecoder() - return decoder.decode(decrypted) -} diff --git a/lib/keyManagementRecovery.ts b/lib/keyManagementRecovery.ts deleted file mode 100644 index a6f4e21..0000000 --- a/lib/keyManagementRecovery.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Word list for generating 4-word recovery phrases - * Using common French words for better user experience - */ -const WORD_LIST = [ - 'abeille', 'arbre', 'avion', 'bateau', 'café', 'chaton', 'ciel', 'cœur', 'diamant', 'étoile', - 'fleur', 'forêt', 'guitare', 'jardin', 'livre', 'lune', 'miel', 'montagne', 'océan', 'papillon', - 'piano', 'plage', 'plume', 'rivière', 'soleil', 'tigre', 'voiture', 'vague', 'vent', 'éclair', - 'arc', 'banane', 'canard', 'carotte', 'cerise', 'cochon', 'crocodile', 'éléphant', 'grenouille', 'hibou', - 'kiwi', 'lapin', 'légume', 'loup', 'mouton', 'orange', 'panda', 'pomme', 'renard', 'serpent', - 'tigre', 'tomate', 'tortue', 'vache', 'zèbre', 'balle', 'ballon', 'bateau', 'camion', 'crayon', - 'livre', 'maison', 'table', 'chaise', 'fenêtre', 'porte', 'lampe', 'clé', 'roue', 'arbre' -] - -/** - * Generate a random 4-word recovery phrase - */ -export function generateRecoveryPhrase(): string[] { - const words: string[] = [] - const random = crypto.getRandomValues(new Uint32Array(4)) - - for (let i = 0; i < 4; i += 1) { - const randomValue = random[i] - if (randomValue === undefined) { - throw new Error('Failed to generate random value') - } - const index = randomValue % WORD_LIST.length - const word = WORD_LIST[index] - if (word === undefined) { - throw new Error('Invalid word index') - } - words.push(word) - } - - return words -} diff --git a/lib/keyManagementStorage.ts b/lib/keyManagementStorage.ts index 09e46c7..80c3685 100644 --- a/lib/keyManagementStorage.ts +++ b/lib/keyManagementStorage.ts @@ -4,35 +4,30 @@ import { storageService } from './storage/indexedDB' export const KEY_STORAGE_KEY = 'nostr_encrypted_key' /** - * Store account identifier in browser storage + * Store account identifier in IndexedDB * This is just a flag to indicate that an account exists */ -export function storeAccountFlag(): void { - if (typeof window === 'undefined' || !window.localStorage) { - throw new Error('localStorage not available') - } - - localStorage.setItem('nostr_account_exists', 'true') +export async function storeAccountFlag(): Promise { + await storageService.set('nostr_account_exists', true, 'nostr_key_storage') } /** * Check if account flag exists */ -export function hasAccountFlag(): boolean { - if (typeof window === 'undefined' || !window.localStorage) { +export async function hasAccountFlag(): Promise { + try { + const exists = await storageService.get('nostr_account_exists', 'nostr_key_storage') + return exists === true + } catch { return false } - - return localStorage.getItem('nostr_account_exists') === 'true' } /** - * Remove account flag from browser storage + * Remove account flag from IndexedDB */ -export function removeAccountFlag(): void { - if (typeof window !== 'undefined' && window.localStorage) { - localStorage.removeItem('nostr_account_exists') - } +export async function removeAccountFlag(): Promise { + await storageService.delete('nostr_account_exists') } export async function getEncryptedKey(): Promise { diff --git a/lib/keyManagementTwoLevel.ts b/lib/keyManagementTwoLevel.ts new file mode 100644 index 0000000..706c103 --- /dev/null +++ b/lib/keyManagementTwoLevel.ts @@ -0,0 +1,445 @@ +/** + * Two-level encryption system for key management + * + * Level 1: Private key encrypted with KEK (Key Encryption Key) + * Level 2: KEK encrypted with 4-word recovery phrase + * + * Flow: + * - KEK is generated randomly and stored encrypted in Credentials API + * - Private key is encrypted with KEK and stored in IndexedDB + * - Recovery phrase (4 words) is used to encrypt/decrypt KEK + */ + +import type { EncryptedPayload } from './keyManagementEncryption' +import { generateRecoveryPhrase } from './keyManagementBIP39' +const PBKDF2_ITERATIONS = 100000 +const PBKDF2_HASH = 'SHA-256' + +/** + * Generate a random KEK (Key Encryption Key) + */ +async function generateKEK(): Promise { + return await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, // extractable + ['encrypt', 'decrypt'] + ) +} + +/** + * Derive encryption key from recovery phrase using PBKDF2 + */ +async function deriveKeyFromPhrase(phrase: string[]): Promise { + const phraseString = phrase.join(' ') + const encoder = new TextEncoder() + const password = encoder.encode(phraseString) + + // Generate deterministic salt from phrase + const saltBuffer = await crypto.subtle.digest('SHA-256', password) + const saltArray = new Uint8Array(saltBuffer) + const salt = saltArray.slice(0, 32) + + // Import password as key material + const keyMaterial = await crypto.subtle.importKey( + 'raw', + password, + 'PBKDF2', + false, + ['deriveBits', 'deriveKey'] + ) + + // Derive key using PBKDF2 + const derivedKey = await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: PBKDF2_ITERATIONS, + hash: PBKDF2_HASH, + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ) + + return derivedKey +} + +/** + * Export KEK to raw bytes (for storage) + */ +async function exportKEK(kek: CryptoKey): Promise { + const exported = await crypto.subtle.exportKey('raw', kek) + return new Uint8Array(exported) +} + +/** + * Import KEK from raw bytes + */ +async function importKEK(keyBytes: Uint8Array): Promise { + // Create a new ArrayBuffer from the Uint8Array + const buffer = new ArrayBuffer(keyBytes.length) + const view = new Uint8Array(buffer) + view.set(keyBytes) + return await crypto.subtle.importKey( + 'raw', + buffer, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ) +} + +/** + * Encrypt KEK with recovery phrase + */ +async function encryptKEK(kek: CryptoKey, recoveryPhrase: string[]): Promise { + const phraseKey = await deriveKeyFromPhrase(recoveryPhrase) + const kekBytes = await exportKEK(kek) + + const encoder = new TextEncoder() + const data = encoder.encode(Array.from(kekBytes).map(b => b.toString(16).padStart(2, '0')).join('')) + const iv = crypto.getRandomValues(new Uint8Array(12)) + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + phraseKey, + data + ) + + const encryptedArray = new Uint8Array(encrypted) + + function toBase64(bytes: Uint8Array): string { + let binary = '' + bytes.forEach((b) => { + binary += String.fromCharCode(b) + }) + return btoa(binary) + } + + return { + iv: toBase64(iv), + ciphertext: toBase64(encryptedArray), + } +} + +/** + * Decrypt KEK with recovery phrase + */ +async function decryptKEK(encryptedKEK: EncryptedPayload, recoveryPhrase: string[]): Promise { + const phraseKey = await deriveKeyFromPhrase(recoveryPhrase) + + function fromBase64(value: string): Uint8Array { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i) + } + return bytes + } + + const iv = fromBase64(encryptedKEK.iv) + const ciphertext = fromBase64(encryptedKEK.ciphertext) + + // Create ArrayBuffer views for decrypt + const ivBuffer = new ArrayBuffer(iv.length) + const ivView = new Uint8Array(ivBuffer) + ivView.set(iv) + + const cipherBuffer = new ArrayBuffer(ciphertext.length) + const cipherView = new Uint8Array(cipherBuffer) + cipherView.set(ciphertext) + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: ivView }, + phraseKey, + cipherBuffer + ) + + const decoder = new TextDecoder() + const hexString = decoder.decode(decrypted) + + // Convert hex string back to bytes + const kekBytes = new Uint8Array(hexString.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || []) + + return await importKEK(kekBytes) +} + +/** + * Encrypt private key with KEK + */ +async function encryptPrivateKeyWithKEK(privateKey: string, kek: CryptoKey): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(privateKey) + const iv = crypto.getRandomValues(new Uint8Array(12)) + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + kek, + data + ) + + const encryptedArray = new Uint8Array(encrypted) + + function toBase64(bytes: Uint8Array): string { + let binary = '' + bytes.forEach((b) => { + binary += String.fromCharCode(b) + }) + return btoa(binary) + } + + return { + iv: toBase64(iv), + ciphertext: toBase64(encryptedArray), + } +} + +/** + * Decrypt private key with KEK + */ +async function decryptPrivateKeyWithKEK(encryptedPrivateKey: EncryptedPayload, kek: CryptoKey): Promise { + function fromBase64(value: string): Uint8Array { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i) + } + return bytes + } + + const iv = fromBase64(encryptedPrivateKey.iv) + const ciphertext = fromBase64(encryptedPrivateKey.ciphertext) + + // Create ArrayBuffer views for decrypt + const ivBuffer = new ArrayBuffer(iv.length) + const ivView = new Uint8Array(ivBuffer) + ivView.set(iv) + + const cipherBuffer = new ArrayBuffer(ciphertext.length) + const cipherView = new Uint8Array(cipherBuffer) + cipherView.set(ciphertext) + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: ivView }, + kek, + cipherBuffer + ) + + const decoder = new TextDecoder() + return decoder.decode(decrypted) +} + +/** + * Store encrypted KEK in Credentials API + */ +async function storeEncryptedKEK(encryptedKEK: EncryptedPayload): Promise { + if (typeof window === 'undefined') { + throw new Error('Window not available') + } + + // Type definition for PasswordCredential + interface PasswordCredentialData { + id: string + name: string + password: string + iconURL?: string + } + type PasswordCredentialConstructorType = new (data: PasswordCredentialData) => Credential & { id: string; password: string } + const PasswordCredentialConstructor = (window as unknown as { PasswordCredential?: PasswordCredentialConstructorType }).PasswordCredential + if (!PasswordCredentialConstructor || !navigator.credentials || !navigator.credentials.store) { + throw new Error('PasswordCredential API not available') + } + + // Store encrypted KEK as password in credential + // Type assertion for PasswordCredential + interface PasswordCredentialData { + id: string + name: string + password: string + iconURL?: string + } + type PasswordCredentialType = new (data: PasswordCredentialData) => Credential & { id: string; password: string } + const PasswordCredentialClass = PasswordCredentialConstructor as PasswordCredentialType + const credential = new PasswordCredentialClass({ + id: 'nostr_kek', + name: 'Nostr KEK', + password: JSON.stringify(encryptedKEK), + iconURL: window.location.origin + '/favicon.ico', + }) + + await navigator.credentials.store(credential) +} + +/** + * Retrieve encrypted KEK from Credentials API + */ +async function getEncryptedKEK(): Promise { + if (typeof window === 'undefined' || !navigator.credentials || !navigator.credentials.get) { + return null + } + + try { + const credential = await navigator.credentials.get({ + password: true, + } as CredentialRequestOptions) + + if (credential && 'password' in credential && typeof credential.password === 'string' && credential.id === 'nostr_kek') { + return JSON.parse(credential.password) as EncryptedPayload + } + + return null + } catch (e) { + console.error('Error retrieving encrypted KEK:', e) + return null + } +} + +export interface CreateAccountResult { + recoveryPhrase: string[] + npub: string + publicKey: string +} + +export interface UnlockAccountResult { + privateKey: string + publicKey: string + npub: string +} + +/** + * Create account with two-level encryption + */ +export async function createAccountTwoLevel( + privateKeyHex: string, + getPublicKey: (secretKey: Uint8Array) => string, + encodeNpub: (publicKey: string) => string +): Promise { + // Step 1: Generate recovery phrase (4 words from BIP39) + const recoveryPhrase = generateRecoveryPhrase() + + // Step 2: Generate KEK (in memory) + const kek = await generateKEK() + + // Step 3: Encrypt private key with KEK + const encryptedPrivateKey = await encryptPrivateKeyWithKEK(privateKeyHex, kek) + + // Step 4: Encrypt KEK with recovery phrase + const encryptedKEK = await encryptKEK(kek, recoveryPhrase) + + // Step 5: Store encrypted KEK in Credentials API + await storeEncryptedKEK(encryptedKEK) + + // Step 6: Store encrypted private key in IndexedDB (via storage service) + const { storageService } = await import('./storage/indexedDB') + await storageService.set('nostr_encrypted_key', encryptedPrivateKey, 'nostr_key_storage') + + // Step 7: Compute public key and npub + const { hexToBytes } = await import('nostr-tools/utils') + const secretKey = hexToBytes(privateKeyHex) + const publicKey = getPublicKey(secretKey) + const npub = encodeNpub(publicKey) + + // Step 8: Store public keys in IndexedDB + await storageService.set('nostr_public_key', { publicKey, npub }, 'nostr_key_storage') + + // Step 9: Store account flag in IndexedDB (not localStorage) + await storageService.set('nostr_account_exists', true, 'nostr_key_storage') + + // Step 10: Clear KEK from memory (it's now encrypted and stored) + // Note: In JavaScript, we can't force garbage collection, but we can null the reference + // The KEK will be garbage collected automatically + + return { + recoveryPhrase, + npub, + publicKey, + } +} + +/** + * Unlock account with two-level decryption + */ +export async function unlockAccountTwoLevel( + recoveryPhrase: string[], + getPublicKey: (secretKey: Uint8Array) => string, + encodeNpub: (publicKey: string) => string +): Promise { + // Step 1: Get encrypted KEK from Credentials API + const encryptedKEK = await getEncryptedKEK() + if (!encryptedKEK) { + throw new Error('No encrypted KEK found in Credentials API') + } + + // Step 2: Decrypt KEK with recovery phrase (in memory) + const kek = await decryptKEK(encryptedKEK, recoveryPhrase) + + // Step 3: Clear recovery phrase from memory (set to empty) + // Note: In JavaScript, we can't force memory clearing, but we can overwrite + recoveryPhrase.fill('') + + // Step 4: Get encrypted private key from IndexedDB + const { storageService } = await import('./storage/indexedDB') + const encryptedPrivateKey = await storageService.get('nostr_encrypted_key', 'nostr_key_storage') + if (!encryptedPrivateKey) { + throw new Error('No encrypted private key found in IndexedDB') + } + + // Step 5: Decrypt private key with KEK (in memory) + const privateKeyHex = await decryptPrivateKeyWithKEK(encryptedPrivateKey, kek) + + // Step 6: Clear KEK from memory + // Note: In JavaScript, we can't force memory clearing, but we can null the reference + + // Step 7: Verify by computing public key + const { hexToBytes } = await import('nostr-tools/utils') + const secretKey = hexToBytes(privateKeyHex) + const publicKey = getPublicKey(secretKey) + const npub = encodeNpub(publicKey) + + return { + privateKey: privateKeyHex, + publicKey, + npub, + } +} + +/** + * Check if account exists + */ +export async function accountExistsTwoLevel(): Promise { + try { + const { storageService } = await import('./storage/indexedDB') + const exists = await storageService.get('nostr_account_exists', 'nostr_key_storage') + return exists === true + } catch { + return false + } +} + +/** + * Get public keys if account exists + */ +export async function getPublicKeysTwoLevel(): Promise<{ publicKey: string; npub: string } | null> { + try { + const { storageService } = await import('./storage/indexedDB') + return await storageService.get<{ publicKey: string; npub: string }>('nostr_public_key', 'nostr_key_storage') + } catch { + return null + } +} + +/** + * Delete account (remove all stored data) + */ +export async function deleteAccountTwoLevel(): Promise { + const { storageService } = await import('./storage/indexedDB') + await storageService.delete('nostr_encrypted_key') + await storageService.delete('nostr_public_key') + await storageService.delete('nostr_account_exists') + + // Try to remove credential (may not be possible via API) + if (navigator.credentials && navigator.credentials.preventSilentAccess) { + navigator.credentials.preventSilentAccess() + } +} diff --git a/lib/nostrAuth.ts b/lib/nostrAuth.ts index 8cb39c4..46d2224 100644 --- a/lib/nostrAuth.ts +++ b/lib/nostrAuth.ts @@ -1,6 +1,6 @@ import { nostrService } from './nostr' import { keyManagementService } from './keyManagement' -import type { NostrConnectState } from '@/types/nostr' +import type { NostrConnectState, NostrProfile } from '@/types/nostr' /** * Nostr authentication service using local key management @@ -18,7 +18,7 @@ export class NostrAuthService { constructor() { if (typeof window !== 'undefined') { - this.loadStateFromStorage() + void this.loadStateFromStorage() this.setupMessageListener() } } @@ -60,7 +60,7 @@ export class NostrAuthService { profile: null, } nostrService.setPublicKey(result.publicKey) - this.saveStateToStorage() + void this.saveStateToStorage() this.notifyListeners() return result @@ -82,7 +82,7 @@ export class NostrAuthService { nostrService.setPublicKey(keys.publicKey) nostrService.setPrivateKey(keys.privateKey) - this.saveStateToStorage() + void this.saveStateToStorage() this.notifyListeners() void this.loadProfile() } catch (e) { @@ -116,7 +116,7 @@ export class NostrAuthService { profile: null, } nostrService.setPublicKey(publicKeys.publicKey) - this.saveStateToStorage() + void this.saveStateToStorage() this.notifyListeners() void this.loadProfile() } @@ -146,7 +146,7 @@ export class NostrAuthService { // The service will continue to work but won't have access to the keys nostrService.setPrivateKey('') nostrService.setPublicKey('') - this.saveStateToStorage() + void this.saveStateToStorage() this.notifyListeners() } @@ -167,7 +167,7 @@ export class NostrAuthService { const profile = await nostrService.getProfile(this.state.pubkey) if (profile) { this.state.profile = profile - this.saveStateToStorage() + void this.saveStateToStorage() this.notifyListeners() } } catch (e) { @@ -183,15 +183,19 @@ export class NostrAuthService { }) } - private loadStateFromStorage(): void { + private async loadStateFromStorage(): Promise { try { - const stored = localStorage.getItem('nostr_auth_state') + const { storageService } = await import('./storage/indexedDB') + const stored = await storageService.get<{ + connected: boolean + pubkey: string | null + profile: NostrProfile | null + }>('nostr_auth_state', 'nostr_auth_storage') if (stored) { - const parsed = JSON.parse(stored) this.state = { - connected: parsed.connected ?? false, - pubkey: parsed.pubkey ?? null, - profile: parsed.profile ?? null, + connected: stored.connected ?? false, + pubkey: stored.pubkey ?? null, + profile: stored.profile ?? null, } if (this.state.pubkey) { nostrService.setPublicKey(this.state.pubkey) @@ -203,7 +207,7 @@ export class NostrAuthService { } } - private saveStateToStorage(): void { + private async saveStateToStorage(): Promise { try { // Only save public information, never private keys const stateToSave = { @@ -211,7 +215,8 @@ export class NostrAuthService { pubkey: this.state.pubkey, profile: this.state.profile, } - localStorage.setItem('nostr_auth_state', JSON.stringify(stateToSave)) + const { storageService } = await import('./storage/indexedDB') + await storageService.set('nostr_auth_state', stateToSave, 'nostr_auth_storage') } catch (e) { console.error('Error saving state to storage:', e) } diff --git a/lib/notifications.ts b/lib/notifications.ts index f9b4dc3..51d0c4d 100644 --- a/lib/notifications.ts +++ b/lib/notifications.ts @@ -109,28 +109,28 @@ export class NotificationService { export const notificationService = new NotificationService() /** - * Load stored notifications from localStorage + * Load stored notifications from IndexedDB */ -export function loadStoredNotifications(userPubkey: string): Notification[] { +export async function loadStoredNotifications(userPubkey: string): Promise { try { + const { storageService } = await import('./storage/indexedDB') const key = `notifications_${userPubkey}` - const stored = localStorage.getItem(key) - if (stored) { - return JSON.parse(stored) as Notification[] - } + const stored = await storageService.get(key, 'notifications_storage') + return stored ?? [] } catch (error) { console.error('Error loading stored notifications:', error) + return [] } - return [] } /** - * Save notifications to localStorage + * Save notifications to IndexedDB */ -export function saveNotifications(userPubkey: string, notifications: Notification[]): void { +export async function saveNotifications(userPubkey: string, notifications: Notification[]): Promise { try { + const { storageService } = await import('./storage/indexedDB') const key = `notifications_${userPubkey}` - localStorage.setItem(key, JSON.stringify(notifications)) + await storageService.set(key, notifications, 'notifications_storage') } catch (error) { console.error('Error saving notifications:', error) } diff --git a/pages/_app.tsx b/pages/_app.tsx index 1e6546e..72e41fb 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,26 +1,41 @@ import '@/styles/globals.css' import type { AppProps } from 'next/app' import { useI18n } from '@/hooks/useI18n' +import React from 'react' function I18nProvider({ children }: { children: React.ReactNode }) { - // Get saved locale from localStorage or default to French - const getInitialLocale = (): 'fr' | 'en' => { + // Get saved locale from IndexedDB or default to French + const getInitialLocale = async (): Promise<'fr' | 'en'> => { if (typeof window === 'undefined') { return 'fr' } - const savedLocale = localStorage.getItem('zapwall-locale') as 'fr' | 'en' | null - if (savedLocale === 'fr' || savedLocale === 'en') { - return savedLocale + try { + const { storageService } = await import('@/lib/storage/indexedDB') + const savedLocale = await storageService.get<'fr' | 'en'>('zapwall-locale', 'app_storage') + if (savedLocale === 'fr' || savedLocale === 'en') { + return savedLocale + } + } catch { + // Fallback to browser locale detection } // Try to detect browser locale const browserLocale = navigator.language.split('-')[0] return browserLocale === 'en' ? 'en' : 'fr' } - const initialLocale = getInitialLocale() + const [initialLocale, setInitialLocale] = React.useState<'fr' | 'en'>('fr') + const [localeLoaded, setLocaleLoaded] = React.useState(false) + + React.useEffect(() => { + getInitialLocale().then((locale) => { + setInitialLocale(locale) + setLocaleLoaded(true) + }) + }, []) + const { loaded } = useI18n(initialLocale) - if (!loaded) { + if (!localeLoaded || !loaded) { return
Loading...
}