From 9fa9fcf637c04ad53eb728b06006ede69c0a4907 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 7 Nov 2026 12:53:27 +0100 Subject: [PATCH 1/3] Ask one factor for step-ups, the code with 2FA Admins found password plus 2FA code too much for actions in a session that already proves the password. Step-ups (Approvals, adding, inviting and deleting admins, the email, mail and remote backup settings, tokens, runner tokens, agent keys and webhooks, turning 2FA off) now go through one helper, users::verify_step_up: with 2FA the current code (used up, failures counted as before), without 2FA the password; the other field is ignored. Approvals keep which items can be confirmed in the panel. The mail-link page and changing the password keep their factors. The webui's prompts show only the code field with 2FA, else only the password field; callers still send both. Co-Authored-By: Claude Opus 5.5 --- docs/OPERATIONS.md | 22 ++-- server/src/actions.rs | 31 ++--- server/src/approvals.rs | 2 +- server/src/backup/remote.rs | 13 +- server/src/mail.rs | 17 ++- server/src/peers.rs | 24 ++-- server/src/proposals/mail.rs | 8 +- server/src/runners/mod.rs | 7 +- server/src/tokens.rs | 7 +- server/src/users.rs | 116 +++++++++++------- webui/src/components/SecondFactor.svelte | 25 ++-- .../components/dialogs/AccountDialog.svelte | 19 +-- .../components/dialogs/ApprovalsDialog.svelte | 4 +- .../components/dialogs/ApproveDialog.svelte | 3 +- .../dialogs/ConfirmActionDialog.svelte | 4 +- .../src/components/dialogs/MessageBox.svelte | 5 +- .../src/components/dialogs/TotpDialog.svelte | 18 ++- webui/src/lib/dialogs.svelte.ts | 10 +- webui/src/pages/Confirm.svelte | 2 +- 19 files changed, 175 insertions(+), 162 deletions(-) diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index a5a066b..1f19aed 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -116,7 +116,7 @@ into the server's config, and the CLI does the same: | DNS for genco's own mails (genco@, signed with selector `genco-panel`) | Settings → Mail: SPF, DKIM, DMARC and PTR with their state | | Send genco's mails through a relay as its account | Settings → Mail → Through an SMTP relay, Sender `ie@example.ch` (From and bounce address; signed by genco only as the panel's own domain) | | Why a mail doesn't arrive | Settings → Mail → Send Test Mail or `genco mail test ` waits for postfix and shows its reason (deferred, bounced, a failed relay login; the command exits non-zero then); the panel also says when outgoing port 25 seems blocked (`genco setup` checks it too) | -| A server without mail (port 25 blocked, no relay) | Settings → Mail → No mail: deletions are confirmed in the panel with password and 2FA code, no invites, no new mail domains, reports only in the panel and webhooks | +| A server without mail (port 25 blocked, no relay) | Settings → Mail → No mail: deletions are confirmed in the panel with the 2FA code (the password without 2FA), no invites, no new mail domains, reports only in the panel and webhooks | | genco sends mail but hosts none | Settings → Mail → uncheck Mail hosting: no new mail domains, mailboxes, forwards or send-only addresses (panel, CLI and agent refuse them); genco's own mails (confirmations, invites, proposal links) keep going. Existing mail domains stay and keep running until deleted; without any, Dovecot, Rspamd and the submission ports stay off as always. No mail implies hosting off | | A PostgreSQL database | `genco database add --group --postgres`, then `genco apply` (installs PostgreSQL the first time) | | Environment variables of an app | `genco domain env --set NAME=value --secret NAME=value --unset NAME`, then `genco apply` | @@ -173,15 +173,15 @@ days, an action after 24 hours). Below are the decided and expired ones of the last 30 days. Find (Ctrl+K) finds both by number ("A12", "#12") and text. -Confirming in the panel takes a second factor, the same for both kinds: +Confirming in the panel takes one more factor, the same for both kinds +(the session already proves the password): -- an admin with two-factor login confirms with their password and a 2FA - code (the code is used up, wrong passwords and codes count toward the - login's limits); +- an admin with two-factor login confirms with a 2FA code (the code is + used up, wrong codes count toward the login's limit); - an admin without 2FA can only reject in the panel; the link in the mail confirms (turn on Start → Two-Factor Login to confirm in the panel); - with mail off (Settings → Mail) nothing is mailed, so the password - confirms, with the 2FA code when the admin has 2FA. + confirms, or the 2FA code when the admin has 2FA. The mails stay the way to confirm from a phone: each admin gets their own link, the real link comes first and the description (it may hold names @@ -544,7 +544,7 @@ weekly and 12 monthly per group and kind). Everything is in the backup's log. A failed copy doesn't fail the backup but is a problem of its own, like no successful copy for more than 2 days. -Set it up in Settings → Backups (your password and 2FA code) or as root: +Set it up in Settings → Backups (your 2FA code, or password without 2FA) or as root: ```sh # Backblaze B2: a bucket and an application key for it (read, write, delete) @@ -558,7 +558,7 @@ genco backup remote test # connects, creates the repository if there is none ``` Where the copy goes decides where all data goes. The panel asks for your -password and 2FA code; when the agent runs `genco backup remote set` or +2FA code (your password without 2FA); when the agent runs `genco backup remote set` or `off`, it only asks: it waits in Start → Approvals with a number and every admin gets a mail to confirm it (with mail off, the agent gets a link where an admin confirms it with their password), the same as a deletion. Root in a terminal changes it directly. Whoever does @@ -573,8 +573,8 @@ panel shows neither. genco makes the remote repository's own password and shows it **once**, when you save. When an admin confirmed the agent's request, the agent -never sees it: Settings → Backups shows "Show Password" (your password -and 2FA code) to the first admin who opens it, once. Keep it outside the server with the bucket's key, in your +never sees it: Settings → Backups shows "Show Password" (your 2FA code, +or password without 2FA) to the first admin who opens it, once. Keep it outside the server with the bucket's key, in your password manager: if the server is lost, the remote copy is useless without it. To take over an existing remote repository (a new server, see [section 4](#4-moving-or-rebuilding-the-whole-server)), enter its password @@ -865,7 +865,7 @@ agent may: follows it where the two differ, and the answer to the webhook starts with " approved it with a comment: ", the proposal's `comment` holds it. Approving in the panel takes the - admin's password and 2FA code (see [Approvals](#approvals)). Nobody decided within 7 days, the + admin's 2FA code (see [Approvals](#approvals)). Nobody decided within 7 days, the status becomes `expired` and the webhook gets "The proposal expired after 7 days without a decision." once. diff --git a/server/src/actions.rs b/server/src/actions.rs index d653cbb..06c24f0 100644 --- a/server/src/actions.rs +++ b/server/src/actions.rs @@ -1,8 +1,8 @@ //! Deleting a domain, a database or a group doesn't happen right away: it //! becomes a pending action ("A12") and every admin gets a mail with their //! own link. It runs when the admin opens the link while logged in and -//! confirms, or confirms it in Start → Approvals with their password and -//! two-factor code (approvals.rs); any admin may reject it there, the +//! confirms, or confirms it in Start → Approvals with their two-factor +//! code (approvals.rs); any admin may reject it there, the //! conversation of genco's agent that asked for it gets the reason. What //! it deletes goes to the trash first (dump, archive), kept 30 days. //! With mail off (Settings → Mail) the password confirms it in the panel, @@ -434,8 +434,8 @@ fn mail_body(cfg: &Config, action: &Action, token: &str) -> String { Confirm: {link}\n\ (open it while logged in to the panel; your own link, it works for \ 24 hours)\n\n\ - Or open Start → Approvals, confirming there asks for your password \ - and 2FA code; you can reject it there too:\n\n{panel}\n\n\ + Or open Start → Approvals, confirming there asks for your 2FA code; \ + you can reject it there too:\n\n{panel}\n\n\ Nothing happens without a confirmation. If you didn't expect this, \ reject it or ignore it.\n\n\ ----------------------------------------\n\n\ @@ -957,7 +957,7 @@ fn pending(action: &Action) -> Result<()> { } /// The link of the mail proves the mailbox of its admin, without one the -/// admin's password and code do. +/// admin's step-up does (`users::verify_step_up`). async fn check_confirm( db: &Db, authed: &Authed, @@ -974,14 +974,18 @@ async fn check_confirm( )); } if action.by_password { - users::verify_password(db, &authed.user, &req.password).await?; - users::verify_code(db, &authed.user, &req.code).await?; + let auth = users::StepUp { + password: req.password.clone(), + code: req.code.clone(), + }; + users::verify_step_up(db, &authed.user, &auth).await?; } Ok(action) } -/// Confirming in Approvals: the password and 2FA code, like a proposal -/// (`users::verify_approval`); the password alone when nothing was mailed. +/// Confirming in Approvals: the 2FA code, like a proposal +/// (`users::verify_approval`); without 2FA the password when nothing was +/// mailed. /// Mail switched off later doesn't count, the mailed links stay the way /// without 2FA. async fn check_panel( @@ -1357,12 +1361,11 @@ mod tests { let tried = check_panel(&db, &authed(&a), id, &password).await; assert!(matches!(tried, Err(Error::Invalid(e)) if e == users::NO_2FA)); + // with 2FA the code alone, the password doesn't help let code = with_2fa(&db, &mut a); - for wrong in [req("admin password", ""), req("wrong", &code())] { - let tried = check_panel(&db, &authed(&a), id, &wrong).await; - assert!(tried.is_err()); - } - let right = req("admin password", &code()); + let wrong = req("admin password", ""); + assert!(check_panel(&db, &authed(&a), id, &wrong).await.is_err()); + let right = req("", &code()); let action = check_panel(&db, &authed(&a), id, &right).await.unwrap(); assert_eq!(action.id, id); // the code is used up diff --git a/server/src/approvals.rs b/server/src/approvals.rs index d9ed1ab..b0f85c7 100644 --- a/server/src/approvals.rs +++ b/server/src/approvals.rs @@ -2,7 +2,7 @@ //! proposals (proposals.rs, "#12") and the pending actions (actions.rs, //! "A12": deletions, the agent's remote backup changes), newest first, //! with the decided and expired ones of the last 30 days below. Both -//! confirm in the panel the same way: the password and the 2FA code +//! confirm in the panel the same way: the 2FA code //! (`users::verify_approval`). use axum::extract::State; diff --git a/server/src/backup/remote.rs b/server/src/backup/remote.rs index e6dd44f..e46a864 100644 --- a/server/src/backup/remote.rs +++ b/server/src/backup/remote.rs @@ -4,7 +4,7 @@ //! repository password are sealed with the secrets key, the API only says //! whether they are set, and restic gets them through its environment. //! Where the copy goes decides where all data goes: the panel asks for the -//! password and code, the agent's changes wait for an admin's confirmation +//! step-up (2FA code, or password), the agent's changes wait for an admin's confirmation //! (actions.rs), and every change or stop is sent to all admins. use std::path::{Path, PathBuf}; @@ -946,9 +946,9 @@ async fn get_settings( struct SaveReq { #[serde(flatten)] settings: Settings, - /// the admin's password and code: it decides where all data goes + /// the admin's step-up: it decides where all data goes #[serde(flatten)] - auth: crate::users::PasswordOnly, + auth: crate::users::StepUp, } async fn save_settings( @@ -957,8 +957,7 @@ async fn save_settings( State(db): State, Json(req): Json, ) -> Result>> { - crate::users::verify_password_and_code(&db, &authed.user, &req.auth) - .await?; + crate::users::verify_step_up(&db, &authed.user, &req.auth).await?; let key_file = crate::secrets::key_file(); let by = &authed.user.name; let generated = change(&cfg, &db, key_file, req.settings, by).await?; @@ -970,9 +969,9 @@ async fn save_settings( async fn new_password( authed: Authed, State(db): State, - Json(auth): Json, + Json(auth): Json, ) -> Result> { - crate::users::verify_password_and_code(&db, &authed.user, &auth).await?; + crate::users::verify_step_up(&db, &authed.user, &auth).await?; let conn = db.conn(); let sealed: Vec = get_json(&conn, NEW_PASSWORD) .ok_or_else(|| Error::Invalid("it was shown already".into()))?; diff --git a/server/src/mail.rs b/server/src/mail.rs index 4ed6a0d..2e877a9 100644 --- a/server/src/mail.rs +++ b/server/src/mail.rs @@ -453,10 +453,10 @@ struct SaveReq { /// hosting has its own route, here it is refused instead of ignored #[serde(default)] hosting: Option, - /// the admin's password and code: a relay of a stolen session would + /// the admin's step-up: a relay of a stolen session would /// get the mails that confirm deletions #[serde(flatten)] - auth: crate::users::PasswordOnly, + auth: crate::users::StepUp, } async fn save_settings( @@ -469,8 +469,7 @@ async fn save_settings( "hosting is switched with POST /settings/mail/hosting".into(), )); } - crate::users::verify_password_and_code(&db, &authed.user, &req.auth) - .await?; + crate::users::verify_step_up(&db, &authed.user, &req.auth).await?; let mut new = req.settings; match new.mode.as_str() { "direct" | "off" => {} @@ -688,7 +687,7 @@ mod tests { } #[tokio::test] - async fn saving_needs_the_password_and_code() { + async fn saving_needs_the_code_with_2fa() { let db = Db::memory(); let hash = crate::users::hash_password("admin password").unwrap(); let a = crate::users::create(&db.conn(), "m@example.ch", "A", &hash) @@ -710,6 +709,7 @@ mod tests { "relayHost": "evil.example.ch", "relayPort": 587, "password": password, + "code": "000000", }); save_settings( authed(), @@ -718,10 +718,9 @@ mod tests { ) }; - let wrong = save("wrong").await.unwrap_err().to_string(); - assert!(wrong.contains("password is wrong"), "{wrong}"); - let no_code = save("admin password").await.unwrap_err().to_string(); - assert!(no_code.contains("two-factor"), "{no_code}"); + // the password doesn't help, the code is wrong + let wrong = save("admin password").await.unwrap_err().to_string(); + assert!(wrong.contains("two-factor code is wrong"), "{wrong}"); assert_ne!(settings(&db).unwrap().relay_host, "evil.example.ch"); } diff --git a/server/src/peers.rs b/server/src/peers.rs index 15a162a..458f7c1 100644 --- a/server/src/peers.rs +++ b/server/src/peers.rs @@ -231,9 +231,9 @@ struct CreateReq { webhook_url: String, #[serde(default)] webhook_all: bool, - /// the admin's password and code: a key is lasting access + /// the admin's step-up: a key is lasting access #[serde(flatten)] - auth: crate::users::PasswordOnly, + auth: crate::users::StepUp, } async fn create_route( @@ -241,8 +241,7 @@ async fn create_route( State(db): State, Json(req): Json, ) -> Result> { - crate::users::verify_password_and_code(&db, &authed.user, &req.auth) - .await?; + crate::users::verify_step_up(&db, &authed.user, &req.auth).await?; let (_, key) = create( &db.conn(), &req.name, @@ -260,12 +259,10 @@ struct ChangeReq { webhook_url: String, #[serde(default)] webhook_all: bool, - /// the admin's, and the 2FA code, when the webhook changes: genco - /// sends the key there - #[serde(default)] - password: String, - #[serde(default)] - code: String, + /// the admin's step-up when the webhook changes: genco sends the key + /// there + #[serde(flatten)] + auth: crate::users::StepUp, } async fn change_route( @@ -278,8 +275,7 @@ async fn change_route( let url = req.webhook_url.trim(); check(&peer.name, url)?; if url != peer.webhook_url { - crate::users::verify_password(&db, &authed.user, &req.password).await?; - crate::users::verify_code(&db, &authed.user, &req.code).await?; + crate::users::verify_step_up(&db, &authed.user, &req.auth).await?; } let conn = db.conn(); conn.execute( @@ -294,9 +290,9 @@ async fn new_key_route( authed: Authed, State(db): State, Path(id): Path, - Json(req): Json, + Json(req): Json, ) -> Result> { - crate::users::verify_password_and_code(&db, &authed.user, &req).await?; + crate::users::verify_step_up(&db, &authed.user, &req).await?; let key = tokens::new_token(); let changed = db.conn().execute( "UPDATE peers SET key = ?1, key_hash = ?2 WHERE id = ?3", diff --git a/server/src/proposals/mail.rs b/server/src/proposals/mail.rs index 4dabf73..2e4d406 100644 --- a/server/src/proposals/mail.rs +++ b/server/src/proposals/mail.rs @@ -115,8 +115,8 @@ fn mail_body( format!( "The AI agent {from} proposes a task for genco's agent \ (proposal #{id}).\n\n\ - {link}open Start → Approvals (approving there asks for your password \ - and 2FA code):\n\n{panel}Nothing happens \ + {link}open Start → Approvals (approving there asks for your 2FA \ + code):\n\n{panel}Nothing happens \ without an approval. It expires in {VALID_DAYS} days.\n\n\ ----------------------------------------\n\n\ {rewritten}{warnings}The proposal, as {from} wrote it (links in it \ @@ -219,8 +219,8 @@ mod tests { Approve or reject: https://panel.example.ch/proposal/abc\n\ (works without logging in, once; shows the proposal first; \ approving asks for your 2FA code, or your password)\n\n\ - Or open Start → Approvals (approving there asks for your password \ - and 2FA code):\n\n\ + Or open Start → Approvals (approving there asks for your 2FA \ + code):\n\n\ https://panel.example.ch/approvals\n\n" )); let line = body.find("\n-----").unwrap(); diff --git a/server/src/runners/mod.rs b/server/src/runners/mod.rs index a32684d..e57a966 100644 --- a/server/src/runners/mod.rs +++ b/server/src/runners/mod.rs @@ -337,10 +337,10 @@ async fn save_settings( #[derive(Deserialize)] struct CreateReq { name: String, - /// the admin's password and code: a runner token takes the groups' + /// the admin's step-up: a runner token takes the groups' /// sources #[serde(flatten)] - auth: crate::users::PasswordOnly, + auth: crate::users::StepUp, } async fn create_route( @@ -348,8 +348,7 @@ async fn create_route( State(db): State, Json(req): Json, ) -> Result> { - crate::users::verify_password_and_code(&db, &authed.user, &req.auth) - .await?; + crate::users::verify_step_up(&db, &authed.user, &req.auth).await?; Ok(Json(create(&db.conn(), &req.name)?)) } diff --git a/server/src/tokens.rs b/server/src/tokens.rs index 89a62bd..cc3ced5 100644 --- a/server/src/tokens.rs +++ b/server/src/tokens.rs @@ -272,9 +272,9 @@ async fn list_tokens( struct CreateReq { name: String, scopes: Vec, - /// the admin's password and code: a token is lasting access + /// the admin's step-up: a token is lasting access #[serde(flatten)] - auth: crate::users::PasswordOnly, + auth: crate::users::StepUp, } async fn create_token( @@ -282,8 +282,7 @@ async fn create_token( State(db): State, Json(req): Json, ) -> Result> { - crate::users::verify_password_and_code(&db, &authed.user, &req.auth) - .await?; + crate::users::verify_step_up(&db, &authed.user, &req.auth).await?; Ok(Json(create( &db.conn(), &req.name, diff --git a/server/src/users.rs b/server/src/users.rs index 3731560..d515f14 100644 --- a/server/src/users.rs +++ b/server/src/users.rs @@ -510,12 +510,12 @@ async fn delete_user( authed: Authed, State(db): State, Path(id): Path, - Json(req): Json, + Json(req): Json, ) -> Result> { if id == authed.user.id { return Err(Error::Invalid("you can't delete yourself".into())); } - verify_password_and_code(&db, &authed.user, &req).await?; + verify_step_up(&db, &authed.user, &req).await?; db.conn().execute("DELETE FROM users WHERE id = ?1", [id])?; Ok(Json(())) } @@ -561,10 +561,11 @@ pub async fn verify_password( Ok(()) } -/// What a dangerous request sends along: the admin's password, and the -/// 2FA code when the admin has 2FA (`verify_password_and_code`). +/// What a dangerous request sends along: the 2FA code when the admin +/// has 2FA, else the password (`verify_step_up`). #[derive(Debug, Default, Deserialize)] -pub struct PasswordOnly { +pub struct StepUp { + #[serde(default)] pub password: String, #[serde(default)] pub code: String, @@ -593,10 +594,8 @@ async fn change_password( struct AccountReq { email: String, name: String, - password: String, - /// of the authenticator app, for a new email when the admin has 2FA - #[serde(default)] - code: String, + #[serde(flatten)] + auth: StepUp, } #[derive(Debug, Serialize)] @@ -609,9 +608,9 @@ struct AccountResp { /// An admin changes their own name, and email: the new one only counts /// once the link mailed to it is opened, then the old address hears of -/// it. With 2FA a stolen session and password alone can't move the -/// account and its confirmation mails away. With mail off no link can -/// go, password and code change it right away. +/// it. With the step-up a stolen session alone can't move the account +/// and its confirmation mails away. With mail off no link can go, the +/// step-up changes it right away. async fn change_account( authed: Authed, State(cfg): State>, @@ -619,17 +618,15 @@ async fn change_account( Json(req): Json, ) -> Result> { let user = authed.user; - verify_password(&db, &user, &req.password).await?; + verify_step_up(&db, &user, &req.auth).await?; let (email, name) = normalize(&req.email, &req.name)?; let confirm_sent = if email == user.email { None } else if mail::off(&db) { - verify_code(&db, &user, &req.code).await?; set_email(&db.conn(), user.id, &email)?; None } else { - verify_code(&db, &user, &req.code).await?; send_email_link(&cfg, &db, &user, &email, &name).await?; Some(email) }; @@ -792,10 +789,10 @@ const INVITE_MS: i64 = 3 * 24 * 60 * 60 * 1000; struct InviteReq { email: String, name: String, - /// the inviting admin's password and code: a new admin is lasting + /// the inviting admin's step-up: a new admin is lasting /// access #[serde(flatten)] - auth: PasswordOnly, + auth: StepUp, } /// Adds an admin without a password, the mail has a link to set one. @@ -808,7 +805,7 @@ async fn invite( if mail::off(&db) { return Err(mail::off_error("add the admin with a password")); } - verify_password_and_code(&db, &authed.user, &req.auth).await?; + verify_step_up(&db, &authed.user, &req.auth).await?; // nobody knows this password, the invite sets the real one let hash = hash_password_async(util::random_hex(32)).await?; let user = create(&db.conn(), &req.email, &req.name, &hash)?; @@ -832,7 +829,7 @@ struct AddReq { new_password: String, /// the adding admin's, as for an invite #[serde(flatten)] - auth: PasswordOnly, + auth: StepUp, } /// Adds an admin with a password, who can log in right away, for a @@ -844,7 +841,7 @@ async fn add( State(db): State, Json(req): Json, ) -> Result> { - verify_password_and_code(&db, &authed.user, &req.auth).await?; + verify_step_up(&db, &authed.user, &req.auth).await?; let hash = hash_password_async(req.new_password).await?; let user = create(&db.conn(), &req.email, &req.name, &hash)?; @@ -880,9 +877,9 @@ async fn reinvite( State(cfg): State>, State(db): State, Path(id): Path, - Json(req): Json, + Json(req): Json, ) -> Result> { - verify_password_and_code(&db, &authed.user, &req).await?; + verify_step_up(&db, &authed.user, &req).await?; let user = by_id(&db.conn(), id)?; send_invite(&cfg, &db, &user, &authed.user.name).await?; Ok(Json(())) @@ -1071,22 +1068,22 @@ fn use_step(db: &Db, user_id: i64, step: u64) -> rusqlite::Result { Ok(changed == 1) } -/// The admin's password and, with 2FA on, a code of the authenticator -/// app: for what a stolen session and password mustn't do either. -pub async fn verify_password_and_code( - db: &Db, - user: &User, - req: &PasswordOnly, -) -> Result<()> { - verify_password(db, user, &req.password).await?; - verify_code(db, user, &req.code).await +/// A step-up for what a stolen session alone mustn't do: the session +/// already proves the password, so one more factor. With 2FA on that is +/// a code of the authenticator app, else the password; the other field +/// is ignored. +pub async fn verify_step_up(db: &Db, user: &User, req: &StepUp) -> Result<()> { + if user.totp { + verify_code(db, user, &req.code).await + } else { + verify_password(db, user, &req.password).await + } } /// Confirming in the panel (Approvals) what a mail link confirms too: a -/// proposal or a pending action. It needs the password and the 2FA code, -/// a stolen session and password mustn't do it. Without 2FA the panel -/// only rejects and the mail's link confirms; with nothing `mailed` (mail -/// is off) the password alone does. +/// proposal or a pending action, with a step-up (`verify_step_up`). +/// Without 2FA the panel only rejects and the mail's link confirms; with +/// nothing `mailed` (mail is off) the password does. pub async fn verify_approval( db: &Db, user: &User, @@ -1097,8 +1094,11 @@ pub async fn verify_approval( if !user.totp && mailed { return Err(Error::Invalid(NO_2FA.into())); } - verify_password(db, user, password).await?; - verify_code(db, user, code).await + let req = StepUp { + password: password.into(), + code: code.into(), + }; + verify_step_up(db, user, &req).await } pub const NO_2FA: &str = "Turn on two-factor login to confirm here, or use \ @@ -1156,12 +1156,12 @@ async fn enable_totp( async fn disable_totp( authed: Authed, State(db): State, - Json(req): Json, + Json(req): Json, ) -> Result> { if !authed.user.totp { return Err(Error::Invalid("two-factor login is off".into())); } - verify_password_and_code(&db, &authed.user, &req).await?; + verify_step_up(&db, &authed.user, &req).await?; db.conn().execute( "UPDATE users SET totp_secret = NULL WHERE id = ?1", [authed.user.id], @@ -1250,8 +1250,8 @@ mod tests { assert!(normalize("a@example.ch", " ").is_err()); } - fn auth(password: &str, code: &str) -> PasswordOnly { - PasswordOnly { + fn auth(password: &str, code: &str) -> StepUp { + StepUp { password: password.into(), code: code.into(), } @@ -1422,8 +1422,7 @@ mod tests { let req = AccountReq { email: "new@example.ch".into(), name: "A".into(), - password: "admin password".into(), - code: String::new(), + auth: auth("admin password", ""), }; let resp = change_account(authed(), State(cfg), State(db.clone()), Json(req)) @@ -1505,7 +1504,7 @@ mod tests { authed(), State(db.clone()), Path(new.id), - Json(auth("admin password", &code(0))), + Json(auth("", &code(0))), ) .await .unwrap() @@ -1526,6 +1525,35 @@ mod tests { assert!(!by_id(&db.conn(), admin.id).unwrap().totp); } + #[tokio::test] + async fn a_step_up_asks_one_factor() { + let db = Db::memory(); + let (admin, secret) = admin_with_2fa(&db, 711, "step@example.ch"); + let code = code_at(&secret, &admin.email, 0); + + // with 2FA the code alone, the password doesn't help + let wrong = auth("admin password", ""); + assert!(verify_step_up(&db, &admin, &wrong).await.is_err()); + let failures = CODE_FAILURES.lock().unwrap().get("code:711").copied(); + assert_eq!(failures.map(|f| f.0), Some(1)); + verify_step_up(&db, &admin, &auth("wrong", &code)) + .await + .unwrap(); + assert!(!CODE_FAILURES.lock().unwrap().contains_key("code:711")); + // used up + let again = auth("", &code); + assert!(verify_step_up(&db, &admin, &again).await.is_err()); + + // without 2FA the password, the code doesn't matter + let hash = hash_password("other password").unwrap(); + let b = create(&db.conn(), "b@example.ch", "B", &hash).unwrap(); + let wrong = auth("", &code); + assert!(verify_step_up(&db, &b, &wrong).await.is_err()); + verify_step_up(&db, &b, &auth("other password", "")) + .await + .unwrap(); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn parallel_requests_cant_use_a_code_twice() { let db = Db::memory(); diff --git a/webui/src/components/SecondFactor.svelte b/webui/src/components/SecondFactor.svelte index 4e2a25a..09e127e 100644 --- a/webui/src/components/SecondFactor.svelte +++ b/webui/src/components/SecondFactor.svelte @@ -1,8 +1,8 @@ - - - {#if session.user?.totp} + +{:else} + + + {/if} diff --git a/webui/src/components/dialogs/AccountDialog.svelte b/webui/src/components/dialogs/AccountDialog.svelte index 5f26fad..9140f8f 100644 --- a/webui/src/components/dialogs/AccountDialog.svelte +++ b/webui/src/components/dialogs/AccountDialog.svelte @@ -5,6 +5,7 @@ import { session } from '@/lib/session.svelte'; import Dialog from '../win/Dialog.svelte'; import Form from '../win/Form.svelte'; + import SecondFactor from '../SecondFactor.svelte'; let { onclose }: { onclose: () => void } = $props(); @@ -45,23 +46,7 @@ - - - {#if session.user?.totp} - - - {/if} + diff --git a/webui/src/components/dialogs/ApprovalsDialog.svelte b/webui/src/components/dialogs/ApprovalsDialog.svelte index d5e7f6b..ce8b580 100644 --- a/webui/src/components/dialogs/ApprovalsDialog.svelte +++ b/webui/src/components/dialogs/ApprovalsDialog.svelte @@ -30,8 +30,8 @@ // Start → Approvals: the AI agents' proposals (#12) and the pending // actions (A12: deletions, the agent's remote backup changes) that wait // for an admin, the decided ones of 30 days below. Confirming here - // takes the password and 2FA code; without 2FA only the mail's link - // confirms, rejecting works here for any admin. + // takes the 2FA code; without 2FA only the mail's link confirms, + // rejecting works here for any admin. let { onclose }: { onclose: () => void } = $props(); let selected = $state(null); diff --git a/webui/src/components/dialogs/ApproveDialog.svelte b/webui/src/components/dialogs/ApproveDialog.svelte index 9fb4b25..3a15fb3 100644 --- a/webui/src/components/dialogs/ApproveDialog.svelte +++ b/webui/src/components/dialogs/ApproveDialog.svelte @@ -7,7 +7,8 @@ // approves a proposal, the comment changes the task: genco's agent // follows it where it differs from the proposal, and the proposing - // agent gets it with the answer. The password and 2FA code confirm it. + // agent gets it with the answer. The 2FA code (the password + // without 2FA) confirms it. let { proposal, onapproved, diff --git a/webui/src/components/dialogs/ConfirmActionDialog.svelte b/webui/src/components/dialogs/ConfirmActionDialog.svelte index bd0337f..9e856d5 100644 --- a/webui/src/components/dialogs/ConfirmActionDialog.svelte +++ b/webui/src/components/dialogs/ConfirmActionDialog.svelte @@ -5,8 +5,8 @@ import Form from '../win/Form.svelte'; import SecondFactor from '../SecondFactor.svelte'; - // confirms a pending action in Approvals with the password and 2FA - // code, then shows what it did + // confirms a pending action in Approvals with the 2FA code (the + // password without 2FA), then shows what it did let { action, onclose }: { action: PendingAction; onclose: () => void } = $props(); diff --git a/webui/src/components/dialogs/MessageBox.svelte b/webui/src/components/dialogs/MessageBox.svelte index 1aab849..c6a69be 100644 --- a/webui/src/components/dialogs/MessageBox.svelte +++ b/webui/src/components/dialogs/MessageBox.svelte @@ -30,7 +30,7 @@

{box.text}

- {#if box.kind === 'ask' || box.kind === 'password'} + {#if box.kind === 'ask' || (box.kind === 'password' && !box.code)} { + requestAnimationFrame(() => el.focus()); + }} /> {/if}
diff --git a/webui/src/components/dialogs/TotpDialog.svelte b/webui/src/components/dialogs/TotpDialog.svelte index ea04a31..7c013eb 100644 --- a/webui/src/components/dialogs/TotpDialog.svelte +++ b/webui/src/components/dialogs/TotpDialog.svelte @@ -35,7 +35,7 @@ } async function disable() { - await post('/users/totp/disable', { code, password }); + await post('/users/totp/disable', { code }); session.user = { ...session.user!, totp: false }; done = 'Two-factor login is off.'; } @@ -49,21 +49,19 @@ {:else if enabled}

- Two-factor login is on. To turn it off, type your password and a - current code. + Two-factor login is on. To turn it off, enter the code from your + authenticator app.

- + - -
{:else if setup}

diff --git a/webui/src/lib/dialogs.svelte.ts b/webui/src/lib/dialogs.svelte.ts index 9ddc509..00e4ba6 100644 --- a/webui/src/lib/dialogs.svelte.ts +++ b/webui/src/lib/dialogs.svelte.ts @@ -35,7 +35,7 @@ type Box = { kind: 'message' | 'confirm' | 'ask' | 'password'; /** the input's value of an `ask` */ value: string; - /** a `password` box asks for the two-factor code too */ + /** a `password` box asks for the two-factor code instead */ code: boolean; done: (answer: string | null, code: string) => void; }; @@ -83,14 +83,14 @@ class Message { } /** - * The admin's own password, and the two-factor code when 2FA is on, - * for what a stolen session and password mustn't do (invites, tokens, - * mail settings), null for Cancel. + * One more factor for what a stolen session alone mustn't do (invites, + * tokens, mail settings): the two-factor code when 2FA is on, else the + * admin's password; null for Cancel. */ password(title: string, what: string): Promise { const code = session.user?.totp ?? false; const text = code - ? `Your password and a two-factor code, to ${what}:` + ? `Enter the code from your authenticator app, to ${what}:` : `Your password, to ${what}:`; return new Promise(done => this.box({ diff --git a/webui/src/pages/Confirm.svelte b/webui/src/pages/Confirm.svelte index 24bdd3a..141d258 100644 --- a/webui/src/pages/Confirm.svelte +++ b/webui/src/pages/Confirm.svelte @@ -17,7 +17,7 @@ // the link of a confirmation mail: an admin confirms a deletion or the // agent's change of the remote backup; with mail off nothing was - // mailed, the password and code confirm it + // mailed, the 2FA code (the password without 2FA) confirms it let { token, action, From bab8c81ed18e11b6c1a85b1347b93dec54402aea Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 7 Nov 2026 12:58:32 +0100 Subject: [PATCH 2/3] Keep the lock of wrong tries while it waits A try refused by the lock still went through attempt_failed, which started the count over at 1, so the next tries were allowed again and the 15 minutes never held. With one step-up factor the session alone reaches the code check, so a stolen session could have guessed 2FA codes without a real limit. A refused try now leaves count and wait as they are; after the wait the entry is dropped and the count restarts. Co-Authored-By: Claude Opus 5.5 --- server/src/users.rs | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/server/src/users.rs b/server/src/users.rs index d515f14..b8c46a7 100644 --- a/server/src/users.rs +++ b/server/src/users.rs @@ -991,11 +991,11 @@ pub fn attempt_failed(key: &str, max: u32) { // unknown emails would grow the map without end failures.retain(|_, (_, last)| *last + CODE_LOCK_MS > now()); let entry = failures.entry(key.to_string()).or_insert((0, 0)); - // after the wait the count starts over - if entry.0 >= max { - entry.0 = 0; + // a try while it waits neither restarts the count nor the wait; after + // the wait the entry is gone and the count starts over + if entry.0 < max { + *entry = (entry.0 + 1, now()); } - *entry = (entry.0 + 1, now()); } fn totp(secret: &str, email: &str) -> Option { @@ -1087,18 +1087,13 @@ pub async fn verify_step_up(db: &Db, user: &User, req: &StepUp) -> Result<()> { pub async fn verify_approval( db: &Db, user: &User, - password: &str, - code: &str, + req: &StepUp, mailed: bool, ) -> Result<()> { if !user.totp && mailed { return Err(Error::Invalid(NO_2FA.into())); } - let req = StepUp { - password: password.into(), - code: code.into(), - }; - verify_step_up(db, user, &req).await + verify_step_up(db, user, req).await } pub const NO_2FA: &str = "Turn on two-factor login to confirm here, or use \ @@ -1554,6 +1549,19 @@ mod tests { .unwrap(); } + #[test] + fn tries_while_locked_dont_restart_the_count() { + let key = "test:lock"; + for _ in 0..MAX_CODE_FAILURES { + attempt_failed(key, MAX_CODE_FAILURES); + } + assert!(!attempts_allowed(key, MAX_CODE_FAILURES)); + // a refused try counts too, the lock holds + attempt_failed(key, MAX_CODE_FAILURES); + assert!(!attempts_allowed(key, MAX_CODE_FAILURES)); + CODE_FAILURES.lock().unwrap().remove(key); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn parallel_requests_cant_use_a_code_twice() { let db = Db::memory(); From 7d2e58c8e69d8dddb4c87144e9caae7a1db7e561 Mon Sep 17 00:00:00 2001 From: Noah Date: Sat, 7 Nov 2026 12:58:32 +0100 Subject: [PATCH 3/3] Use StepUp for every confirmation request The mail-link approval did its own code-or-password check and the Approvals requests declared password and code by hand; they now flatten users::StepUp and go through verify_step_up and verify_approval. The message box resets its value with each new box, a code-only box sent the text of the last one as the password. Stale comments about password and code fixed, the mail settings test no longer uses a fixed code that could be valid. Co-Authored-By: Claude Opus 5.5 --- docs/OPERATIONS.md | 13 +++--- server/src/actions.rs | 41 ++++++------------- server/src/backup/remote.rs | 5 ++- server/src/mail.rs | 6 +-- server/src/proposals.rs | 20 +++++---- server/src/proposals/links.rs | 32 +++++++-------- .../src/components/dialogs/MessageBox.svelte | 8 ++-- 7 files changed, 59 insertions(+), 66 deletions(-) diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 1f19aed..6226de5 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -544,7 +544,8 @@ weekly and 12 monthly per group and kind). Everything is in the backup's log. A failed copy doesn't fail the backup but is a problem of its own, like no successful copy for more than 2 days. -Set it up in Settings → Backups (your 2FA code, or password without 2FA) or as root: +Set it up in Settings → Backups (your 2FA code, or password without +2FA) or as root: ```sh # Backblaze B2: a bucket and an application key for it (read, write, delete) @@ -558,8 +559,8 @@ genco backup remote test # connects, creates the repository if there is none ``` Where the copy goes decides where all data goes. The panel asks for your -2FA code (your password without 2FA); when the agent runs `genco backup remote set` or -`off`, it only asks: it waits in Start → Approvals with a number and every +2FA code (your password without 2FA); when the agent runs `genco backup +remote set` or `off`, it only asks: it waits in Start → Approvals with a number and every admin gets a mail to confirm it (with mail off, the agent gets a link where an admin confirms it with their password), the same as a deletion. Root in a terminal changes it directly. Whoever does it, every admin gets a mail (and the AI agents' webhooks a notice) when @@ -574,7 +575,8 @@ panel shows neither. genco makes the remote repository's own password and shows it **once**, when you save. When an admin confirmed the agent's request, the agent never sees it: Settings → Backups shows "Show Password" (your 2FA code, -or password without 2FA) to the first admin who opens it, once. Keep it outside the server with the bucket's key, in your +or password without 2FA) to the first admin who opens it, once. Keep it +outside the server with the bucket's key, in your password manager: if the server is lost, the remote copy is useless without it. To take over an existing remote repository (a new server, see [section 4](#4-moving-or-rebuilding-the-whole-server)), enter its password @@ -865,7 +867,8 @@ agent may: follows it where the two differ, and the answer to the webhook starts with " approved it with a comment: ", the proposal's `comment` holds it. Approving in the panel takes the - admin's 2FA code (see [Approvals](#approvals)). Nobody decided within 7 days, the + admin's 2FA code (see [Approvals](#approvals)). Nobody decided within + 7 days, the status becomes `expired` and the webhook gets "The proposal expired after 7 days without a decision." once. diff --git a/server/src/actions.rs b/server/src/actions.rs index 06c24f0..34495b2 100644 --- a/server/src/actions.rs +++ b/server/src/actions.rs @@ -6,8 +6,8 @@ //! conversation of genco's agent that asked for it gets the reason. What //! it deletes goes to the trash first (dump, archive), kept 30 days. //! With mail off (Settings → Mail) the password confirms it in the panel, -//! the two-factor code too when the admin has 2FA. The agent's changes of -//! the remote backup go the same way (backup/remote.rs). +//! the two-factor code instead when the admin has 2FA. The agent's changes +//! of the remote backup go the same way (backup/remote.rs). use std::sync::Arc; use std::sync::atomic::{AtomicI64, Ordering}; @@ -936,16 +936,6 @@ async fn get_action( Ok(Json(find(&db.conn(), &token)?.0)) } -#[derive(Debug, Default, Deserialize)] -struct ConfirmReq { - /// the admin's, in Approvals or for an action that wasn't mailed - #[serde(default)] - password: String, - /// of the authenticator app when the admin has 2FA - #[serde(default)] - code: String, -} - fn pending(action: &Action) -> Result<()> { if action.status != "pending" { return Err(Error::Invalid(format!( @@ -962,7 +952,7 @@ async fn check_confirm( db: &Db, authed: &Authed, token: &str, - req: &ConfirmReq, + req: &users::StepUp, ) -> Result { let (action, link_user) = find(&db.conn(), token)?; pending(&action)?; @@ -974,11 +964,7 @@ async fn check_confirm( )); } if action.by_password { - let auth = users::StepUp { - password: req.password.clone(), - code: req.code.clone(), - }; - users::verify_step_up(db, &authed.user, &auth).await?; + users::verify_step_up(db, &authed.user, req).await?; } Ok(action) } @@ -992,13 +978,12 @@ async fn check_panel( db: &Db, authed: &Authed, id: i64, - req: &ConfirmReq, + req: &users::StepUp, ) -> Result { let action = get_one(&db.conn(), id)?; pending(&action)?; let mailed = !action.by_password; - users::verify_approval(db, &authed.user, &req.password, &req.code, mailed) - .await?; + users::verify_approval(db, &authed.user, req, mailed).await?; Ok(action) } @@ -1014,7 +999,7 @@ async fn confirm( State(db): State, State(events): State, Path(key): Path, - Json(req): Json, + Json(req): Json, ) -> Result> { let action = match action_id(&key) { Some(id) => check_panel(&db, &authed, id, &req).await?, @@ -1251,9 +1236,9 @@ mod tests { let token = path.strip_prefix("/confirm/").unwrap(); assert!(find(&db.conn(), token).unwrap().0.by_password); - let none = ConfirmReq::default(); + let none = users::StepUp::default(); assert!(check_confirm(&db, &authed, token, &none).await.is_err()); - let right = ConfirmReq { + let right = users::StepUp { password: "admin password".into(), code: String::new(), }; @@ -1325,8 +1310,8 @@ mod tests { conn.last_insert_rowid() } - fn req(password: &str, code: &str) -> ConfirmReq { - ConfirmReq { + fn req(password: &str, code: &str) -> users::StepUp { + users::StepUp { password: password.into(), code: code.into(), } @@ -1432,7 +1417,7 @@ mod tests { let (action, user) = find(&db.conn(), &token).unwrap(); assert_eq!((action.id, user), (id, Some(a.id))); - let none = ConfirmReq::default(); + let none = users::StepUp::default(); let other = check_confirm(&db, &authed(&b), &token, &none).await; assert!( matches!(other, Err(Error::Invalid(e)) if e.contains("another")) @@ -1509,7 +1494,7 @@ mod tests { // decided once, and nothing to confirm any more assert!(rejected(&db.conn(), &events, id, &a, "").is_err()); - let none = ConfirmReq::default(); + let none = users::StepUp::default(); let tried = check_panel(&db, &authed(&a), id, &none).await; assert!(tried.is_err()); } diff --git a/server/src/backup/remote.rs b/server/src/backup/remote.rs index e46a864..d0169b1 100644 --- a/server/src/backup/remote.rs +++ b/server/src/backup/remote.rs @@ -4,8 +4,9 @@ //! repository password are sealed with the secrets key, the API only says //! whether they are set, and restic gets them through its environment. //! Where the copy goes decides where all data goes: the panel asks for the -//! step-up (2FA code, or password), the agent's changes wait for an admin's confirmation -//! (actions.rs), and every change or stop is sent to all admins. +//! step-up (the 2FA code, or the password), the agent's changes wait for +//! an admin's confirmation (actions.rs), and every change or stop is sent +//! to all admins. use std::path::{Path, PathBuf}; use std::process::Stdio; diff --git a/server/src/mail.rs b/server/src/mail.rs index 2e877a9..246a179 100644 --- a/server/src/mail.rs +++ b/server/src/mail.rs @@ -1,8 +1,8 @@ //! Mail goes through the local postfix, which delivers it directly or //! relays it through an external SMTP server (Settings → Mail). The sites //! use the same postfix for their mails. With mail off (port 25 blocked -//! and no relay) genco sends none: deletions are confirmed with the -//! password and reports only show in the panel. +//! and no relay) genco sends none: deletions are confirmed with the 2FA +//! code (the password without 2FA) and reports only show in the panel. use axum::extract::State; use axum::routing::{get, post}; @@ -709,7 +709,7 @@ mod tests { "relayHost": "evil.example.ch", "relayPort": 587, "password": password, - "code": "000000", + "code": "wrong", }); save_settings( authed(), diff --git a/server/src/proposals.rs b/server/src/proposals.rs index 2aef9bf..8c5a90f 100644 --- a/server/src/proposals.rs +++ b/server/src/proposals.rs @@ -684,11 +684,8 @@ struct ApproveReq { /// the admin's change to the task, empty for none #[serde(default)] comment: String, - #[serde(default)] - password: String, - /// of the authenticator app - #[serde(default)] - code: String, + #[serde(flatten)] + auth: crate::users::StepUp, } /// Approves in the panel with the second factor @@ -717,8 +714,7 @@ async fn approve_in_panel( |r| r.get(0), )?; let mailed = links > 0 || !crate::mail::off(db); - crate::users::verify_approval(db, user, &req.password, &req.code, mailed) - .await?; + crate::users::verify_approval(db, user, &req.auth, mailed).await?; approved(&db.conn(), events, id, user, comment.as_deref()) } @@ -1368,7 +1364,10 @@ mod tests { .unwrap(); let user = crate::users::by_id(&db.conn(), 961).unwrap(); let req = ApproveReq { - password: "pw".into(), + auth: crate::users::StepUp { + password: "pw".into(), + ..Default::default() + }, ..Default::default() }; @@ -1397,7 +1396,10 @@ mod tests { // never mailed and mail off: the password does, as before let wrong = ApproveReq { - password: "wrong".into(), + auth: crate::users::StepUp { + password: "wrong".into(), + ..Default::default() + }, ..Default::default() }; assert!( diff --git a/server/src/proposals/links.rs b/server/src/proposals/links.rs index 2adb069..0af8fb3 100644 --- a/server/src/proposals/links.rs +++ b/server/src/proposals/links.rs @@ -132,12 +132,9 @@ struct ApproveReq { /// the admin's change to the task, empty for none #[serde(default)] comment: String, - /// of the authenticator app, when the admin has 2FA - #[serde(default)] - code: String, - /// the admin's own, when the admin has no 2FA - #[serde(default)] - password: String, + /// the session-less step-up: the code with 2FA, else the password + #[serde(flatten)] + auth: users::StepUp, } /// Approves as the link's admin with the second factor, returns the @@ -154,12 +151,7 @@ async fn approve_link( let (p, user) = check(cfg, db, headers, token).await?; let comment = admin_note(&req.comment)?; let action = "Approve proposal via the mail link"; - let verified = if user.totp { - users::verify_code(db, &user, &req.code).await - } else { - users::verify_password(db, &user, &req.password).await - }; - if let Err(e) = verified { + if let Err(e) = users::verify_step_up(db, &user, &req.auth).await { log(&db.conn(), &user, action, &p, false); return Err(e); } @@ -303,14 +295,20 @@ mod tests { fn code(code: &str) -> ApproveReq { ApproveReq { - code: code.into(), + auth: users::StepUp { + code: code.into(), + ..Default::default() + }, ..Default::default() } } fn password(password: &str) -> ApproveReq { ApproveReq { - password: password.into(), + auth: users::StepUp { + password: password.into(), + ..Default::default() + }, ..Default::default() } } @@ -581,8 +579,10 @@ mod tests { let mut req = ApproveReq { comment: "x".repeat(MAX_NOTE + 1), - code: now_code(), - ..Default::default() + auth: users::StepUp { + code: now_code(), + ..Default::default() + }, }; let tried = approve_link(&cfg, &db, &events, &h, &token, &req).await; assert!( diff --git a/webui/src/components/dialogs/MessageBox.svelte b/webui/src/components/dialogs/MessageBox.svelte index c6a69be..9afce90 100644 --- a/webui/src/components/dialogs/MessageBox.svelte +++ b/webui/src/components/dialogs/MessageBox.svelte @@ -9,9 +9,12 @@ let value = $state(''); let code = $state(''); - // a new box starts without the code of the last one + // a new box starts with its own value and without the code of the + // last one $effect.pre(() => { - if (box) code = ''; + if (!box) return; + value = box.value; + code = ''; }); function submit(e: SubmitEvent) { @@ -40,7 +43,6 @@ bind:value {@attach el => { // the typing goes here, a given value is selected - value = box.value; requestAnimationFrame(() => { el.focus(); el.select();