story-research-zapwall/components/KeyManagementManager.tsx
Nicolas Cantu d7a04dd8f8 Fix nsec import validation for Uint8Array data
**Motivations:**
- Fix import error when importing nsec private keys
- Support both string and Uint8Array formats from nip19.decode

**Root causes:**
- nip19.decode returns decoded.data as Uint8Array, not string
- Validation in KeyManagementManager only checked for string type
- importPrivateKey in keyManagement.ts only handled string type

**Correctifs:**
- Updated KeyManagementManager validation to accept Uint8Array
- Updated importPrivateKey to convert Uint8Array to hex using bytesToHex
- Improved error messages to show actual validation errors

**Evolutions:**
- None

**Pages affectées:**
- components/KeyManagementManager.tsx
- lib/keyManagement.ts
2026-01-05 22:38:21 +01:00

419 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
import { nostrAuthService } from '@/lib/nostrAuth'
import { keyManagementService } from '@/lib/keyManagement'
import { nip19 } from 'nostr-tools'
interface PublicKeys {
publicKey: string
npub: string
}
export function KeyManagementManager() {
const [publicKeys, setPublicKeys] = useState<PublicKeys | null>(null)
const [accountExists, setAccountExists] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [importKey, setImportKey] = useState('')
const [importing, setImporting] = useState(false)
const [showImportForm, setShowImportForm] = useState(false)
const [showReplaceWarning, setShowReplaceWarning] = useState(false)
const [recoveryPhrase, setRecoveryPhrase] = useState<string[] | null>(null)
const [newNpub, setNewNpub] = useState<string | null>(null)
const [copiedNpub, setCopiedNpub] = useState(false)
const [copiedPublicKey, setCopiedPublicKey] = useState(false)
const [copiedRecoveryPhrase, setCopiedRecoveryPhrase] = useState(false)
useEffect(() => {
void loadKeys()
}, [])
async function loadKeys() {
try {
setLoading(true)
setError(null)
const exists = await nostrAuthService.accountExists()
setAccountExists(exists)
if (exists) {
const keys = await keyManagementService.getPublicKeys()
if (keys) {
setPublicKeys(keys)
}
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to load keys'
setError(errorMessage)
console.error('Error loading keys:', e)
} finally {
setLoading(false)
}
}
function extractKeyFromUrl(url: string): string | null {
try {
// Try to parse as URL
const urlObj = new URL(url)
// Check if it's a nostr:// URL with nsec
if (urlObj.protocol === 'nostr:' || urlObj.protocol === 'nostr://') {
const path = urlObj.pathname || urlObj.href.replace(/^nostr:?\/\//, '')
if (path.startsWith('nsec')) {
return path
}
}
// Check if URL contains nsec
const nsecMatch = url.match(/nsec1[a-z0-9]+/i)
if (nsecMatch) {
return nsecMatch[0]
}
return null
} catch {
// Not a valid URL, try to extract nsec from text
const nsecMatch = url.match(/nsec1[a-z0-9]+/i)
if (nsecMatch) {
return nsecMatch[0]
}
// Assume it's already a key (hex or nsec)
return url.trim()
}
}
async function handleImport() {
if (!importKey.trim()) {
setError('Please enter a private key')
return
}
// Extract key from URL or text
const extractedKey = extractKeyFromUrl(importKey.trim())
if (!extractedKey) {
setError('Invalid key format. Please provide a nsec (nsec1...) or hex private key.')
return
}
// Validate key format
try {
// Try to decode as nsec
const decoded = nip19.decode(extractedKey)
if (decoded.type !== 'nsec') {
throw new Error('Invalid nsec format')
}
// decoded.data can be string (hex) or Uint8Array, both are valid
if (typeof decoded.data !== 'string' && !(decoded.data instanceof Uint8Array)) {
throw new Error('Invalid nsec format')
}
} catch (e) {
// If decoding failed, assume it's hex, validate length (64 hex chars = 32 bytes)
if (!/^[0-9a-f]{64}$/i.test(extractedKey)) {
const errorMsg = e instanceof Error ? e.message : 'Invalid format'
setError(`Invalid key format: ${errorMsg}. Please provide a nsec (nsec1...) or hex (64 characters) private key.`)
return
}
}
// If account exists, show warning
if (accountExists) {
setShowReplaceWarning(true)
return
}
await performImport(extractedKey)
}
async function performImport(key: string) {
try {
setImporting(true)
setError(null)
setShowReplaceWarning(false)
// If account exists, delete it first
if (accountExists) {
await nostrAuthService.deleteAccount()
}
// Create new account with imported key
const result = await nostrAuthService.createAccount(key)
setRecoveryPhrase(result.recoveryPhrase)
setNewNpub(result.npub)
setImportKey('')
setShowImportForm(false)
await loadKeys()
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to import key'
setError(errorMessage)
console.error('Error importing key:', e)
} finally {
setImporting(false)
}
}
async function handleCopyRecoveryPhrase() {
if (!recoveryPhrase) {
return
}
try {
await navigator.clipboard.writeText(recoveryPhrase.join(' '))
setCopiedRecoveryPhrase(true)
setTimeout(() => {
setCopiedRecoveryPhrase(false)
}, 2000)
} catch (e) {
console.error('Error copying recovery phrase:', e)
}
}
async function handleCopyNpub() {
if (!publicKeys?.npub) {
return
}
try {
await navigator.clipboard.writeText(publicKeys.npub)
setCopiedNpub(true)
setTimeout(() => {
setCopiedNpub(false)
}, 2000)
} catch (e) {
console.error('Error copying npub:', e)
}
}
async function handleCopyPublicKey() {
if (!publicKeys?.publicKey) {
return
}
try {
await navigator.clipboard.writeText(publicKeys.publicKey)
setCopiedPublicKey(true)
setTimeout(() => {
setCopiedPublicKey(false)
}, 2000)
} catch (e) {
console.error('Error copying public key:', e)
}
}
if (loading) {
return (
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<p className="text-cyber-accent">Loading...</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<h2 className="text-2xl font-bold text-neon-cyan mb-4">Key Management</h2>
{error && (
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4 mb-4">
<p className="text-red-400">{error}</p>
</div>
)}
{/* Public Keys Display */}
{publicKeys && (
<div className="space-y-4 mb-6">
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
<div className="flex justify-between items-start mb-2">
<p className="text-neon-blue font-semibold">Public Key (npub)</p>
<button
onClick={() => {
void handleCopyNpub()
}}
className="px-3 py-1 text-xs bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded transition-colors"
>
{copiedNpub ? '✓ Copied' : 'Copy'}
</button>
</div>
<p className="text-neon-cyan text-sm font-mono break-all">{publicKeys.npub}</p>
</div>
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
<div className="flex justify-between items-start mb-2">
<p className="text-neon-blue font-semibold">Public Key (hex)</p>
<button
onClick={() => {
void handleCopyPublicKey()
}}
className="px-3 py-1 text-xs bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded transition-colors"
>
{copiedPublicKey ? '✓ Copied' : 'Copy'}
</button>
</div>
<p className="text-neon-cyan text-sm font-mono break-all">{publicKeys.publicKey}</p>
</div>
</div>
)}
{!publicKeys && !accountExists && (
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4 mb-6">
<p className="text-yellow-400 font-semibold mb-2">No account found</p>
<p className="text-yellow-300/90 text-sm">
Create a new account by importing a private key. The key will be encrypted using a two-level encryption system.
</p>
</div>
)}
{/* Import Form */}
{!showImportForm && (
<button
onClick={() => {
setShowImportForm(true)
setError(null)
}}
className="w-full py-3 px-6 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
{accountExists ? 'Replace Account (Import New Key)' : 'Import Private Key'}
</button>
)}
{showImportForm && (
<div className="space-y-4">
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
<p className="text-yellow-400 font-semibold mb-2"> Important</p>
<p className="text-yellow-300/90 text-sm">
After importing, you will receive <strong className="font-bold">4 recovery words</strong> (BIP39 dictionary) to secure your account.
These words encrypt a Key Encryption Key (KEK) stored in the browser&apos;s Credentials API, which then encrypts your private key stored in IndexedDB (two-level encryption system).
</p>
{accountExists && (
<p className="text-yellow-300/90 text-sm mt-2">
<strong className="font-bold">Warning:</strong> Importing a new key will replace your existing account. Make sure you have your recovery phrase saved before proceeding.
</p>
)}
</div>
<div>
<label htmlFor="importKey" className="block text-sm font-medium text-cyber-accent mb-2">
Private Key (nsec URL, nsec1..., or hex)
</label>
<textarea
id="importKey"
value={importKey}
onChange={(e) => {
setImportKey(e.target.value)
setError(null)
}}
placeholder="nsec1... or nostr://nsec1... or hex key"
className="w-full px-3 py-2 bg-cyber-dark border border-neon-cyan/30 rounded-lg font-mono text-sm text-neon-cyan focus:border-neon-cyan focus:outline-none"
rows={4}
/>
<p className="text-sm text-cyber-accent/70 mt-2">
You can paste a nsec key, a nostr:// URL containing a nsec, or a hex private key (64 characters).
</p>
</div>
{showReplaceWarning && (
<div className="bg-red-900/20 border border-red-400/50 rounded-lg p-4">
<p className="text-red-400 font-semibold mb-2"> Replace Existing Account?</p>
<p className="text-red-300/90 text-sm mb-4">
This will delete your current account and create a new one with the imported key. Make sure you have saved your recovery phrase for the current account.
</p>
<div className="flex gap-4">
<button
onClick={() => {
setShowReplaceWarning(false)
}}
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
>
Cancel
</button>
<button
onClick={() => {
void performImport(extractKeyFromUrl(importKey.trim()) || importKey.trim())
}}
disabled={importing}
className="flex-1 py-2 px-4 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded-lg font-medium transition-all border border-red-400/50 hover:shadow-glow-red disabled:opacity-50"
>
{importing ? 'Replacing...' : 'Replace Account'}
</button>
</div>
</div>
)}
{!showReplaceWarning && (
<div className="flex gap-4">
<button
onClick={() => {
setShowImportForm(false)
setImportKey('')
setError(null)
}}
className="flex-1 py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
>
Cancel
</button>
<button
onClick={() => {
void handleImport()
}}
disabled={importing}
className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
>
{importing ? 'Importing...' : 'Import'}
</button>
</div>
)}
</div>
)}
{/* Recovery Phrase Display (after import) */}
{recoveryPhrase && newNpub && (
<div className="mt-6 space-y-4">
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4">
<p className="text-yellow-400 font-semibold mb-2"> Important</p>
<p className="text-yellow-300/90 text-sm">
These <strong className="font-bold">4 recovery words</strong> are your only way to recover your account.
<strong className="font-bold"> They will never be displayed again.</strong>
</p>
<p className="text-yellow-300/90 text-sm mt-2">
These words (BIP39 dictionary) are used with <strong>PBKDF2</strong> to encrypt a Key Encryption Key (KEK) stored in the browser&apos;s Credentials API. This KEK then encrypts your private key stored in IndexedDB (two-level system).
</p>
<p className="text-yellow-300/90 text-sm mt-2">
Save them in a safe place. Without these words, you will permanently lose access to your account.
</p>
</div>
<div className="bg-cyber-darker border border-neon-cyan/30 rounded-lg p-6">
<div className="grid grid-cols-2 gap-4 mb-4">
{recoveryPhrase.map((word, index) => (
<div
key={index}
className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-3 text-center font-mono text-lg"
>
<span className="text-cyber-accent/70 text-sm mr-2">{index + 1}.</span>
<span className="font-semibold text-neon-cyan">{word}</span>
</div>
))}
</div>
<button
onClick={() => {
void handleCopyRecoveryPhrase()
}}
className="w-full py-2 px-4 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg text-sm font-medium transition-colors"
>
{copiedRecoveryPhrase ? '✓ Copied!' : 'Copy Recovery Words'}
</button>
</div>
<div className="bg-neon-blue/10 border border-neon-blue/30 rounded-lg p-4">
<p className="text-neon-blue font-semibold mb-2">Your new public key (npub)</p>
<p className="text-neon-cyan text-sm font-mono break-all">{newNpub}</p>
</div>
<button
onClick={() => {
setRecoveryPhrase(null)
setNewNpub(null)
void loadKeys()
}}
className="w-full py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
>
Done
</button>
</div>
)}
</div>
</div>
)
}