Skip to content

Ask for a sign-in's verification code only after the password - #780

Open
davidmckayv wants to merge 1 commit into
mainfrom
david/sign-in-code-after-password
Open

davidmckayv wants to merge 1 commit into
mainfrom
david/sign-in-code-after-password

Conversation

@davidmckayv

Copy link
Copy Markdown
Contributor

What this changes

Problem. The private sign-in form asked for a username, a password and "Verification code, if the site asks for one" all at once. A person had to guess whether a code was coming, and type one before the site had sent it. If they left it blank and the site then asked, the Bot was told to have them take over the browser.

Rule this implements: every MFA code is asked for after the fact, never during or before.

New flow

  1. The form asks only for username, password and "Save to Passwords".
  2. The computer types the login and submits. If the site, having accepted the password, shows a one-time-code field, /sign-in/fill reports codeRequested and stops. The request moves to a new code_required state instead of finishing or failing.
  3. The dialog stays open and says "The site sent a verification code. Enter it to finish signing in.", with one code field. POST /api/sign-in-requests/:id/code has the computer type that code into the page that is still waiting for it (new /sign-in/code on the computer, on the acting list next to /sign-in/fill).
  4. A wrong code keeps the request in code_required and shows the site's refusal. A site that drops back to its login page reopens the login form. A code nobody enters expires with the request, and the person sees that. Take over and Cancel still work from the code step, and an abandoned code attempt (an expired filling lease) reopens at the code step.
  5. The Bot is told only the final outcome, never the code.

code is no longer read on submit or use-saved. An older form that sends one has it ignored, so it can never be typed early.

Security notes

  • The code takes the same path as the password: form, then one request body, then the computer, then the page. It never goes into a row, an audit payload, a log line, a tool result or the transcript. The code field in the page is marked secret, so snapshots mask it. Any computer error is scrubbed of it.
  • A saved login still never stores a code. A typed login is saved once the site has accepted the password, which is when the code is asked for, so the password never has to be held between requests.
  • Audit: a new computer.sign_in_code_requested event for the step. computer.sign_in_failed now carries step: "login" | "code", and computer.sign_in_completed carries verificationCode: true. None of them include the value.
  • The computer types a code only on the origin the request was for, and only into a code field that is still showing. A code page on another origin returns "take over the browser" instead.

Where it runs

  • New state that outlives a request? Only the code_required status on the existing sign_in_requests row in Postgres. The page waiting for the code lives in the Bot's own computer, as the login page always has. Nothing is held in server memory between the two requests.
  • Second replica? Either replica can take the code request: it reads the row and calls the same computer.
  • Serialised? The same conditional transition (code_required -> filling) used for the login, so two code submits racing end in one attempt.
  • Fan-out to a browser? None new. The form polls the row as before.
  • New listener, port or schedule? No. One new path on the existing computer API.

Boundary and audit

  • Every acting call still goes through the gateway.
  • New failures each write a row (computer.sign_in_failed, step: "code").
  • Nothing new is trusted from the client.

Changelog

  • CHANGELOG.md under Unreleased: the new flow, plus migration 0056_sign_in_code_step (it widens the sign_in_requests.status check). The server and the computer have to be upgraded together.

Tests

Each fails before the change and passes after:

  • agent-computer/tests/sign-in.test.ts (real Chromium, runs in the computer-browser CI job): a fake site that shows a code field only after the password is accepted. The computer stops with codeRequested: true and leaves the code field empty, then fillSignInCode finishes on /home. Also covered: a refused code leaves the code field showing; a code field drawn as type=password is read as the code step, not a refused login; no code is typed on another origin or when no code field is showing. Before: SyntaxError: Export named 'parseSignInCode' not found.
  • server/tests/passwords-sign-in.test.ts: the state machine. Submit login, get code_required (Bot not told), submit code, get signed_in (Bot told once). Also: wrong code; expiry while waiting for the code; code refused before the site asks; fallback to the login page; saved login; take over and cancel from the code step; lease reopen; the routes. Before: 9 failures.
  • server/tests/passwords-store.integration.test.ts: code_required against real Postgres (the check constraint and the pending list).
  • app/tests/sign-in-code-after-password.test.tsx: the real SignInForm against a fake server. Only username and password show first; the code field appears after code_required; a wrong code keeps asking; the code body is {code} alone. Before: fails at expect(tree.queryByLabelText(/code/i)).toBeNull(), because the up-front code field is there.

Live proof

I ran a full stack on this branch: the server, the app, and a computer image rebuilt from this branch. The site was a small local fake with login, then a code page, then an account page, using made-up test values. I drove it through the real app in Chrome:

  1. Asked General Assistant to sign in to the site. It opened the login page and called computer_request_sign_in.
  2. The dialog asked only for username and password.
  3. After Sign in, the site log shows POST /login credentials=accepted then GET /verify (code page shown). The dialog switched to "Enter the verification code".
  4. A wrong code: the site log shows code=refused and the dialog shows "The site did not accept that code…".
  5. The right code: the site log shows code=accepted then GET /account (signed in). The Bot replied "You're signed in. The page says: Welcome back, Dana…".
  6. Audit trail, in order: sign_in_requested, sign_in_code_requested, sign_in_failed {step: code}, sign_in_completed {verificationCode: true}. The password and both codes have 0 hits in a full pg_dump of the database and 0 hits in the server log.

The private sign-in form asked for a username, a password and "a
verification code, if the site asks for one" all at once, so a person
had to guess whether a code was coming and type one before the site had
sent it.

The form now asks only for the username and password. The computer types
them, submits, and if the site then shows a one-time-code field it
reports codeRequested and stops. The request moves to a new
code_required state, the form asks for the code the site just sent, and
POST /api/sign-in-requests/:id/code has the computer type it into the
page that is still waiting (new /sign-in/code on the computer, on the
acting list like /sign-in/fill).

A wrong code keeps the request in code_required with the site's refusal
as its outcome; a site that falls back to its login page reopens the
login form; an unanswered code expires with the request. Take over and
cancel still work from the code step, and an abandoned code attempt
reopens at the code step. The Bot is told only the final outcome, never
the code. A typed login the site accepted is saved when the code is
asked for, and a code is never saved.

Audit: computer.sign_in_code_requested for the step, and
computer.sign_in_failed carries step "login" or "code". Migration 0056
widens the status check to allow code_required.

This branch has not been deployed

No deployments
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