mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Nostr keys derived from the cashu seed phrase
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minibits_wallet",
|
||||
"version": "0.3.4-beta.17",
|
||||
"version": "0.3.4-beta.18",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android:clean": "cd android && ./gradlew clean",
|
||||
|
||||
@@ -5,12 +5,18 @@ import {
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewStyle,
|
||||
ViewStyle,
|
||||
StatusBar,
|
||||
StatusBarProps,
|
||||
ColorValue,
|
||||
Platform
|
||||
} from "react-native"
|
||||
import Animated, {
|
||||
SharedValue,
|
||||
useAnimatedStyle,
|
||||
interpolate,
|
||||
Extrapolation,
|
||||
} from "react-native-reanimated"
|
||||
import { useIsFocused } from '@react-navigation/native'
|
||||
import useIsInternetReachable from '../utils/useIsInternetReachable'
|
||||
import { translate } from "../i18n"
|
||||
@@ -140,6 +146,16 @@ export interface HeaderProps {
|
||||
* Pass any additional props directly to the StatusBar component.
|
||||
*/
|
||||
StatusBarProps?: StatusBarProps
|
||||
/**
|
||||
* Shared value from scroll position for animated title.
|
||||
* When provided, the title will fade in as the user scrolls.
|
||||
*/
|
||||
scrollY?: SharedValue<number>
|
||||
/**
|
||||
* The scroll distance over which the title animation occurs.
|
||||
* Defaults to 60.
|
||||
*/
|
||||
scrollDistance?: number
|
||||
}
|
||||
|
||||
interface HeaderActionProps {
|
||||
@@ -198,11 +214,27 @@ export function Header(props: HeaderProps) {
|
||||
titleStyle: $titleStyleOverride,
|
||||
containerStyle: $containerStyleOverride,
|
||||
StatusBarProps,
|
||||
scrollY,
|
||||
scrollDistance = 60,
|
||||
} = props
|
||||
|
||||
const $containerInsets = useSafeAreaInsetsStyle(safeAreaEdges)
|
||||
const titleContent = !!TitleActionComponent ? undefined : titleTx ? translate(titleTx, titleTxOptions) : title
|
||||
|
||||
// Animated title opacity - fades in as user scrolls
|
||||
const animatedTitleStyle = useAnimatedStyle(() => {
|
||||
if (!scrollY) {
|
||||
return {}
|
||||
}
|
||||
const opacity = interpolate(
|
||||
scrollY.value,
|
||||
[0, scrollDistance],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP
|
||||
)
|
||||
return { opacity }
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={[$container, $containerInsets, { backgroundColor }, $containerStyleOverride]}>
|
||||
<FocusAwareStatusBar
|
||||
@@ -232,12 +264,13 @@ export function Header(props: HeaderProps) {
|
||||
{TitleActionComponent}
|
||||
</View>
|
||||
)}
|
||||
{!!titleContent && (
|
||||
<View
|
||||
{!!titleContent && (
|
||||
<Animated.View
|
||||
style={[
|
||||
titleMode === "center" && $titleWrapperCenter,
|
||||
titleMode === "flex" && $titleWrapperFlex,
|
||||
$titleContainerStyleOverride,
|
||||
scrollY ? animatedTitleStyle : undefined,
|
||||
]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
@@ -247,7 +280,7 @@ export function Header(props: HeaderProps) {
|
||||
text={titleContent}
|
||||
style={[$title, $titleStyleOverride]}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
<HeaderAction
|
||||
tx={rightTx}
|
||||
@@ -315,7 +348,7 @@ const $container: ViewStyle = {
|
||||
|
||||
const $title: TextStyle = {
|
||||
textAlign: "center",
|
||||
fontFamily: typography.logo.normal,
|
||||
//fontFamily: typography.logo.normal,
|
||||
color: 'white'
|
||||
}
|
||||
|
||||
|
||||
@@ -321,6 +321,11 @@
|
||||
"privacyScreen_torDaemon": "Use Tor network",
|
||||
"privacyScreen_torDaemonDescription": "Allow connections with mints through Tor .onion addresses.",
|
||||
"privacyScreen_torStatus": "Tor status",
|
||||
"privacyScreen_derivedKeys": "Nostr keys derived from seed",
|
||||
"privacyScreen_derivedKeysDescription": "Your Nostr keys are deterministically derived from your wallet seed phrase. This is now default behavior on Minibits.",
|
||||
"privacyScreen_legacyKeysDescription": "Your wallet uses random Nostr keys. Upgrade to derive them from your seed for easier recovery.",
|
||||
"privacyScreen_upgradeKeys": "Upgrade",
|
||||
"privacyScreen_upgradingKeys": "Upgrading Nostr keys...",
|
||||
"privateKey": "Private key",
|
||||
"privateNotePlaceholder": "Your private note",
|
||||
"profileMissingAddressError": "Missing address",
|
||||
|
||||
@@ -320,6 +320,11 @@
|
||||
"privacyScreen_torDaemon": "Utilice la red Tor",
|
||||
"privacyScreen_torDaemonDescription": "Permitir conexiones con mints a través de direcciones .onion de Tor.",
|
||||
"privacyScreen_torStatus": "Estado de Tor",
|
||||
"privacyScreen_derivedKeys": "Claves Nostr derivadas de la semilla",
|
||||
"privacyScreen_derivedKeysDescription": "Tus claves Nostr se derivan de forma determinista de tu frase semilla.",
|
||||
"privacyScreen_legacyKeysDescription": "Tu billetera usa claves Nostr aleatorias. Actualiza para derivarlas de tu semilla para una recuperación más fácil.",
|
||||
"privacyScreen_upgradeKeys": "Actualizar",
|
||||
"privacyScreen_upgradingKeys": "Actualizando claves Nostr...",
|
||||
"privateKey": "Clave privada",
|
||||
"privateNotePlaceholder": "Tu nota privada",
|
||||
"profileMissingAddressError": "Dirección faltante",
|
||||
|
||||
@@ -321,6 +321,11 @@
|
||||
"privacyScreen_torDaemon": "Usar rede Tor",
|
||||
"privacyScreen_torDaemonDescription": "Permitir conexões com mints via endereços onion Tor.",
|
||||
"privacyScreen_torStatus": "Status do Tor",
|
||||
"privacyScreen_derivedKeys": "Chaves Nostr derivadas da semente",
|
||||
"privacyScreen_derivedKeysDescription": "Suas chaves Nostr são derivadas deterministicamente da sua frase semente.",
|
||||
"privacyScreen_legacyKeysDescription": "Sua carteira usa chaves Nostr aleatórias. Atualize para derivá-las da sua semente para recuperação mais fácil.",
|
||||
"privacyScreen_upgradeKeys": "Atualizar",
|
||||
"privacyScreen_upgradingKeys": "Atualizando chaves Nostr...",
|
||||
"privateKey": "Private key",
|
||||
"privateNotePlaceholder": "Sua nota privada",
|
||||
"profileMissingAddressError": "Endereço faltando",
|
||||
|
||||
@@ -321,6 +321,11 @@
|
||||
"privacyScreen_torDaemon": "Použi Tor sieť",
|
||||
"privacyScreen_torDaemonDescription": "Umožni spojenie s mintmi na Tor .onion adresách.",
|
||||
"privacyScreen_torStatus": "Tor status",
|
||||
"privacyScreen_derivedKeys": "Nostr kľúče odvodené zo seedu",
|
||||
"privacyScreen_derivedKeysDescription": "Nostr kľúče sú deterministicky odvodené z tvojej seed frázy.",
|
||||
"privacyScreen_legacyKeysDescription": "Tvoja peňaženka používa náhodné Nostr kľúče. Aktualizuj ich na odvodené z tvojho seedu pre jednoduchšiu obnovu.",
|
||||
"privacyScreen_upgradeKeys": "Aktualizovať",
|
||||
"privacyScreen_upgradingKeys": "Aktualizujem Nostr kľúče...",
|
||||
"privateKey": "Privátny kľúč",
|
||||
"privateNotePlaceholder": "Súkromná poznámka",
|
||||
"profileMissingAddressError": "Chýbajúca adresa",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {Instance, SnapshotOut, types, flow} from 'mobx-state-tree'
|
||||
import {JwtTokens, KeyChain, log, MinibitsClient, NostrEvent, NostrKeyPair, NostrUnsignedEvent} from '../services'
|
||||
import AppError, { Err } from '../utils/AppError'
|
||||
import { MINIBITS_SERVER_API_HOST } from '@env'
|
||||
import { finalizeEvent, getEventHash, verifyEvent } from 'nostr-tools/pure'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { decodeJwtExpiry } from '../utils/authUtils'
|
||||
|
||||
@@ -48,6 +48,7 @@ export const WalletProfileStoreModel = types
|
||||
|
||||
if(!hasKeys) {
|
||||
log.debug('[publishToRelays] Profile will not be published to relays, wallet keys not yet available.')
|
||||
return
|
||||
}
|
||||
|
||||
const {pubkey, name, picture, nip05, lud16} = self
|
||||
@@ -76,7 +77,7 @@ export const WalletProfileStoreModel = types
|
||||
}
|
||||
|
||||
const relaysToPublish: string[] = relaysStore.allUrls
|
||||
const keys: NostrKeyPair = (yield rootStore.walletStore.getCachedWalletKeys() as Promise<any>).NOSTR
|
||||
const keys: NostrKeyPair = (yield KeyChain.getWalletKeys()).NOSTR
|
||||
|
||||
log.debug('[publishToRelays]', 'Publish profile to relays', {profileEvent, relaysToPublish})
|
||||
|
||||
@@ -90,8 +91,9 @@ export const WalletProfileStoreModel = types
|
||||
|
||||
} catch (e: any) {
|
||||
// on profile creation / recovery this fails at first as nostr keys are not yet saved
|
||||
log.warn(e.name, e.message)
|
||||
return false // silent
|
||||
log.warn(e.name, e.message)
|
||||
return false
|
||||
// silent
|
||||
}
|
||||
})
|
||||
}))
|
||||
@@ -106,7 +108,7 @@ export const WalletProfileStoreModel = types
|
||||
self.pubkey = pubkey
|
||||
self.walletId = walletId
|
||||
|
||||
yield self.publishToRelays()
|
||||
self.publishToRelays()
|
||||
})
|
||||
}))
|
||||
.actions(self => ({
|
||||
@@ -121,7 +123,7 @@ export const WalletProfileStoreModel = types
|
||||
profileRecord = yield MinibitsClient.createWalletProfile(walletId, seedHash)
|
||||
self.hydrate(profileRecord)
|
||||
|
||||
log.info('[create]', 'Wallet profile saved in WalletProfileStore', {self})
|
||||
log.info('[create] ', 'Wallet profile saved in WalletProfileStore', {self})
|
||||
return self
|
||||
} catch (e: any) {
|
||||
// Unlikely we might hit the same walletId so we retry with another one
|
||||
@@ -131,7 +133,7 @@ export const WalletProfileStoreModel = types
|
||||
// attempt to create new unique profile again
|
||||
profileRecord = yield MinibitsClient.createWalletProfile(name, seedHash)
|
||||
|
||||
log.error('[create]', 'Profile reset executed to resolve duplicate walletId on the server.', {caller: 'create', walletId, newWalletId: name})
|
||||
log.error('[create] ', 'Profile reset executed to resolve duplicate walletId on the server.', {caller: 'create', walletId, newWalletId: name})
|
||||
self.hydrate(profileRecord)
|
||||
|
||||
return self
|
||||
@@ -149,7 +151,7 @@ export const WalletProfileStoreModel = types
|
||||
|
||||
self.hydrate(profileRecord)
|
||||
|
||||
log.debug('[updateName]', 'Wallet name updated in the WalletProfileStore', {self})
|
||||
log.debug('[updateName] ', 'Wallet name updated in the WalletProfileStore', {self})
|
||||
return self
|
||||
}),
|
||||
updatePicture: flow(function* updatePicture(picture: string) {
|
||||
@@ -164,7 +166,7 @@ export const WalletProfileStoreModel = types
|
||||
|
||||
const publishedEvent = yield self.publishToRelays()
|
||||
|
||||
log.debug('[updatePicture]', 'Wallet picture updated in the WalletProfileStore', {self, publishedEvent})
|
||||
log.debug('[updatePicture] ', 'Wallet picture updated in the WalletProfileStore', {self, publishedEvent})
|
||||
return self
|
||||
}),
|
||||
updateNip05: flow(function* updateNip05(newPubkey: string, name: string, nip05: string, lud16: string, picture: string, isOwnProfile: boolean) {
|
||||
@@ -181,35 +183,35 @@ export const WalletProfileStoreModel = types
|
||||
}
|
||||
)
|
||||
|
||||
log.trace('[updateNip05]', 'profileRecord', {profileRecord})
|
||||
log.trace('[updateNip05] ', 'profileRecord', {profileRecord})
|
||||
|
||||
self.hydrate(profileRecord)
|
||||
self.isOwnProfile = isOwnProfile
|
||||
|
||||
log.info('[updateNip05]', 'Wallet nip05 updated in the WalletProfileStore', {self})
|
||||
log.info('[updateNip05] ', 'Wallet nip05 updated in the WalletProfileStore', {self})
|
||||
return self
|
||||
}),
|
||||
recover: flow(function* recover(walletId: string, seedHash: string) {
|
||||
recover: flow(function* recover(walletId: string, seedHash: string, newPubkey?: string) {
|
||||
|
||||
let profileRecord: WalletProfileRecord = yield MinibitsClient.recoverProfile(
|
||||
walletId, seedHash
|
||||
)
|
||||
|
||||
walletId, seedHash, newPubkey
|
||||
)
|
||||
|
||||
self.hydrate(profileRecord)
|
||||
|
||||
log.info('[recover]', 'Wallet profile recovered in WalletProfileStore', {self})
|
||||
return self
|
||||
log.info('[recover] ', 'Wallet profile recovered in WalletProfileStore', {self, pubkeyRotated: !!newPubkey})
|
||||
return self
|
||||
}),
|
||||
recoverAddress: flow(function* recover(walletId: string, seedHash: string) {
|
||||
recoverAddress: flow(function* recoverAddress(walletId: string, seedHash: string, newPubkey?: string) {
|
||||
|
||||
let profileRecord: WalletProfileRecord = yield MinibitsClient.recoverAddress(
|
||||
walletId, seedHash
|
||||
)
|
||||
|
||||
walletId, seedHash, newPubkey
|
||||
)
|
||||
|
||||
self.hydrate(profileRecord)
|
||||
|
||||
log.info('[recover]', 'Wallet profile recovered in WalletProfileStore', {self})
|
||||
return self
|
||||
log.info('[recoverAddress] ', 'Wallet address recovered in WalletProfileStore', {self, pubkeyRotated: !!newPubkey})
|
||||
return self
|
||||
}),
|
||||
setDevice: flow(function* setDevice(device: string) {
|
||||
try {
|
||||
@@ -221,7 +223,7 @@ export const WalletProfileStoreModel = types
|
||||
yield MinibitsClient.updateDeviceToken(device)
|
||||
self.device = device
|
||||
} catch (e: any) {
|
||||
log.error('[setDevice]', e.message)
|
||||
log.error('[setDevice] ', e.message)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -231,12 +231,15 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route }
|
||||
if(!seedHashRef.current || !seedRef.current) {
|
||||
throw new AppError(Err.VALIDATION_ERROR, translate('backupMissingMnemonicOrSeedError'))
|
||||
}
|
||||
// create a new walletId and a new Nostr key pair
|
||||
// and keep provided seed
|
||||
// Derive Nostr keys from the recovered mnemonic and keep provided seed
|
||||
setIsLoading(true)
|
||||
setStatusMessage(translate("recovery_recoveringAddress"))
|
||||
|
||||
const keys = await walletStore.getCachedWalletKeys()
|
||||
|
||||
// Derive Nostr keypair from the recovered mnemonic (NIP-06)
|
||||
const nostrKeys = KeyChain.deriveNostrKeyPair(mnemonic)
|
||||
|
||||
// Set seed to the provided one
|
||||
const seed = {
|
||||
seed: Buffer.from(seedRef.current).toString('base64'),
|
||||
@@ -246,30 +249,37 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route }
|
||||
|
||||
// In case there is a profile linked to provided seedHash,
|
||||
// it's address, avatar and seed is recovered to the current profile.
|
||||
// Pass newPubkey so server rotates the profile to the derived pubkey.
|
||||
await walletProfileStore.recover(
|
||||
keys.walletId,
|
||||
keys.walletId,
|
||||
seedHashRef.current,
|
||||
nostrKeys.publicKey
|
||||
)
|
||||
|
||||
// update seed to the provided one
|
||||
// Update both seed and Nostr keys to derived values
|
||||
const keysCopy = { ...keys }
|
||||
keysCopy.SEED = seed
|
||||
keysCopy.NOSTR = nostrKeys
|
||||
|
||||
await KeyChain.saveWalletKeys(keysCopy)
|
||||
await KeyChain.saveWalletKeys(keysCopy)
|
||||
walletStore.cleanCachedWalletKeys()
|
||||
|
||||
// Re-authenticate with new derived keys to get fresh JWT tokens
|
||||
await authStore.clearTokens()
|
||||
await authStore.enrollDevice(nostrKeys)
|
||||
|
||||
if(!mintsStore.mintExists(MINIBITS_MINT_URL)) {
|
||||
await mintsStore.addMint(MINIBITS_MINT_URL)
|
||||
await mintsStore.addMint(MINIBITS_MINT_URL)
|
||||
}
|
||||
|
||||
setStatusMessage(translate('recovery_completed'))
|
||||
|
||||
// go directly to the wallet (profile hase been rehydrated from the one with the seed)
|
||||
|
||||
// go directly to the wallet (profile has been rehydrated from the one with the seed)
|
||||
//@ts-ignore
|
||||
navigation.navigate('Tabs')
|
||||
await delay(1000)
|
||||
setStatusMessage('')
|
||||
setIsLoading(false)
|
||||
setIsLoading(false)
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ type Props = StaticScreenProps<{}>
|
||||
export const OwnKeysScreen = observer(function OwnKeysScreen({ route }: Props) {
|
||||
|
||||
const navigation = useNavigation()
|
||||
const {walletProfileStore, walletStore, relaysStore} = useStores()
|
||||
const {walletProfileStore, walletStore, relaysStore, authStore} = useStores()
|
||||
|
||||
useHeader({
|
||||
leftIcon: 'faArrowLeft',
|
||||
@@ -190,17 +190,17 @@ export const OwnKeysScreen = observer(function OwnKeysScreen({ route }: Props) {
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
// update wallet profile
|
||||
const updatedProfile = await walletProfileStore.updateNip05(
|
||||
// update wallet profile on server
|
||||
await walletProfileStore.updateNip05(
|
||||
ownProfile.pubkey,
|
||||
ownProfile.name as string,
|
||||
ownProfile.nip05 as string,
|
||||
ownProfile.nip05 as string,
|
||||
ownProfile.lud16 || '',
|
||||
ownProfile.picture as string,
|
||||
true // isOwnProfile
|
||||
true // isOwnProfile
|
||||
)
|
||||
|
||||
// update Nostr keys
|
||||
// update Nostr keys locally
|
||||
const cachedKeys = await walletStore.getCachedWalletKeys()
|
||||
const keys = { ...cachedKeys } // Create a shallow copy to avoid modifying readonly properties
|
||||
keys['NOSTR'] = ownKeyPair
|
||||
@@ -208,9 +208,13 @@ export const OwnKeysScreen = observer(function OwnKeysScreen({ route }: Props) {
|
||||
await KeyChain.saveWalletKeys(keys)
|
||||
walletStore.cleanCachedWalletKeys()
|
||||
|
||||
// Re-authenticate with new keys to get fresh JWT tokens
|
||||
await authStore.clearTokens()
|
||||
await authStore.enrollDevice(ownKeyPair)
|
||||
|
||||
setIsLoading(false)
|
||||
setIsProfileChangeCompleted(true)
|
||||
|
||||
|
||||
} catch(e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
|
||||
+130
-14
@@ -16,7 +16,7 @@ import {
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {useStores} from '../models'
|
||||
import AppError from '../utils/AppError'
|
||||
import { KeyChain, MinibitsClient, } from '../services'
|
||||
import { KeyChain, MinibitsClient, log } from '../services'
|
||||
import { MINIBITS_NIP05_DOMAIN } from '@env'
|
||||
import { StackActions, StaticScreenProps, useNavigation } from '@react-navigation/native'
|
||||
import { translate } from '../i18n'
|
||||
@@ -32,13 +32,30 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) {
|
||||
}
|
||||
})
|
||||
|
||||
const {userSettingsStore, walletProfileStore, walletStore} = useStores()
|
||||
const {userSettingsStore, walletProfileStore, walletStore, authStore} = useStores()
|
||||
const [info, setInfo] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [statusMessage, setStatusMessage] = useState<string>('')
|
||||
const [isLoggerOn, setIsLoggerOn] = useState<boolean>(
|
||||
userSettingsStore.isLoggerOn,
|
||||
)
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
const [isDerivedKeys, setIsDerivedKeys] = useState<boolean>(false)
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
|
||||
// Check if Nostr keys are derived from seed on mount
|
||||
useEffect(() => {
|
||||
const checkDerivedKeys = async () => {
|
||||
try {
|
||||
const keys = await walletStore.getCachedWalletKeys()
|
||||
if (keys) {
|
||||
setIsDerivedKeys(KeyChain.areNostrKeysDerived(keys))
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('[PrivacyScreen] Error checking derived keys', e)
|
||||
}
|
||||
}
|
||||
checkDerivedKeys()
|
||||
}, [])
|
||||
|
||||
const toggleLoggerSwitch = async () => {
|
||||
try {
|
||||
@@ -47,7 +64,55 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) {
|
||||
)
|
||||
|
||||
setIsLoggerOn(result)
|
||||
|
||||
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const upgradeToNostrDerivedKeys = async () => {
|
||||
setIsLoading(true)
|
||||
setStatusMessage(translate('privacyScreen_upgradingKeys'))
|
||||
|
||||
try {
|
||||
const keys = await walletStore.getCachedWalletKeys()
|
||||
if (!keys || !keys.SEED?.mnemonic) {
|
||||
throw new Error('Wallet keys or mnemonic not available')
|
||||
}
|
||||
|
||||
// Derive Nostr keys from mnemonic using NIP-06
|
||||
const derivedNostrKeys = KeyChain.deriveNostrKeyPair(keys.SEED.mnemonic)
|
||||
|
||||
log.trace('[upgradeToNostrDerivedKeys]', {
|
||||
oldPubkey: keys.NOSTR.publicKey,
|
||||
newPubkey: derivedNostrKeys.publicKey
|
||||
})
|
||||
|
||||
// Update server profile with the new derived pubkey
|
||||
// Uses recover endpoint with same seedHash to atomically rotate pubkey
|
||||
await walletProfileStore.recover(
|
||||
keys.walletId,
|
||||
keys.SEED.seedHash,
|
||||
derivedNostrKeys.publicKey
|
||||
)
|
||||
|
||||
// Update local keys
|
||||
const keysCopy = { ...keys }
|
||||
keysCopy.NOSTR = derivedNostrKeys
|
||||
|
||||
await KeyChain.saveWalletKeys(keysCopy)
|
||||
walletStore.cleanCachedWalletKeys()
|
||||
|
||||
// Re-authenticate with new derived keys to get fresh JWT tokens
|
||||
await authStore.clearTokens()
|
||||
await authStore.enrollDevice(derivedNostrKeys)
|
||||
|
||||
// Update state
|
||||
setIsDerivedKeys(true)
|
||||
setStatusMessage('')
|
||||
setIsLoading(false)
|
||||
setInfo(translate('privacyScreen_derivedKeysDescription'))
|
||||
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
@@ -59,16 +124,20 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) {
|
||||
}
|
||||
|
||||
const resetProfile = async function() {
|
||||
setIsLoading(true)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// overwrite with new keys
|
||||
const newKeyPair = KeyChain.generateNostrKeyPair()
|
||||
const cachedKeys = await walletStore.getCachedWalletKeys()
|
||||
if (!cachedKeys || !cachedKeys.SEED?.mnemonic) {
|
||||
throw new Error('Wallet keys or mnemonic not available')
|
||||
}
|
||||
|
||||
// Derive Nostr keys from mnemonic (NIP-06) instead of generating random keys
|
||||
const derivedKeyPair = KeyChain.deriveNostrKeyPair(cachedKeys.SEED.mnemonic)
|
||||
|
||||
// update Nostr keys
|
||||
const cachedKeys = await walletStore.getCachedWalletKeys()
|
||||
const keys = { ...cachedKeys } // Create a shallow copy to avoid modifying readonly properties
|
||||
keys['NOSTR'] = newKeyPair
|
||||
const keys = { ...cachedKeys }
|
||||
keys['NOSTR'] = derivedKeyPair
|
||||
|
||||
await KeyChain.saveWalletKeys(keys)
|
||||
walletStore.cleanCachedWalletKeys()
|
||||
@@ -79,15 +148,23 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) {
|
||||
// get random image
|
||||
const pictures = await MinibitsClient.getRandomPictures() // TODO PERF
|
||||
|
||||
// update wallet profile
|
||||
// update wallet profile on server
|
||||
await walletProfileStore.updateNip05(
|
||||
newKeyPair.publicKey,
|
||||
derivedKeyPair.publicKey,
|
||||
name,
|
||||
name + MINIBITS_NIP05_DOMAIN, // nip05
|
||||
name + MINIBITS_NIP05_DOMAIN, // lud16
|
||||
pictures[0],
|
||||
false // isOwnProfile
|
||||
)
|
||||
|
||||
// Re-authenticate with derived keys to get fresh JWT tokens
|
||||
await authStore.clearTokens()
|
||||
await authStore.enrollDevice(derivedKeyPair)
|
||||
|
||||
// Update derived keys state
|
||||
setIsDerivedKeys(true)
|
||||
|
||||
// @ts-ignore
|
||||
navigation.navigate('ContactsNavigator', {
|
||||
screen: 'Profile'
|
||||
@@ -229,7 +306,46 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) {
|
||||
<Card
|
||||
style={[$card, {marginTop: spacing.medium}]}
|
||||
ContentComponent={
|
||||
<>
|
||||
<>
|
||||
<ListItem
|
||||
tx="privacyScreen_derivedKeys"
|
||||
subTx={isDerivedKeys
|
||||
? "privacyScreen_derivedKeysDescription"
|
||||
: "privacyScreen_legacyKeysDescription"
|
||||
}
|
||||
leftIcon={'faKey'}
|
||||
leftIconColor={
|
||||
isDerivedKeys
|
||||
? colors.palette.success200
|
||||
: iconColor as string
|
||||
}
|
||||
leftIconInverse={true}
|
||||
RightComponent={
|
||||
isDerivedKeys ? (
|
||||
<View style={$rightContainer}>
|
||||
<Switch
|
||||
value={true}
|
||||
disabled={true}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
style={{maxHeight: 10, marginTop: spacing.medium}}
|
||||
preset="secondary"
|
||||
tx="privacyScreen_upgradeKeys"
|
||||
onPress={upgradeToNostrDerivedKeys}
|
||||
/>
|
||||
)
|
||||
}
|
||||
style={$item}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
style={[$card, {marginTop: spacing.medium}]}
|
||||
ContentComponent={
|
||||
<>
|
||||
<ListItem
|
||||
tx="privacyScreen_logger"
|
||||
subTx="privacyScreen_loggerDescription"
|
||||
@@ -253,7 +369,7 @@ export const PrivacyScreen = observer(function PrivacyScreen({ route }: Props) {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{isLoading && <Loading />}
|
||||
{isLoading && <Loading statusMessage={statusMessage} />}
|
||||
</ScrollView>
|
||||
{/*<BottomModal
|
||||
isVisible={isTorModalVisible ? true : false}
|
||||
|
||||
@@ -266,30 +266,30 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route }
|
||||
const { updatedAmount: addedAmount } = proofsStore.addOrUpdate(proofStates.UNSPENT, {
|
||||
mintUrl: recoveredMint.mintUrl,
|
||||
unit: selectedKeyset.unit as MintUnit,
|
||||
tId: transaction.id,
|
||||
tId: transaction!.id,
|
||||
isPending: false,
|
||||
isSpent: false,
|
||||
})
|
||||
isSpent: false,
|
||||
})
|
||||
|
||||
if (amount !== addedAmount) {
|
||||
transaction.update({amount: addedAmount})
|
||||
transaction!.update({amount: addedAmount})
|
||||
recoveredAmount = addedAmount
|
||||
}
|
||||
|
||||
// Finally, update completed transaction
|
||||
transactionData.push({
|
||||
status: TransactionStatus.COMPLETED,
|
||||
recoveredAmount,
|
||||
recoveredAmount,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
transaction.update({
|
||||
transaction!.update({
|
||||
status: TransactionStatus.COMPLETED,
|
||||
data: JSON.stringify(transactionData)
|
||||
})
|
||||
|
||||
const balanceAfter = proofsStore.getUnitBalance(selectedKeyset.unit as MintUnit)?.unitBalance
|
||||
transaction.update({balanceAfter})
|
||||
transaction!.update({balanceAfter})
|
||||
}
|
||||
|
||||
if(proofStates.PENDING.length > 0) {
|
||||
@@ -322,22 +322,22 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route }
|
||||
const { updatedAmount: addedAmount } = proofsStore.addOrUpdate(proofStates.PENDING, {
|
||||
mintUrl: recoveredMint.mintUrl,
|
||||
unit: selectedKeyset.unit as MintUnit,
|
||||
tId: pendingTransaction.id,
|
||||
tId: pendingTransaction!.id,
|
||||
isPending: true,
|
||||
isSpent: false,
|
||||
isSpent: false,
|
||||
})
|
||||
|
||||
if (pendingAmount !== addedAmount) {
|
||||
pendingTransaction.update({amount: addedAmount})
|
||||
pendingTransaction!.update({amount: addedAmount})
|
||||
}
|
||||
|
||||
// Finally, update pending transaction
|
||||
pendingTransactionData.push({
|
||||
status: TransactionStatus.PENDING,
|
||||
status: TransactionStatus.PENDING,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
pendingTransaction.update({
|
||||
pendingTransaction!.update({
|
||||
status: TransactionStatus.PENDING,
|
||||
data: JSON.stringify(pendingTransactionData)
|
||||
})
|
||||
@@ -429,12 +429,15 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route }
|
||||
if(!seedHashRef.current || !seedRef.current) {
|
||||
throw new AppError(Err.VALIDATION_ERROR, translate('backupMissingMnemonicOrSeedError'))
|
||||
}
|
||||
// create a new walletId and a new Nostr key pair
|
||||
// and keep provided seed
|
||||
// Derive Nostr keys from the recovered mnemonic and keep provided seed
|
||||
setIsLoading(true)
|
||||
setStatusMessage(translate("recovery_recoveringAddress"))
|
||||
|
||||
const keys = await walletStore.getCachedWalletKeys()
|
||||
|
||||
// Derive Nostr keypair from the recovered mnemonic (NIP-06)
|
||||
const nostrKeys = KeyChain.deriveNostrKeyPair(mnemonic)
|
||||
|
||||
// Set seed to the provided one
|
||||
const seed = {
|
||||
seed: Buffer.from(seedRef.current).toString('base64'),
|
||||
@@ -444,30 +447,37 @@ export const SeedRecoveryScreen = observer(function SeedRecoveryScreen({ route }
|
||||
|
||||
// In case there is a profile linked to provided seedHash,
|
||||
// it's address, avatar and seed is recovered to the current profile.
|
||||
// Pass newPubkey so server rotates the profile to the derived pubkey.
|
||||
await walletProfileStore.recover(
|
||||
keys.walletId,
|
||||
keys.walletId,
|
||||
seedHashRef.current,
|
||||
nostrKeys.publicKey
|
||||
)
|
||||
|
||||
// update seed to the provided one
|
||||
// Update both seed and Nostr keys to derived values
|
||||
const keysCopy = { ...keys }
|
||||
keysCopy.SEED = seed
|
||||
keysCopy.NOSTR = nostrKeys
|
||||
|
||||
await KeyChain.saveWalletKeys(keysCopy)
|
||||
await KeyChain.saveWalletKeys(keysCopy)
|
||||
walletStore.cleanCachedWalletKeys()
|
||||
|
||||
// Re-authenticate with new derived keys to get fresh JWT tokens
|
||||
await authStore.clearTokens()
|
||||
await authStore.enrollDevice(nostrKeys)
|
||||
|
||||
if(!mintsStore.mintExists(MINIBITS_MINT_URL)) {
|
||||
await mintsStore.addMint(MINIBITS_MINT_URL)
|
||||
await mintsStore.addMint(MINIBITS_MINT_URL)
|
||||
}
|
||||
|
||||
setStatusMessage(translate('recovery_completed'))
|
||||
|
||||
// go directly to the wallet (profile hase been rehydrated from the one with the seed)
|
||||
|
||||
// go directly to the wallet (profile has been rehydrated from the one with the seed)
|
||||
//@ts-ignore
|
||||
navigation.navigate('Tabs')
|
||||
await delay(1000)
|
||||
setStatusMessage('')
|
||||
setIsLoading(false)
|
||||
setIsLoading(false)
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC, useEffect, useRef, useState} from 'react'
|
||||
import {AppState, LayoutAnimation, Platform, ScrollView, TextStyle, View, ViewStyle, useColorScheme} from 'react-native'
|
||||
import React, {FC, useEffect, useLayoutEffect, useRef, useState} from 'react'
|
||||
import {AppState, Platform, TextStyle, View, ViewStyle, useColorScheme} from 'react-native'
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
useAnimatedScrollHandler,
|
||||
interpolate,
|
||||
Extrapolation,
|
||||
} from 'react-native-reanimated'
|
||||
import notifee, { AuthorizationStatus } from '@notifee/react-native'
|
||||
import messaging from '@react-native-firebase/messaging'
|
||||
import { HotUpdater, getUpdateSource } from '@hot-updater/react-native'
|
||||
@@ -8,20 +15,16 @@ import {
|
||||
HOT_UPDATER_API_KEY,
|
||||
HOT_UPDATER_URL,
|
||||
} from '@env'
|
||||
import {ThemeCode, Themes, colors, spacing, useThemeColor} from '../theme'
|
||||
import {ListItem, Screen, Text, Card, NwcIcon, Button, BottomModal, InfoModal, Icon} from '../components'
|
||||
import {useHeader} from '../utils/useHeader'
|
||||
import {ThemeCode, Themes, colors, spacing, typography, useThemeColor} from '../theme'
|
||||
import {ListItem, Screen, Text, Card, NwcIcon, Button, BottomModal, InfoModal, Icon, Header} from '../components'
|
||||
import {useStores} from '../models'
|
||||
import {translate} from '../i18n'
|
||||
import { log } from '../services'
|
||||
|
||||
import {Env} from '../utils/envtypes'
|
||||
import { round } from '../utils/number'
|
||||
import { Currencies, CurrencyCode, availableExchangeCurrencies } from '../services/wallet/currency'
|
||||
import { NotificationService } from '../services/notificationService'
|
||||
import { SvgXml } from 'react-native-svg'
|
||||
import { CurrencySign } from './Wallet/CurrencySign'
|
||||
import { CommonActions, StaticScreenProps, useNavigation } from '@react-navigation/native'
|
||||
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
|
||||
|
||||
|
||||
|
||||
@@ -29,7 +32,22 @@ type Props = StaticScreenProps<undefined>
|
||||
|
||||
export const SettingsScreen = observer(function SettingsScreen({ route }: Props) {
|
||||
const navigation = useNavigation()
|
||||
useHeader({}) // default header component
|
||||
const scrollY = useSharedValue(0)
|
||||
const HEADER_SCROLL_DISTANCE = spacing.screenHeight * 0.07
|
||||
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerShown: true,
|
||||
header: () => (
|
||||
<Header
|
||||
titleTx="settingsScreen_title"
|
||||
scrollY={scrollY}
|
||||
scrollDistance={HEADER_SCROLL_DISTANCE}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}, [])
|
||||
|
||||
const appState = useRef(AppState.currentState)
|
||||
const {
|
||||
mintsStore,
|
||||
@@ -49,7 +67,6 @@ export const SettingsScreen = observer(function SettingsScreen({ route }: Props)
|
||||
const [isThemeModalVisible, setIsThemeModalVisible] = useState<boolean>(false)
|
||||
const [isNativeUpdateAvailable, setIsNativeUpdateAvailable] = useState<boolean>(false)
|
||||
const [areNotificationsEnabled, setAreNotificationsEnabled] = useState<boolean>(false)
|
||||
const [isHeaderVisible, setIsHeaderVisible] = useState(true)
|
||||
const [info, setInfo] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -234,10 +251,6 @@ export const SettingsScreen = observer(function SettingsScreen({ route }: Props)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* const gotoPreferredUnit = function() {
|
||||
Alert.alert('Preferred unit is set based on your Wallet screen.')
|
||||
} */
|
||||
|
||||
|
||||
const getRateColor = function () {
|
||||
@@ -289,42 +302,71 @@ export const SettingsScreen = observer(function SettingsScreen({ route }: Props)
|
||||
toggleThemeModal()
|
||||
}
|
||||
|
||||
const collapseHeader = function () {
|
||||
LayoutAnimation.easeInEaseOut()
|
||||
setIsHeaderVisible(false)
|
||||
|
||||
}
|
||||
|
||||
const expandHeader = function () {
|
||||
LayoutAnimation.easeInEaseOut()
|
||||
setIsHeaderVisible(true)
|
||||
}
|
||||
|
||||
/* const isCloseToBottom = function ({layoutMeasurement, contentOffset, contentSize}){
|
||||
return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20
|
||||
}
|
||||
|
||||
const isCloseToTop = function({layoutMeasurement, contentOffset, contentSize}){
|
||||
return contentOffset.y == 0;
|
||||
} */
|
||||
|
||||
const $itemRight = {color: useThemeColor('textDim')}
|
||||
const headerBg = useThemeColor('header')
|
||||
const headerTitle = useThemeColor('headerTitle')
|
||||
const headerTitle = useThemeColor('headerTitle')
|
||||
const colorScheme = useColorScheme()
|
||||
const defaultThemeColor = colorScheme === 'dark' ? Themes[ThemeCode.DARK]?.color : Themes[ThemeCode.LIGHT]?.color
|
||||
|
||||
|
||||
// Collapsible header animation
|
||||
const HEADER_MAX_HEIGHT = spacing.screenHeight * 0.15
|
||||
const HEADER_MIN_HEIGHT = spacing.screenHeight * 0.08
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
onScroll: (event) => {
|
||||
scrollY.value = event.contentOffset.y
|
||||
},
|
||||
})
|
||||
|
||||
const animatedHeaderStyle = useAnimatedStyle(() => {
|
||||
const height = interpolate(
|
||||
scrollY.value,
|
||||
[0, HEADER_SCROLL_DISTANCE],
|
||||
[HEADER_MAX_HEIGHT, HEADER_MIN_HEIGHT],
|
||||
Extrapolation.CLAMP
|
||||
)
|
||||
return { height }
|
||||
})
|
||||
|
||||
const animatedTitleStyle = useAnimatedStyle(() => {
|
||||
const scale = interpolate(
|
||||
scrollY.value,
|
||||
[0, HEADER_SCROLL_DISTANCE],
|
||||
[1, 0.75],
|
||||
Extrapolation.CLAMP
|
||||
)
|
||||
const translateY = interpolate(
|
||||
scrollY.value,
|
||||
[0, HEADER_SCROLL_DISTANCE],
|
||||
[0, -spacing.extraLarge * 1.5],
|
||||
Extrapolation.CLAMP
|
||||
)
|
||||
const opacity = interpolate(
|
||||
scrollY.value,
|
||||
[0, HEADER_SCROLL_DISTANCE * 0.8],
|
||||
[1, 0],
|
||||
Extrapolation.CLAMP
|
||||
)
|
||||
return {
|
||||
transform: [{ scale }, { translateY }],
|
||||
opacity,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Screen contentContainerStyle={$screen} preset='fixed'>
|
||||
<View style={[isHeaderVisible ? $headerContainer : $headerCollapsed, {backgroundColor: headerBg}]}>
|
||||
<Text
|
||||
preset='heading'
|
||||
tx='settingsScreen_title'
|
||||
style={{color: headerTitle}}
|
||||
/>
|
||||
</View>
|
||||
<ScrollView
|
||||
<Animated.View style={[animatedHeaderStyle, $headerContainer, {backgroundColor: headerBg}]}>
|
||||
<Animated.Text
|
||||
style={[animatedTitleStyle, $headerTitle, {color: headerTitle}]}
|
||||
>
|
||||
{translate('settingsScreen_title')}
|
||||
</Animated.Text>
|
||||
</Animated.View>
|
||||
<Animated.ScrollView
|
||||
style={$contentContainer}
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
<Card
|
||||
style={$card}
|
||||
@@ -526,7 +568,7 @@ export const SettingsScreen = observer(function SettingsScreen({ route }: Props)
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ScrollView>
|
||||
</Animated.ScrollView>
|
||||
<BottomModal
|
||||
isVisible={isCurrencyModalVisible ? true : false}
|
||||
style={{alignItems: 'stretch'}}
|
||||
@@ -595,18 +637,20 @@ const $screen: ViewStyle = {
|
||||
|
||||
}
|
||||
|
||||
const $headerContainer: TextStyle = {
|
||||
const $headerContainer: ViewStyle = {
|
||||
alignItems: 'center',
|
||||
paddingBottom: spacing.medium,
|
||||
height: spacing.screenHeight * 0.15,
|
||||
//justifyContent: 'flex-end',
|
||||
//paddingBottom: spacing.extraLarge * 2 + spacing.medium,
|
||||
//overflow: 'visible',
|
||||
}
|
||||
|
||||
const $headerCollapsed: TextStyle = {
|
||||
alignItems: 'center',
|
||||
paddingBottom: spacing.medium,
|
||||
height: spacing.screenHeight * 0.08,
|
||||
const $headerTitle: TextStyle = {
|
||||
fontSize: spacing.extraLarge,
|
||||
//lineHeight: 44,
|
||||
fontFamily: typography.primary?.medium,
|
||||
}
|
||||
|
||||
|
||||
const $contentContainer: TextStyle = {
|
||||
marginTop: -spacing.extraLarge * 2,
|
||||
padding: spacing.extraSmall,
|
||||
|
||||
+79
-27
@@ -1,18 +1,21 @@
|
||||
import * as _Keychain from 'react-native-keychain'
|
||||
import AppError, {Err} from '../utils/AppError'
|
||||
import QuickCrypto from 'react-native-quick-crypto'
|
||||
import { generateMnemonic as generateNewMnemonic, mnemonicToSeedSync } from "@scure/bip39"
|
||||
import { wordlist } from "@scure/bip39/wordlists/english"
|
||||
|
||||
import * as bip39 from '@scure/bip39'
|
||||
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
||||
import { accountFromSeedWords } from 'nostr-tools/nip06'
|
||||
import {log} from './logService'
|
||||
import { getRandomUsername } from '../utils/usernames'
|
||||
|
||||
// Default account index for NIP-06 derivation (future multi-account support)
|
||||
const DEFAULT_NOSTR_ACCOUNT_INDEX = 0
|
||||
|
||||
|
||||
export enum KeyChainServiceName {
|
||||
// NOSTR = 'app.minibits.nostr',
|
||||
// SEED = 'app.minibits.seed',
|
||||
// MNEMONIC = 'app.minibits.mnemonic',
|
||||
BIOMETRIC_AUTH = 'app.minibits.auth',
|
||||
KEYS = 'app.minibits.keys',
|
||||
JWT_TOKENS = 'app.minibits.jwt',
|
||||
@@ -55,28 +58,13 @@ const getSupportedBiometryType = async function () {
|
||||
}
|
||||
|
||||
|
||||
const generateMnemonic = function () {
|
||||
try {
|
||||
log.trace('[generateMnemonic]', 'start')
|
||||
|
||||
const mnemonic = generateNewMnemonic(wordlist)
|
||||
|
||||
log.trace('[generateMnemonic]', 'New mnemonic created:', {mnemonic})
|
||||
|
||||
return mnemonic
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.KEYCHAIN_ERROR, e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const generateNostrKeyPair = function () {
|
||||
try {
|
||||
const privateKeyBytes = generateSecretKey() // Uint8Array
|
||||
const privateKey = bytesToHex(privateKeyBytes)
|
||||
const publicKey = getPublicKey(privateKeyBytes)
|
||||
|
||||
log.trace('New Nostr keypair created', {publicKey, privateKey})
|
||||
log.trace('New random Nostr keypair created', {publicKey, privateKey})
|
||||
|
||||
return {publicKey, privateKey} as NostrKeyPair
|
||||
} catch (e: any) {
|
||||
@@ -85,17 +73,80 @@ const generateNostrKeyPair = function () {
|
||||
}
|
||||
|
||||
|
||||
const generateWalletKeys = function () {
|
||||
/**
|
||||
* Derive Nostr keypair from mnemonic using NIP-06
|
||||
* Uses BIP-32 derivation path: m/44'/1237'/{accountIndex}'/0/0
|
||||
*
|
||||
* @param mnemonic BIP-39 mnemonic phrase
|
||||
* @param accountIndex Account index for derivation (default 0, for future multi-account support)
|
||||
*/
|
||||
const deriveNostrKeyPair = function (
|
||||
mnemonic: string,
|
||||
accountIndex: number = DEFAULT_NOSTR_ACCOUNT_INDEX
|
||||
): NostrKeyPair {
|
||||
try {
|
||||
const mnemonic = generateMnemonic()
|
||||
const seedBytesArray = mnemonicToSeedSync(mnemonic)
|
||||
const { privateKey: privateKeyBytes, publicKey } = accountFromSeedWords(
|
||||
mnemonic,
|
||||
'', // No passphrase
|
||||
accountIndex
|
||||
)
|
||||
const privateKey = bytesToHex(privateKeyBytes)
|
||||
|
||||
log.trace('[deriveNostrKeyPair] Nostr keypair derived from mnemonic', {publicKey, accountIndex})
|
||||
|
||||
return { publicKey, privateKey }
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.KEYCHAIN_ERROR, e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the wallet's Nostr keys are derived from the mnemonic (NIP-06)
|
||||
* Returns true if keys match, false if they are legacy random keys
|
||||
*
|
||||
* @param walletKeys The wallet keys to check
|
||||
*/
|
||||
const areNostrKeysDerived = function (walletKeys: WalletKeys): boolean {
|
||||
try {
|
||||
const { NOSTR, SEED } = walletKeys
|
||||
|
||||
if (!SEED?.mnemonic || !NOSTR?.publicKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Derive what the keys should be from the mnemonic
|
||||
const derivedKeys = deriveNostrKeyPair(SEED.mnemonic)
|
||||
|
||||
// Compare with stored keys
|
||||
const isDerived = derivedKeys.publicKey === NOSTR.publicKey
|
||||
|
||||
log.trace('[areNostrKeysDerived]', { isDerived, storedPubkey: NOSTR.publicKey, derivedPubkey: derivedKeys.publicKey })
|
||||
|
||||
return isDerived
|
||||
} catch (e: any) {
|
||||
log.error('[areNostrKeysDerived] Error checking keys', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const generateWalletKeys = function (accountIndex: number = DEFAULT_NOSTR_ACCOUNT_INDEX) {
|
||||
try {
|
||||
const mnemonic = bip39.generateMnemonic(wordlist)
|
||||
log.trace('[generateMnemonic]', 'New mnemonic created:', {mnemonic})
|
||||
|
||||
const seedBytesArray = bip39.mnemonicToSeedSync(mnemonic)
|
||||
const seed = Buffer.from(seedBytesArray).toString('base64')
|
||||
log.trace('[generateWalletKeys] Seed generated from mnemonic (base64)', {seed})
|
||||
|
||||
const seedHash = QuickCrypto
|
||||
.createHash('sha256')
|
||||
.update(seedBytesArray)
|
||||
.digest('hex')
|
||||
const nostrKeys = generateNostrKeyPair()
|
||||
|
||||
// Derive Nostr keys from mnemonic using NIP-06 (BIP-32 derivation path m/44'/1237'/{account}'/0/0)
|
||||
const nostrKeys = deriveNostrKeyPair(mnemonic, accountIndex)
|
||||
const walletId = getRandomUsername()
|
||||
|
||||
const walletKeys: WalletKeys = {
|
||||
@@ -114,12 +165,13 @@ const generateWalletKeys = function () {
|
||||
log.trace('[generateWalletKeys] New keys', {walletKeys})
|
||||
|
||||
return walletKeys
|
||||
|
||||
|
||||
} catch (e: any) {
|
||||
throw new AppError(Err.KEYCHAIN_ERROR, e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Save wallet keys in KeyChain/KeyStore
|
||||
*
|
||||
@@ -428,9 +480,9 @@ const removeJwtTokens = async function (): Promise<boolean> {
|
||||
|
||||
export const KeyChain = {
|
||||
getSupportedBiometryType,
|
||||
|
||||
generateMnemonic,
|
||||
generateNostrKeyPair,
|
||||
deriveNostrKeyPair,
|
||||
areNostrKeysDerived,
|
||||
generateWalletKeys,
|
||||
saveWalletKeys,
|
||||
getWalletKeys,
|
||||
|
||||
@@ -196,39 +196,45 @@ const updateDeviceToken = async function (deviceToken: string) {
|
||||
return walletProfile
|
||||
}
|
||||
|
||||
// recover profile address, avatar and seed from former profile (if exists, otherwise only seed is linked) to the current one.
|
||||
const recoverProfile = async function (walletId: string, seedHash: string) {
|
||||
// recover profile address, avatar and seedHash from former profile (if exists, otherwise only seedHash is linked) to the current one.
|
||||
// newPubkey is the NIP-06 derived pubkey from the recovered mnemonic - server will rotate profile to this pubkey
|
||||
const recoverProfile = async function (walletId: string, seedHash: string, newPubkey?: string) {
|
||||
const url = MINIBITS_SERVER_API_HOST + '/profile'
|
||||
const method = 'PUT'
|
||||
|
||||
const body = {
|
||||
const method = 'PUT'
|
||||
|
||||
const body: { walletId: string, seedHash: string, newPubkey?: string } = {
|
||||
walletId,
|
||||
seedHash
|
||||
}
|
||||
seedHash
|
||||
}
|
||||
|
||||
if (newPubkey) {
|
||||
body.newPubkey = newPubkey
|
||||
}
|
||||
|
||||
const walletProfile = await fetchApi(url + `/recover`, {
|
||||
method,
|
||||
method,
|
||||
body,
|
||||
jwtAuthRequired: true
|
||||
}) as WalletProfileRecord
|
||||
|
||||
log.info('[recoverProfile]', `Recovered wallet profile `, {walletAddress: walletProfile.nip05})
|
||||
log.info('[recoverProfile]', `Recovered wallet profile `, {walletAddress: walletProfile.nip05, pubkeyRotated: !!newPubkey})
|
||||
|
||||
return walletProfile
|
||||
}
|
||||
|
||||
// recover address only to the current profile, keep current avatar and seed
|
||||
const recoverAddress = async function (walletId: string, seedHash: string) {
|
||||
// recover address only from the profile identified by seedHash to the current profile
|
||||
// keep current avatar, seed and nostr keys.
|
||||
const recoverAddress = async function (walletId: string, seedHash: string) {
|
||||
const url = MINIBITS_SERVER_API_HOST + '/profile'
|
||||
const method = 'PUT'
|
||||
|
||||
const body = {
|
||||
const method = 'PUT'
|
||||
|
||||
const body: { walletId: string, seedHash: string } = {
|
||||
walletId,
|
||||
seedHash
|
||||
}
|
||||
seedHash
|
||||
}
|
||||
|
||||
const walletProfile = await fetchApi(url + `/recoverAddress`, {
|
||||
method,
|
||||
method,
|
||||
body,
|
||||
jwtAuthRequired: true
|
||||
}) as WalletProfileRecord
|
||||
|
||||
Reference in New Issue
Block a user