Skip to content

feat(accounts): answer an MFA step-up with a code from the vault - #23

Merged
pyramation merged 1 commit into
mainfrom
feat/totp-step-up
Aug 9, 2026
Merged

feat(accounts): answer an MFA step-up with a code from the vault#23
pyramation merged 1 commit into
mainfrom
feat/totp-step-up

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

Two things, both from the STEP_UP_REQUIRED trace on a live endpoint.

The bare code was falling through. #22 only matched the typed suffixes, but the generated guard in constructive-db (guard_step_up.sql, require_step_up.sql) also raises a bare STEP_UP_REQUIRED, which means "this session has no recent password verification" — so it maps to the password factor:

-/STEP_UP_REQUIRED_(PASSWORD|MFA|FRESH_AUTH)/
+/STEP_UP_REQUIRED(?:_(PASSWORD|MFA|FRESH_AUTH))?/
 ...
-return found[1].toLowerCase() as StepUpKind;
+return (found[1]?.toLowerCase() as StepUpKind) ?? 'password';

Same fix in apps/desktop/src/shared/step-up.ts, since an IPC error reaches the renderer as plain text and the server's own code is the only trustworthy thing to key off.

The vault now answers the code demand itself. An account can name a one-time code it already holds, and withStepUp reaches for it before giving up:

if (error.kind === 'mfa') {
  const code = proof?.totpCode ?? (await this.stepUpCode(accountItemId));
  if (!code) throw error;                 // no link → the dialog still asks
  await client.verifyTotp(code);
}

So a linked account never sees the step-up dialog for _MFA at all — the request is held and replayed exactly as before, just without a human in the middle. A password demand is deliberately excluded: only fresh_auth also accepts a code.

What's stored is the item id and nothing else — totp_item_id on the account item, no copy of the seed, and linkTotp refuses an item that isn't a code or carries no totp_seed field. A link whose item has left the vault is dropped on the next read rather than erroring. The code itself is generated per demand and never written anywhere.

Surfaces: Link a code / Unlink code on each account card in the app (with the linked code's title shown on the card), and dcrypt account link-code <email> <code> / unlink-code <email>.

Also: the rebuild test compared a TOTP code taken either side of a ~9s rebuild, so it failed whenever the pair straddled a 30s window (it did, on this run). It now compares the decrypted seed, which is what "carried every row across" actually means.

Link to Devin session: https://app.devin.ai/sessions/04636534e07048089ffb6b78142e12cd
Requested by: @pyramation

The server's bare STEP_UP_REQUIRED now reads as a password demand, and an
account can name a one-time code already in the vault, which the manager
uses to satisfy an MFA demand without asking anyone. Only the item id is
stored; the seed stays where it is.
@pyramation pyramation self-assigned this Aug 9, 2026
@devin-ai-integration

Copy link
Copy Markdown

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@pyramation
pyramation merged commit 3b42ba9 into main Aug 9, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant