Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add auto mnemonic matching #1556

Merged
merged 8 commits into from
Feb 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
deleteKeyPair,
updateNickname,
updateMnemonicHash,
updateIndex,
} from '@main/services/localUser';
import { createIPCChannel, renameFunc } from '@main/utils/electronInfra';

Expand All @@ -25,6 +26,7 @@ export default () => {
renameFunc(changeDecryptionPassword, 'changeDecryptionPassword'),
renameFunc(updateNickname, 'updateNickname'),
renameFunc(updateMnemonicHash, 'updateMnemonicHash'),
renameFunc(updateIndex, 'updateIndex'),
renameFunc(deleteEncryptedPrivateKeys, 'deleteEncryptedPrivateKeys'),
renameFunc(deleteKeyPair, 'deleteKeyPair'),
renameFunc(decryptPrivateKey, 'decryptPrivateKey'),
Expand Down
26 changes: 14 additions & 12 deletions front-end/src/main/services/localUser/keyPairs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,12 +197,8 @@ export const updateNickname = async (keyPairId: string, nickname: string) => {
const prisma = getPrismaClient();

await prisma.keyPair.update({
where: {
id: keyPairId,
},
data: {
nickname,
},
where: { id: keyPairId },
data: { nickname },
});
};

Expand All @@ -211,12 +207,18 @@ export const updateMnemonicHash = async (keyPairId: string, secret_hash: string)
const prisma = getPrismaClient();

await prisma.keyPair.update({
where: {
id: keyPairId,
},
data: {
secret_hash,
},
where: { id: keyPairId },
data: { secret_hash },
});
};

// Update key pair index
export const updateIndex = async (keyPairId: string, index: number) => {
const prisma = getPrismaClient();

await prisma.keyPair.update({
where: { id: keyPairId },
data: { index },
});
};

Expand Down
2 changes: 2 additions & 0 deletions front-end/src/preload/localUser/keyPairs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,7 @@ export default {
ipcRenderer.invoke('keyPairs:updateNickname', keyPairId, nickname),
updateMnemonicHash: (keyPairId: string, mnemonicHash: string): Promise<void> =>
ipcRenderer.invoke('keyPairs:updateMnemonicHash', keyPairId, mnemonicHash),
updateIndex: (keyPairId: string, index: number): Promise<void> =>
ipcRenderer.invoke('keyPairs:updateIndex', keyPairId, index),
},
};
2 changes: 1 addition & 1 deletion front-end/src/renderer/components/ui/AppDatePicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ function handleNow() {
:clearable="clearable"
auto-apply
partial-flow
:flow="['date', 'time']"
text-input
:config="{
keepActionRow: true,
Expand All @@ -52,6 +51,7 @@ function handleNow() {
:placeholder="placeholder"
:min-date="minDate"
:max-date="maxDate"
teleport="body"
class="is-fill"
enable-seconds
@open="$emit('open')"
Expand Down
96 changes: 96 additions & 0 deletions front-end/src/renderer/composables/useMatchRecoveryPhrase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { KeyPair } from '@prisma/client';

import useUserStore from '@renderer/stores/storeUser';

import {
restorePrivateKey,
updateIndex,
updateMnemonicHash as updateLocalMnemonicHash,
} from '@renderer/services/keyPairService';
import { updateKey as updateOrganizationKey } from '@renderer/services/organization';

import { isLoggedInOrganization, safeAwait } from '@renderer/utils';
import { computed, type Ref } from 'vue';

export default function useMatchRecoveryPrase() {
/* Stores */
const user = useUserStore();

/* Computed */
const externalKeys = computed(() => user.keyPairs.filter(k => !k.secret_hash));

/* Functions */
const startMatching = async (
startIndex: number,
endIndex: number,
abortController: AbortController,
totalRef: Ref<number>,
) => {
if (!user.recoveryPhrase) {
throw new Error('Recovery phrase is not set');
}

let count = 0;

for (let i = startIndex; i <= endIndex; i++) {
if (abortController.signal.aborted || externalKeys.value.length === count) {
break;
}

const pkED25519 = await restorePrivateKey(user.recoveryPhrase.words, '', i, 'ED25519');
const pkECDSA = await restorePrivateKey(user.recoveryPhrase.words, '', i, 'ECDSA');

const keyPairED25519 = externalKeys.value.find(
k => k.public_key === pkED25519.publicKey.toStringRaw(),
);
const keyPairECDSA = externalKeys.value.find(
k => k.public_key === pkECDSA.publicKey.toStringRaw(),
);

if (keyPairED25519) {
await safeAwait(updateKeyPairsHash(keyPairED25519, i));
totalRef.value++;
count++;
}
if (keyPairECDSA) {
await safeAwait(updateKeyPairsHash(keyPairECDSA, i));
totalRef.value++;
count++;
}
}

await user.refetchUserState();
await user.refetchKeys();

return count;
};

const updateKeyPairsHash = async (localKeyPair: KeyPair, index: number): Promise<void> => {
if (!user.recoveryPhrase) {
return;
}

if (isLoggedInOrganization(user.selectedOrganization)) {
const organizationKeyPair = user.selectedOrganization.userKeys.find(
key => key.publicKey === localKeyPair.public_key,
);
if (organizationKeyPair) {
await updateOrganizationKey(
user.selectedOrganization.serverUrl,
user.selectedOrganization.userId,
organizationKeyPair.id,
user.recoveryPhrase.hash,
index,
);
}
}
await updateLocalMnemonicHash(localKeyPair.id, user.recoveryPhrase.hash);
await updateIndex(localKeyPair.id, index);
};

return {
externalKeys,
startMatching,
updateKeyPairsHash,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { ref, watch } from 'vue';

import { useRouter } from 'vue-router';
import { useToast } from 'vue-toast-notification';
import useSetDynamicLayout, { LOGGED_IN_LAYOUT } from '@renderer/composables/useSetDynamicLayout';
import useMatchRecoveryPrase from '@renderer/composables/useMatchRecoveryPhrase';

import AppButton from '@renderer/components/ui/AppButton.vue';
import AppInput from '@renderer/components/ui/AppInput.vue';
import Import from '@renderer/components/RecoveryPhrase/Import.vue';

const SEARCHING_TEXT = 'Abort Search';

/* Composables */
useSetDynamicLayout(LOGGED_IN_LAYOUT);
const toast = useToast();
const router = useRouter();
const { startMatching, externalKeys } = useMatchRecoveryPrase();

/* State */
const loadingText = ref<string | null>(null);
const errorMessage = ref<string | null>(null);
const startIndex = ref<number>(0);
const endIndex = ref<number>(100);
const abortController = ref<AbortController | null>(null);
const totalRecovered = ref<number>(0);
const cachedExternalKeys = ref([...externalKeys.value]);

const handleSearch = async () => {
if (loadingText.value === SEARCHING_TEXT) {
handleAbort();
return;
}

loadingText.value = SEARCHING_TEXT;
try {
abortController.value = new AbortController();

const currentSearchCount = await startMatching(
Number(startIndex.value),
Number(endIndex.value),
abortController.value,
totalRecovered,
);

const message =
currentSearchCount === 0
? 'No keys matched'
: totalRecovered.value === cachedExternalKeys.value.length
? 'All keys matched to recovery phrase'
: `Matched ${currentSearchCount} keys to recovery phrase`;
toast.success(message);
} finally {
loadingText.value = null;

if (totalRecovered.value === cachedExternalKeys.value.length) {
await router.back();
}
}
};

const handleAbort = async () => {
loadingText.value = 'Aborting the search...';
abortController.value?.abort();
loadingText.value = null;
};

watch([startIndex, endIndex], async ([start, end]) => {
if (Number(start) > Number(end)) {
errorMessage.value = 'Start index must be less than end index';
} else {
errorMessage.value = null;
}
});
</script>
<template>
<div class="flex-column-100 p-7">
<div class="position-relative">
<AppButton color="secondary" @click="$router.back()">Back</AppButton>
</div>
<h4 class="text-display text-bold text-center">Match External Keys to Recovery Phrase</h4>
<p class="text-center mt-1">
Enter a recovery phrase to automatically match your external keys to it.
</p>
<div class="mt-8">
<Import />
</div>

<hr class="separator my-4 mx-3" />

<div class="d-flex flex-wrap align-items-center justify-content-between gap-4 ms-3">
<div class="d-flex gap-3">
<div class="form-group">
<label class="form-label">Start Index</label>
<AppInput
v-model="startIndex"
:filled="true"
:disabled="Boolean(loadingText)"
data-testid="input-start-index"
type="number"
placeholder="Enter start index"
/>
</div>
<div class="position-relative">
<span class="absolute-centered" :style="{ top: '70%' }">-</span>
</div>
<div class="form-group">
<label class="form-label">End Index</label>
<AppInput
v-model="endIndex"
:filled="true"
:disabled="Boolean(loadingText)"
data-testid="input-end-index"
type="number"
placeholder="Enter end index"
/>
</div>
</div>
<div class="form-group">
<p class="form-label text-nowrap" :class="{ 'text-danger': errorMessage }">
{{
errorMessage
? errorMessage
: `Total keys recovered: ${totalRecovered}/${cachedExternalKeys.length}`
}}
</p>
<AppButton
data-testid="button-search-abort"
@click="handleSearch"
:color="loadingText === SEARCHING_TEXT ? 'danger' : 'primary'"
:disabled="externalKeys.length === 0 || isNaN(startIndex) || isNaN(endIndex)"
:disable-on-loading="false"
:loading="Boolean(loadingText)"
:loading-text="loadingText || ''"
>Search</AppButton
>
</div>
</div>
</div>
</template>
3 changes: 3 additions & 0 deletions front-end/src/renderer/pages/MatchRecoveryPhrase/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import MatchRecoveryPhrase from './MatchRecoveryPhrase.vue';

export default MatchRecoveryPhrase;
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ const handleClearWords = (value: boolean) => {
<h1 class="text-display text-bold text-center">Enter your Recovery Phrase</h1>
<div class="mt-8">
<Import :should-clear="shouldClearInputs" @reset-cleared="handleClearWords($event)" />
<div class="form-group mt-4">

<hr class="separator my-4 mx-3" />

<div class="form-group mx-3">
<label class="form-label">Enter Recovery Phrase Nickname</label>
<RecoveryPhraseNicknameInput
v-model="mnemonicHashNickname"
Expand All @@ -43,13 +46,14 @@ const handleClearWords = (value: boolean) => {
data-testid="input-recovery-phrase-nickname"
/>
</div>
<div class="row justify-content-between mt-6">
<div class="col-4 d-grid">

<div class="d-flex justify-content-between mt-4 mx-3">
<div class="">
<AppButton type="button" color="secondary" @click="handleClearWords(true)"
>Clear</AppButton
>
</div>
<div class="col-4 d-grid">
<div class="">
<AppButton
color="primary"
data-testid="button-continue-phrase"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Tabs } from '..';

import { computed, ref, watch } from 'vue';

import { RESTORE_MISSING_KEYS } from '@renderer/router';
import { MATCH_RECOVERY_PHRASE, RESTORE_MISSING_KEYS } from '@renderer/router';

import useUserStore from '@renderer/stores/storeUser';

Expand Down Expand Up @@ -74,6 +74,10 @@ const handleRedirectToRecoverMnemonicKeys = () => {
router.push({ name: RESTORE_MISSING_KEYS });
};

const handleRedirectToMatchRecoveryPhrase = () => {
router.push({ name: MATCH_RECOVERY_PHRASE });
};

/* Watchers */
watch(
() => props.selectedRecoveryPhrase,
Expand Down Expand Up @@ -171,6 +175,16 @@ watch(
@click="handleRedirectToRecoverMnemonicKeys()"
>Restore Missing Keys</AppButton
>

<!-- Restore missing keys from recovery phrase -->
<AppButton
v-if="selectedTab === Tabs.PRIVATE_KEY && listedKeyPairs.length > 0"
color="primary"
:data-testid="`button-restore-lost-keys`"
class="rounded-3 text-nowrap min-w-unset"
@click="handleRedirectToMatchRecoveryPhrase()"
>Match Recovery Phrase</AppButton
>
</div>

<UpdateRecoveryPhraseNickname
Expand Down
1 change: 1 addition & 0 deletions front-end/src/renderer/router/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const RESTORE_MISSING_KEYS = 'restore-missing-keys';
export const RESTORE_KEY = 'restoreKey';
export const MIGRATE_RECOVERY_PHRASE_HASH = 'migrate-recovery-phrase-hash';
export const MATCH_RECOVERY_PHRASE = 'match-recovery-phrase';
Loading
Loading