Remove initializeCookieCleanup function and migration code
This commit is contained in:
parent
42e3e7e692
commit
b8daab2bcd
@ -4,9 +4,12 @@ export function RecoveryWarning() {
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-yellow-800 font-semibold mb-2">⚠️ Important</p>
|
||||
<p className="text-yellow-700 text-sm">
|
||||
Ces 4 mots-clés sont votre seule façon de récupérer votre compte.
|
||||
Ces <strong className="font-bold">4 mots-clés</strong> sont votre seule façon de récupérer votre compte.
|
||||
<strong className="font-bold"> Ils ne seront jamais affichés à nouveau.</strong>
|
||||
</p>
|
||||
<p className="text-yellow-700 text-sm mt-2">
|
||||
Ces mots-clés (dictionnaire BIP39) sont utilisés avec <strong>PBKDF2</strong> 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).
|
||||
</p>
|
||||
<p className="text-yellow-700 text-sm mt-2">
|
||||
Notez-les dans un endroit sûr. Sans ces mots-clés, vous perdrez définitivement l'accès à votre compte.
|
||||
</p>
|
||||
@ -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}
|
||||
/>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Après l'import, vous recevrez <strong>4 mots-clés de récupération</strong> (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.
|
||||
</p>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 mb-4">{error}</p>}
|
||||
</>
|
||||
|
||||
@ -23,7 +23,7 @@ export function RecoveryStep({
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-2xl font-bold mb-4">Sauvegardez vos mots-clés de récupération</h2>
|
||||
<h2 className="text-2xl font-bold mb-4">Sauvegardez vos 4 mots-clés de récupération</h2>
|
||||
<RecoveryWarning />
|
||||
<RecoveryPhraseDisplay recoveryPhrase={recoveryPhrase} copied={copied} onCopy={handleCopy} />
|
||||
<PublicKeyDisplay npub={npub} />
|
||||
|
||||
@ -30,19 +30,30 @@ export function LanguageSelector() {
|
||||
const [currentLocale, setCurrentLocale] = useState<Locale>(getLocale())
|
||||
|
||||
useEffect(() => {
|
||||
// Load saved locale from localStorage
|
||||
const savedLocale = typeof window !== 'undefined' ? (localStorage.getItem(LOCALE_STORAGE_KEY) as Locale | null) : null
|
||||
// Load saved locale from IndexedDB
|
||||
const loadLocale = async () => {
|
||||
try {
|
||||
const { storageService } = await import('@/lib/storage/indexedDB')
|
||||
const savedLocale = await storageService.get<Locale>(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()
|
||||
|
||||
@ -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<string[]>([])
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const suggestionsRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||
const newValue = e.target.value.trim().toLowerCase()
|
||||
onChange(newValue)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="relative">
|
||||
<label htmlFor={`word-${index}`} className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Mot {index + 1}
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={`word-${index}`}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={() => {
|
||||
// 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 && (
|
||||
<div
|
||||
ref={suggestionsRef}
|
||||
className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-40 overflow-y-auto"
|
||||
>
|
||||
{suggestions.map((suggestion, idx) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-gray-100 ${
|
||||
idx === selectedIndex ? 'bg-gray-100' : ''
|
||||
}`}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WordInputs({
|
||||
words,
|
||||
onWordChange,
|
||||
@ -13,25 +123,19 @@ function WordInputs({
|
||||
words: string[]
|
||||
onWordChange: (index: number, value: string) => void
|
||||
}) {
|
||||
const [, setFocusedIndex] = useState<number | null>(null)
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{words.map((word, index) => (
|
||||
<div key={index}>
|
||||
<label htmlFor={`word-${index}`} className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Mot {index + 1}
|
||||
</label>
|
||||
<input
|
||||
id={`word-${index}`}
|
||||
type="text"
|
||||
<WordInputWithAutocomplete
|
||||
key={index}
|
||||
index={index}
|
||||
value={word}
|
||||
onChange={(e) => 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"
|
||||
onChange={(value) => onWordChange(index, value)}
|
||||
onFocus={() => setFocusedIndex(index)}
|
||||
onBlur={() => setFocusedIndex(null)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@ -148,7 +252,8 @@ export function UnlockAccountModal({ onSuccess, onClose }: UnlockAccountModalPro
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h2 className="text-2xl font-bold mb-4">Déverrouiller votre compte</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
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.
|
||||
</p>
|
||||
<UnlockAccountForm words={words} handleWordChange={handleWordChange} handlePaste={handlePaste} />
|
||||
{error && <p className="text-sm text-red-600 mb-4">{error}</p>}
|
||||
|
||||
@ -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<Locale>('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
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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<string> {
|
||||
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<string>(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<string> {
|
||||
const masterKey = await getOrCreateMasterKey()
|
||||
return `${masterKey}:${articleId}`
|
||||
}
|
||||
|
||||
@ -60,7 +61,7 @@ export async function storePrivateContent(
|
||||
): Promise<void> {
|
||||
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<StoredArticleData>(key, secret)
|
||||
|
||||
if (!data) {
|
||||
|
||||
33
lib/cookieCleanup.ts
Normal file
33
lib/cookieCleanup.ts
Normal file
@ -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}`
|
||||
}
|
||||
}
|
||||
@ -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<boolean> {
|
||||
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<void> {
|
||||
await deleteStoredKeys()
|
||||
removeAccountFlag()
|
||||
await deleteAccountTwoLevel()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
255
lib/keyManagementBIP39.ts
Normal file
255
lib/keyManagementBIP39.ts
Normal file
@ -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
|
||||
}
|
||||
@ -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<CryptoKey> {
|
||||
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<EncryptedPayload> {
|
||||
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<string> {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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<void> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const exists = await storageService.get<boolean>('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<void> {
|
||||
await storageService.delete('nostr_account_exists')
|
||||
}
|
||||
|
||||
export async function getEncryptedKey(): Promise<EncryptedPayload | null> {
|
||||
|
||||
445
lib/keyManagementTwoLevel.ts
Normal file
445
lib/keyManagementTwoLevel.ts
Normal file
@ -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<CryptoKey> {
|
||||
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<CryptoKey> {
|
||||
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<Uint8Array> {
|
||||
const exported = await crypto.subtle.exportKey('raw', kek)
|
||||
return new Uint8Array(exported)
|
||||
}
|
||||
|
||||
/**
|
||||
* Import KEK from raw bytes
|
||||
*/
|
||||
async function importKEK(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
// 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<EncryptedPayload> {
|
||||
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<CryptoKey> {
|
||||
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<EncryptedPayload> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<EncryptedPayload | null> {
|
||||
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<CreateAccountResult> {
|
||||
// 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<UnlockAccountResult> {
|
||||
// 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<EncryptedPayload>('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<boolean> {
|
||||
try {
|
||||
const { storageService } = await import('./storage/indexedDB')
|
||||
const exists = await storageService.get<boolean>('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<void> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@ -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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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<Notification[]> {
|
||||
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<Notification[]>(key, 'notifications_storage')
|
||||
return stored ?? []
|
||||
} catch (error) {
|
||||
console.error('Error loading stored notifications:', error)
|
||||
}
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
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 <div className="min-h-screen bg-cyber-darker flex items-center justify-center text-neon-cyan">Loading...</div>
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user