Skip to content

Commit 7f26a44

Browse files
authored
Feat/eid wallet passphrase recovery (#881)
* feat: passphrase setting * feat: basic passphrase recovery * feat: password recovery * style: password page * chore: format * fix: lint
1 parent 1328414 commit 7f26a44

File tree

10 files changed

+1304
-22
lines changed

10 files changed

+1304
-22
lines changed

infrastructure/eid-wallet/src/lib/global/controllers/evault.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,84 @@ export class VaultController {
600600
return this.#endpoint;
601601
}
602602

603+
/**
604+
* Resolve the Fastify (non-GraphQL) base URL for this vault.
605+
* The GraphQL endpoint lives at `<base>/graphql`; other HTTP routes sit at `<base>`.
606+
*/
607+
private async resolveBaseUrl(w3id: string): Promise<string> {
608+
const graphqlUrl = await this.resolveEndpoint(w3id);
609+
return graphqlUrl.replace(/\/graphql$/, "");
610+
}
611+
612+
/**
613+
* Store the recovery passphrase for this vault on the eVault server.
614+
* Only a PBKDF2 hash is persisted on the server; the plain text is never sent
615+
* across the wire in any readable form — it is sent over HTTPS and immediately
616+
* hashed server-side with a random salt.
617+
*
618+
* @throws if the passphrase does not meet strength requirements (validated server-side)
619+
* @throws if no vault is found
620+
*/
621+
async setRecoveryPassphrase(
622+
passphrase: string,
623+
confirmPassphrase: string,
624+
): Promise<void> {
625+
if (passphrase !== confirmPassphrase) {
626+
throw new Error("Passphrases do not match");
627+
}
628+
629+
const vault = await this.vault;
630+
if (!vault?.ename) {
631+
throw new Error("No vault available");
632+
}
633+
634+
const base = await this.resolveBaseUrl(vault.ename);
635+
636+
// Retrieve a valid auth token by re-using the token used for other vault ops
637+
const token = PUBLIC_EID_WALLET_TOKEN || null;
638+
639+
const headers: Record<string, string> = {
640+
"Content-Type": "application/json",
641+
"X-ENAME": vault.ename,
642+
};
643+
if (token) headers.Authorization = `Bearer ${token}`;
644+
645+
const response = await axios.post(
646+
new URL("/passphrase/set", base).toString(),
647+
{ passphrase },
648+
{ headers },
649+
);
650+
651+
if (!response.data?.success) {
652+
throw new Error(
653+
response.data?.error ?? "Failed to store recovery passphrase",
654+
);
655+
}
656+
}
657+
658+
/**
659+
* Check whether a recovery passphrase has been set on the eVault.
660+
*/
661+
async hasRecoveryPassphrase(): Promise<boolean> {
662+
const vault = await this.vault;
663+
if (!vault?.ename) return false;
664+
665+
try {
666+
const base = await this.resolveBaseUrl(vault.ename);
667+
const token = PUBLIC_EID_WALLET_TOKEN || null;
668+
const headers: Record<string, string> = { "X-ENAME": vault.ename };
669+
if (token) headers.Authorization = `Bearer ${token}`;
670+
671+
const response = await axios.get(
672+
new URL("/passphrase/status", base).toString(),
673+
{ headers },
674+
);
675+
return response.data?.hasPassphrase === true;
676+
} catch {
677+
return false;
678+
}
679+
}
680+
603681
async clear() {
604682
await this.#store.delete("vault");
605683
}

infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Key01Icon,
99
LanguageSquareIcon,
1010
Link02Icon,
11+
LockPasswordIcon,
1112
PinCodeIcon,
1213
Shield01Icon,
1314
} from "@hugeicons/core-free-icons";
@@ -124,6 +125,11 @@ $effect(() => {
124125
label="Pin"
125126
href="/settings/pin"
126127
/>
128+
<SettingsNavigationBtn
129+
icon={LockPasswordIcon}
130+
label="Recovery Passphrase"
131+
href="/settings/passphrase"
132+
/>
127133
<SettingsNavigationBtn
128134
icon={Shield01Icon}
129135
label="Privacy"
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<script lang="ts">
2+
import { goto } from "$app/navigation";
3+
import type { GlobalState } from "$lib/global";
4+
import { runtime } from "$lib/global/runtime.svelte";
5+
import { ButtonAction, Drawer } from "$lib/ui";
6+
import { ShieldKeyIcon } from "@hugeicons/core-free-icons";
7+
import { HugeiconsIcon } from "@hugeicons/svelte";
8+
import { getContext, onMount } from "svelte";
9+
10+
let globalState: GlobalState | undefined = $state(undefined);
11+
12+
let passphrase = $state("");
13+
let confirmPassphrase = $state("");
14+
let isLoading = $state(false);
15+
let showSuccessDrawer = $state(false);
16+
let errorMessage = $state<string | null>(null);
17+
let hasExistingPassphrase = $state(false);
18+
19+
const REQUIREMENTS = [
20+
{ label: "At least 12 characters", test: (p: string) => p.length >= 12 },
21+
{ label: "Uppercase letter (A–Z)", test: (p: string) => /[A-Z]/.test(p) },
22+
{ label: "Lowercase letter (a–z)", test: (p: string) => /[a-z]/.test(p) },
23+
{ label: "Number (0–9)", test: (p: string) => /[0-9]/.test(p) },
24+
{
25+
label: "Special character (!@#$…)",
26+
test: (p: string) => /[^A-Za-z0-9]/.test(p),
27+
},
28+
];
29+
30+
$effect(() => {
31+
runtime.header.title = "Recovery Passphrase";
32+
});
33+
34+
onMount(async () => {
35+
globalState = getContext<() => GlobalState>("globalState")();
36+
if (!globalState) throw new Error("Global state is not defined");
37+
try {
38+
hasExistingPassphrase =
39+
await globalState.vaultController.hasRecoveryPassphrase();
40+
} catch {
41+
// non-critical
42+
}
43+
});
44+
45+
async function handleSave() {
46+
errorMessage = null;
47+
48+
if (!passphrase) {
49+
errorMessage = "Please enter a passphrase.";
50+
return;
51+
}
52+
53+
const unmet = REQUIREMENTS.filter((r) => !r.test(passphrase));
54+
if (unmet.length > 0) {
55+
errorMessage = "Passphrase does not meet all requirements.";
56+
return;
57+
}
58+
59+
if (passphrase !== confirmPassphrase) {
60+
errorMessage = "Passphrases do not match.";
61+
return;
62+
}
63+
64+
isLoading = true;
65+
try {
66+
await globalState?.vaultController.setRecoveryPassphrase(
67+
passphrase,
68+
confirmPassphrase,
69+
);
70+
passphrase = "";
71+
confirmPassphrase = "";
72+
hasExistingPassphrase = true;
73+
showSuccessDrawer = true;
74+
} catch (err) {
75+
const message = err instanceof Error ? err.message : String(err);
76+
errorMessage = message.includes("requirements")
77+
? message
78+
: "Failed to save passphrase. Please try again.";
79+
console.error("setRecoveryPassphrase failed:", err);
80+
} finally {
81+
isLoading = false;
82+
}
83+
}
84+
85+
async function handleClose() {
86+
showSuccessDrawer = false;
87+
await goto("/settings");
88+
}
89+
90+
const allMet = $derived(
91+
passphrase.length > 0 && REQUIREMENTS.every((r) => r.test(passphrase)),
92+
);
93+
const mismatch = $derived(
94+
confirmPassphrase.length > 0 && confirmPassphrase !== passphrase,
95+
);
96+
</script>
97+
98+
<main
99+
class="h-[85vh] px-[5vw] pb-[8svh] flex flex-col justify-between"
100+
style="padding-top: max(4svh, env(safe-area-inset-top));"
101+
>
102+
<section class="flex flex-col gap-[3svh]">
103+
{#if hasExistingPassphrase}
104+
<p class="text-black-700">
105+
A recovery passphrase is already set. Enter a new one below to replace it.
106+
</p>
107+
{:else}
108+
<p class="text-black-700">
109+
Set a passphrase that will be required when recovering your eVault.
110+
Only a secure hash is stored — your passphrase is never readable.
111+
</p>
112+
{/if}
113+
114+
<div>
115+
<p class="mb-[1svh]">New passphrase</p>
116+
<input
117+
type="password"
118+
bind:value={passphrase}
119+
autocomplete="new-password"
120+
placeholder="Enter your passphrase"
121+
class="w-full rounded-xl border border-transparent bg-gray px-4 py-3 focus:outline-none focus:border-primary transition-colors"
122+
/>
123+
124+
{#if passphrase}
125+
<ul class="mt-[1.5svh] flex flex-col gap-[0.6svh]">
126+
{#each REQUIREMENTS as req}
127+
<li class="small flex items-center gap-2 {req.test(passphrase) ? 'text-green-600' : 'text-black-300'}">
128+
<span class="font-bold w-3 text-center">{req.test(passphrase) ? "" : "·"}</span>
129+
{req.label}
130+
</li>
131+
{/each}
132+
</ul>
133+
{/if}
134+
</div>
135+
136+
<div>
137+
<p class="mb-[1svh]">Confirm passphrase</p>
138+
<input
139+
type="password"
140+
bind:value={confirmPassphrase}
141+
autocomplete="new-password"
142+
placeholder="Re-enter your passphrase"
143+
class="w-full rounded-xl border {mismatch ? 'border-danger' : 'border-transparent'} bg-gray px-4 py-3 focus:outline-none focus:border-primary transition-colors"
144+
/>
145+
{#if mismatch}
146+
<p class="text-danger mt-[0.5svh]">Passphrases do not match.</p>
147+
{/if}
148+
</div>
149+
150+
{#if errorMessage}
151+
<p class="text-danger">{errorMessage}</p>
152+
{/if}
153+
</section>
154+
155+
<ButtonAction
156+
class="w-full"
157+
callback={handleSave}
158+
disabled={isLoading || !allMet || !confirmPassphrase || mismatch}
159+
>
160+
{isLoading ? "Saving…" : hasExistingPassphrase ? "Update Passphrase" : "Set Passphrase"}
161+
</ButtonAction>
162+
</main>
163+
164+
<Drawer bind:isPaneOpen={showSuccessDrawer}>
165+
<div class="relative bg-gray w-18 h-18 rounded-3xl flex justify-center items-center mb-[2.3svh]">
166+
<span class="relative z-1">
167+
<HugeiconsIcon icon={ShieldKeyIcon} color="var(--color-primary)" />
168+
</span>
169+
<img class="absolute top-0 start-0" src="/images/Line.svg" alt="" />
170+
<img class="absolute top-0 start-0" src="/images/Line2.svg" alt="" />
171+
</div>
172+
<h4>Recovery Passphrase {hasExistingPassphrase ? "Updated" : "Set"}!</h4>
173+
<p class="text-black-700 mt-[0.5svh] mb-[2.3svh]">
174+
Your recovery passphrase has been securely stored. You will need it when recovering your eVault.
175+
</p>
176+
<ButtonAction class="w-full" callback={handleClose}>Done</ButtonAction>
177+
</Drawer>

0 commit comments

Comments
 (0)