Skip to content

Commit eb6e5ad

Browse files
committed
Support for resetting locked recovery phrases.
1 parent 6686b70 commit eb6e5ad

File tree

11 files changed

+424
-55
lines changed

11 files changed

+424
-55
lines changed

src/frontend/src/lib/components/views/RecoveryPhraseInput.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
.map((word) => word.toLowerCase().replace(/[^a-z]/g, ""));
6363
if (clipboard.length > 0) {
6464
clipboard.forEach((word, i) => {
65-
if (index + i >= word.length) {
65+
if (index + i >= words.length) {
6666
return;
6767
}
6868
words[index + i] = word;

src/frontend/src/lib/components/wizards/createRecoveryPhrase/CreateRecoveryPhraseWizard.svelte

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,35 +6,39 @@
66
import { generateMnemonic } from "$lib/utils/recoveryPhrase";
77
import Reset from "$lib/components/wizards/createRecoveryPhrase/views/Reset.svelte";
88
import Retry from "$lib/components/wizards/createRecoveryPhrase/views/Retry.svelte";
9+
import Unlock from "$lib/components/wizards/createRecoveryPhrase/views/Unlock.svelte";
910
1011
interface Props {
1112
action?: "create" | "verify";
1213
onCreate: (recoveryPhrase: string[]) => Promise<void>;
1314
onVerify: (recoveryPhrase: string[]) => Promise<boolean>;
15+
onUnlock: (recoveryPhrase: string[]) => Promise<boolean>;
1416
onCancel: () => void;
1517
onError: (error: unknown) => void;
1618
unverifiedRecoveryPhrase?: string[];
17-
hasExistingRecoveryPhrase?: boolean;
19+
existingRecoveryPhraseType?: "unprotected" | "protected";
1820
}
1921
2022
const {
2123
action = "create",
2224
onCreate,
2325
onVerify,
26+
onUnlock,
2427
onCancel,
2528
onError,
2629
unverifiedRecoveryPhrase,
27-
hasExistingRecoveryPhrase,
30+
existingRecoveryPhraseType,
2831
}: Props = $props();
2932
3033
let recoveryPhrase = $state<string[] | undefined>(
3134
action === "verify" ? unverifiedRecoveryPhrase : undefined,
3235
);
3336
let isWritten = $state(action === "verify");
3437
let isIncorrect = $state(false);
38+
let isLocked = $state(existingRecoveryPhraseType === "protected");
3539
let incorrectRecoveryPhrase = $state<string[]>();
3640
37-
const createRecoveryPhrase = async () => {
41+
const handleCreate = async () => {
3842
try {
3943
const generated = generateMnemonic();
4044
await onCreate(generated);
@@ -43,39 +47,57 @@
4347
onError(error);
4448
}
4549
};
46-
const verifyRecoveryPhrase = async (entered: string[]) => {
50+
const handleVerify = async (entered: string[]) => {
4751
try {
4852
isIncorrect = !(await onVerify(entered));
4953
incorrectRecoveryPhrase = isIncorrect ? entered : undefined;
5054
} catch (error) {
5155
onError(error);
5256
}
5357
};
54-
const retryVerification = () => {
58+
const handleRetry = () => {
5559
isWritten = false;
5660
isIncorrect = false;
5761
};
62+
const handleUnlock = async (entered: string[]) => {
63+
try {
64+
isIncorrect = !(await onUnlock(entered));
65+
incorrectRecoveryPhrase = isIncorrect ? entered : undefined;
66+
isLocked = isIncorrect;
67+
} catch (error) {
68+
onError(error);
69+
}
70+
};
5871
</script>
5972

6073
{#if action === "create" && recoveryPhrase === undefined}
61-
{#if hasExistingRecoveryPhrase}
62-
<Reset onReset={createRecoveryPhrase} {onCancel} />
74+
{#if existingRecoveryPhraseType !== undefined}
75+
{#if isIncorrect}
76+
<Retry onRetry={handleRetry} {onCancel} inputMethod="typing" />
77+
{:else if isLocked}
78+
<Unlock
79+
recoveryPhrase={incorrectRecoveryPhrase}
80+
onCompleted={handleUnlock}
81+
/>
82+
{:else}
83+
<Reset onReset={handleCreate} {onCancel} />
84+
{/if}
6385
{:else}
64-
<Acknowledge onAcknowledged={createRecoveryPhrase} />
86+
<Acknowledge onAcknowledged={handleCreate} />
6587
{/if}
6688
{:else if !isWritten && recoveryPhrase !== undefined}
6789
<Write {recoveryPhrase} onWritten={() => (isWritten = true)} />
6890
{:else if isIncorrect}
6991
<Retry
70-
onRetry={retryVerification}
92+
onRetry={handleRetry}
7193
{onCancel}
72-
verificationMethod={recoveryPhrase !== undefined ? "selecting" : "typing"}
94+
inputMethod={recoveryPhrase !== undefined ? "selecting" : "typing"}
7395
/>
7496
{:else if recoveryPhrase !== undefined}
75-
<VerifySelecting {recoveryPhrase} onCompleted={verifyRecoveryPhrase} />
97+
<VerifySelecting {recoveryPhrase} onCompleted={handleVerify} />
7698
{:else}
7799
<VerifyTyping
78-
onCompleted={verifyRecoveryPhrase}
100+
onCompleted={handleVerify}
79101
recoveryPhrase={incorrectRecoveryPhrase}
80102
/>
81103
{/if}

src/frontend/src/lib/components/wizards/createRecoveryPhrase/views/Retry.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
interface Props {
99
onRetry: () => void;
1010
onCancel: () => void;
11-
verificationMethod: "selecting" | "typing";
11+
inputMethod: "selecting" | "typing";
1212
}
1313
14-
const { onRetry, onCancel, verificationMethod }: Props = $props();
14+
const { onRetry, onCancel, inputMethod }: Props = $props();
1515
</script>
1616

1717
<FeaturedIcon variant="error" size="lg" class="mb-4">
@@ -21,7 +21,7 @@
2121
{$t`Something is wrong!`}
2222
</h2>
2323
<p class="text-text-tertiary mb-8 text-base font-medium">
24-
{#if verificationMethod === "selecting"}
24+
{#if inputMethod === "selecting"}
2525
<Trans>Incorrect word order. Review and try again.</Trans>
2626
{:else}
2727
<Trans>Incorrect recovery phrase. Please try again.</Trans>
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<script lang="ts">
2+
import { t } from "$lib/stores/locale.store";
3+
import { Trans } from "$lib/components/locale";
4+
import RecoveryPhraseInput from "$lib/components/views/RecoveryPhraseInput.svelte";
5+
import Button from "$lib/components/ui/Button.svelte";
6+
import FeaturedIcon from "$lib/components/ui/FeaturedIcon.svelte";
7+
import { LockKeyholeIcon } from "@lucide/svelte";
8+
9+
const EMPTY_PHRASE = Array.from({ length: 24 }).map(() => "");
10+
11+
interface Props {
12+
onCompleted: (recoveryPhrase: string[]) => Promise<void>;
13+
recoveryPhrase?: string[];
14+
}
15+
16+
const { onCompleted, recoveryPhrase }: Props = $props();
17+
18+
let value = $state<string[]>(recoveryPhrase ?? EMPTY_PHRASE);
19+
let showValues = $state(recoveryPhrase !== undefined);
20+
let isCheckingPhrase = $state(false);
21+
22+
const phraseValid = $derived(value.every((word) => word.length > 0));
23+
const autoSubmit = $derived(recoveryPhrase === undefined);
24+
25+
const handleSubmit = async () => {
26+
try {
27+
isCheckingPhrase = true;
28+
await onCompleted(value);
29+
} finally {
30+
isCheckingPhrase = false;
31+
}
32+
};
33+
34+
// Auto-submit after the last word has been entered
35+
$effect(() => {
36+
if (!phraseValid || !autoSubmit) {
37+
return;
38+
}
39+
handleSubmit();
40+
});
41+
</script>
42+
43+
<div class="limited-height mb-4">
44+
<FeaturedIcon size="md">
45+
<LockKeyholeIcon class="size-5" />
46+
</FeaturedIcon>
47+
</div>
48+
<h2 class="text-text-primary mb-3 text-2xl font-medium">
49+
{#if isCheckingPhrase}
50+
{$t`Checking recovery phrase`}
51+
{:else}
52+
{$t`Unlock to continue`}
53+
{/if}
54+
</h2>
55+
<p class="text-text-tertiary mb-8 text-base font-medium">
56+
{#if isCheckingPhrase}
57+
<Trans>This may take a few seconds</Trans>
58+
{:else}
59+
<Trans>Enter each word in the correct order:</Trans>
60+
{/if}
61+
</p>
62+
<RecoveryPhraseInput bind:value {showValues} disabled={isCheckingPhrase} />
63+
<div class="more-limited-height mt-5 flex flex-row">
64+
<Button
65+
onclick={() => (showValues = !showValues)}
66+
variant="tertiary"
67+
disabled={isCheckingPhrase}
68+
class="flex-1"
69+
>
70+
{showValues ? $t`Hide all` : $t`Show all`}
71+
</Button>
72+
<Button
73+
onclick={() => (value = EMPTY_PHRASE)}
74+
variant="tertiary"
75+
disabled={isCheckingPhrase}
76+
class="flex-1"
77+
>
78+
{$t`Clear all`}
79+
</Button>
80+
</div>
81+
{#if !autoSubmit}
82+
<Button
83+
onclick={handleSubmit}
84+
variant="primary"
85+
size="xl"
86+
disabled={isCheckingPhrase || !phraseValid}
87+
class="mt-5"
88+
>
89+
{$t`Submit`}
90+
</Button>
91+
{/if}
92+
93+
<style>
94+
@media (max-height: 640px) {
95+
.limited-height {
96+
display: none !important;
97+
}
98+
}
99+
@media (max-height: 570px) {
100+
.more-limited-height {
101+
display: none !important;
102+
}
103+
}
104+
</style>

src/frontend/src/lib/utils/recoveryPhrase.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ async function generateMasterKey(
7171
seed: Uint8Array,
7272
): Promise<[Uint8Array, Uint8Array]> {
7373
const data = new TextEncoder().encode("ed25519 seed");
74-
const key = await window.crypto.subtle.importKey(
74+
const key = await globalThis.crypto.subtle.importKey(
7575
"raw",
7676
data,
7777
{
@@ -81,7 +81,7 @@ async function generateMasterKey(
8181
false,
8282
["sign"],
8383
);
84-
const h = await window.crypto.subtle.sign("HMAC", key, seed);
84+
const h = await globalThis.crypto.subtle.sign("HMAC", key, seed);
8585
const slipSeed = new Uint8Array(h.slice(0, 32));
8686
const chainCode = new Uint8Array(h.slice(32));
8787
return [slipSeed, chainCode];
@@ -94,7 +94,7 @@ async function derive(
9494
): Promise<[Uint8Array, Uint8Array]> {
9595
// From the spec: Data = 0x00 || ser256(kpar) || ser32(i)
9696
const data = new Uint8Array([0, ...parentKey, ...toBigEndianArray(i)]);
97-
const key = await window.crypto.subtle.importKey(
97+
const key = await globalThis.crypto.subtle.importKey(
9898
"raw",
9999
parentChaincode,
100100
{
@@ -105,7 +105,7 @@ async function derive(
105105
["sign"],
106106
);
107107

108-
const h = await window.crypto.subtle.sign("HMAC", key, data);
108+
const h = await globalThis.crypto.subtle.sign("HMAC", key, data);
109109
const slipSeed = new Uint8Array(h.slice(0, 32));
110110
const chainCode = new Uint8Array(h.slice(32));
111111
return [slipSeed, chainCode];

0 commit comments

Comments
 (0)