fedimint_gateway_ui/
mnemonic.rs1use axum::Form;
2use axum::extract::State;
3use axum::http::header;
4use axum::response::{Html, IntoResponse};
5use fedimint_ui_common::UiState;
6use fedimint_ui_common::auth::UserAuth;
7use maud::{DOCTYPE, Markup, PreEscaped, html};
8use serde::Deserialize;
9
10use crate::{DynGatewayApi, MNEMONIC_IFRAME_ROUTE};
11
12#[derive(Deserialize)]
14pub struct RevealMnemonicForm {
15 pub password: String,
16}
17
18pub fn render() -> Markup {
23 html! {
24 div class="card h-100" {
25 div class="card-header dashboard-header" {
26 span { "Gateway Secret Phrase" }
27 }
28
29 div class="card-body" {
30 form
32 id="mnemonic-reveal-form"
33 method="post"
34 action=(MNEMONIC_IFRAME_ROUTE)
35 target="mnemonic-iframe"
36 class="mb-3"
37 {
38 div class="input-group" {
39 input
40 type="password"
41 name="password"
42 class="form-control"
43 placeholder="Enter password to reveal"
44 autocomplete="current-password"
45 required;
46 button type="submit" class="btn btn-secondary" {
47 "Show"
48 }
49 button
50 type="button"
51 class="btn btn-outline-secondary"
52 onclick="hideMnemonic()"
53 {
54 "Hide"
55 }
56 }
57 }
58
59 iframe
63 name="mnemonic-iframe"
64 id="mnemonic-iframe"
65 src=(MNEMONIC_IFRAME_ROUTE)
66 sandbox=""
67 style="width: 100%; height: 240px; border: 1px solid #dee2e6; border-radius: 0.375rem; background: #fff;"
68 title="Gateway Secret Phrase"
69 {}
70 }
71 }
72
73 script {
74 (PreEscaped(r#"
75 function hideMnemonic() {
76 document.getElementById("mnemonic-iframe").src = "/ui/mnemonic/iframe";
77 document.querySelector("input[name=password]").value = "";
78 }
79 "#))
80 }
81 }
82}
83
84fn iframe_styles() -> Markup {
86 html! {
87 style {
88 (PreEscaped(r#"
89 body {
90 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
91 margin: 0;
92 padding: 1rem;
93 background-color: #fff;
94 }
95 ol {
96 column-count: 2;
97 column-gap: 2rem;
98 font-size: 1.1rem;
99 padding-left: 1.4rem;
100 margin: 0;
101 }
102 li {
103 margin-bottom: 0.25rem;
104 }
105 .placeholder {
106 color: #6c757d;
107 font-style: italic;
108 display: flex;
109 align-items: center;
110 justify-content: center;
111 height: 100%;
112 min-height: 200px;
113 }
114 .error {
115 color: #dc3545;
116 padding: 0.75rem;
117 background-color: #f8d7da;
118 border: 1px solid #f5c6cb;
119 border-radius: 0.375rem;
120 }
121 .warning {
122 color: #856404;
123 padding: 0.75rem;
124 background-color: #fff3cd;
125 border: 1px solid #ffeeba;
126 border-radius: 0.375rem;
127 margin-bottom: 1rem;
128 font-size: 0.9rem;
129 }
130 "#))
131 }
132 }
133}
134
135fn render_iframe_initial() -> Markup {
137 html! {
138 (DOCTYPE)
139 html {
140 head {
141 meta charset="utf-8";
142 meta name="viewport" content="width=device-width, initial-scale=1.0";
143 (iframe_styles())
144 }
145 body {
146 div class="placeholder" {
147 "Enter your password to reveal the secret phrase"
148 }
149 }
150 }
151 }
152}
153
154fn render_iframe_revealed(mnemonic: &[String]) -> Markup {
156 html! {
157 (DOCTYPE)
158 html {
159 head {
160 meta charset="utf-8";
161 meta name="viewport" content="width=device-width, initial-scale=1.0";
162 (iframe_styles())
163 }
164 body {
165 div class="warning" {
166 "⚠ Never share these words with anyone. Store them securely offline."
167 }
168 ol {
169 @for word in mnemonic {
170 li { (word) }
171 }
172 }
173 }
174 }
175 }
176}
177
178fn render_iframe_error(message: &str) -> Markup {
180 html! {
181 (DOCTYPE)
182 html {
183 head {
184 meta charset="utf-8";
185 meta name="viewport" content="width=device-width, initial-scale=1.0";
186 (iframe_styles())
187 }
188 body {
189 div class="error" {
190 strong { "Error: " }
191 (message)
192 }
193 }
194 }
195 }
196}
197
198fn iframe_security_headers() -> [(header::HeaderName, &'static str); 4] {
200 [
201 (
202 header::CACHE_CONTROL,
203 "no-store, no-cache, must-revalidate, private",
204 ),
205 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
206 (header::X_FRAME_OPTIONS, "SAMEORIGIN"),
207 (
208 header::CONTENT_SECURITY_POLICY,
209 "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'self'",
210 ),
211 ]
212}
213
214pub async fn mnemonic_iframe_handler<E>(
217 _state: State<UiState<DynGatewayApi<E>>>,
218 _auth: UserAuth,
219) -> impl IntoResponse
220where
221 E: std::fmt::Display + Send + Sync + 'static,
222{
223 let markup = render_iframe_initial();
224 (iframe_security_headers(), Html(markup.into_string())).into_response()
225}
226
227pub async fn mnemonic_reveal_handler<E>(
230 State(state): State<UiState<DynGatewayApi<E>>>,
231 _auth: UserAuth,
232 Form(form): Form<RevealMnemonicForm>,
233) -> impl IntoResponse
234where
235 E: std::fmt::Display + Send + Sync + 'static,
236{
237 let password_valid =
239 bcrypt::verify(&form.password, &state.api.get_password_hash()).unwrap_or(false);
240
241 if !password_valid {
242 let markup = render_iframe_error("Invalid password");
243 return (iframe_security_headers(), Html(markup.into_string())).into_response();
244 }
245
246 match state.api.handle_mnemonic_msg().await {
248 Ok(response) => {
249 let markup = render_iframe_revealed(&response.mnemonic);
250 (iframe_security_headers(), Html(markup.into_string())).into_response()
251 }
252 Err(e) => {
253 let markup = render_iframe_error(&format!("Failed to fetch mnemonic: {e}"));
254 (iframe_security_headers(), Html(markup.into_string())).into_response()
255 }
256 }
257}