fedimint_gateway_ui/
mnemonic.rs

1use 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/// Form data for revealing the mnemonic
13#[derive(Deserialize)]
14pub struct RevealMnemonicForm {
15    pub password: String,
16}
17
18/// Renders the mnemonic card with password-protected reveal.
19/// The mnemonic is only displayed after re-authentication, and the form
20/// submits directly to a sandboxed iframe so the mnemonic never passes
21/// through parent page JavaScript.
22pub 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                // Password form that submits directly to the iframe
31                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                // Sandboxed iframe - form submits here, scripts disabled
60                // The sandbox attribute with allow-forms is needed to accept form submissions
61                // but scripts remain disabled
62                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
84/// Common styles for iframe content
85fn 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
135/// Renders the initial iframe content (placeholder prompting for password)
136fn 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
154/// Renders the iframe content with the revealed mnemonic
155fn 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
178/// Renders the iframe content with an error message
179fn 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
198/// Security headers for iframe responses
199fn 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
214/// Handler for the initial iframe content (GET request).
215/// Returns a placeholder prompting the user to enter their password.
216pub 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
227/// Handler for revealing the mnemonic (POST request with password).
228/// Verifies the password and returns the mnemonic if valid.
229pub 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    // Verify password
238    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    // Password valid, fetch and display mnemonic
247    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}