From bd58e2cef258b46badd9a7d0229204957a6a6316 Mon Sep 17 00:00:00 2001
From: Mohammed Sohail <sohailsameja@gmail.com>
Date: Mon, 10 Aug 2026 14:58:22 +0300
Subject: [PATCH] feat: add human verification capture

---
 cmd/ferroxide/captcha.go              | 105 ++++++++++++++++++++
 cmd/ferroxide/captcha_browser_test.go | 135 ++++++++++++++++++++++++++
 cmd/ferroxide/captcha_test.go         |  85 ++++++++++++++++
 cmd/ferroxide/main.go                 | 124 ++++++++++++++++++++++-
 protonmail/humanverify.go             |  95 ++++++++++++++++++
 protonmail/humanverify_test.go        |  96 ++++++++++++++++++
 protonmail/protonmail.go              |  66 +++++++++++++
 7 files changed, 702 insertions(+), 4 deletions(-)
 create mode 100644 cmd/ferroxide/captcha.go
 create mode 100644 cmd/ferroxide/captcha_browser_test.go
 create mode 100644 cmd/ferroxide/captcha_test.go
 create mode 100644 protonmail/humanverify.go
 create mode 100644 protonmail/humanverify_test.go

diff --git a/cmd/ferroxide/captcha.go b/cmd/ferroxide/captcha.go
new file mode 100644
index 00000000..ce60e98a
--- /dev/null
+++ b/cmd/ferroxide/captcha.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+	"fmt"
+	"net"
+	"net/http"
+	"net/url"
+	"os"
+	"strings"
+	"time"
+)
+
+// captchaBeaconTimeout bounds how long we wait for the browser to report the
+// solved challenge before falling back to asking for it by hand.
+const captchaBeaconTimeout = 5 * time.Minute
+
+// captchaSnippet is pasted into the browser console on the challenge page. It
+// catches the message the page emits once solved and reports the token back.
+//
+// The report goes out as an image request rather than a fetch on purpose: the
+// challenge page is served under a Content-Security-Policy of
+//
+//	connect-src https: wss:; img-src http: https: data: blob: cid:
+//
+// so a fetch to a loopback address is refused while an image request to one is
+// not. The prompt is a fallback for when even that is blocked, and comes second
+// so the report is already on its way before the dialog blocks the page.
+const captchaSnippet = `addEventListener('message', e => {
+  if (!e.data || e.data.type !== 'pm_captcha') return;
+  new Image().src = '%v?t=' + encodeURIComponent(e.data.token);
+  prompt('ferroxide token (only needed if your terminal has not continued)', e.data.token);
+});`
+
+// solveCaptcha walks the user through the CAPTCHA challenge at captchaURL and
+// returns the verification token it yields.
+//
+// The challenge cannot be embedded in a page we serve: every host that runs it
+// correctly restricts framing to its own sibling app origin, and the one host
+// that permits framing serves neither the challenge assets nor a usable session
+// cookie. So it is opened top-level, where the page ends up posting its result
+// to itself, and a listener pasted into the console forwards it to us.
+func solveCaptcha(captchaURL string) (string, error) {
+	// Bind to the loopback interface only: this is a one-shot channel for the
+	// browser on this machine, not something to expose to the network.
+	ln, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		return "", fmt.Errorf("failed to listen for the verification result: %v", err)
+	}
+	defer ln.Close()
+
+	tokens := make(chan string, 1)
+
+	srv := &http.Server{Handler: captchaHandler(tokens)}
+	defer srv.Close()
+	go srv.Serve(ln)
+
+	beaconURL := (&url.URL{Scheme: "http", Host: ln.Addr().String(), Path: "/token"}).String()
+
+	fmt.Fprintf(os.Stderr, "\nProton is asking for a CAPTCHA before it will accept this login.\n\n")
+	fmt.Fprintf(os.Stderr, "1. Open this address in a browser:\n\n     %v\n\n", captchaURL)
+	fmt.Fprintf(os.Stderr, "2. Open the developer console (F12) and paste:\n\n%v\n\n",
+		indent(fmt.Sprintf(captchaSnippet, beaconURL), "     "))
+	fmt.Fprintf(os.Stderr, "3. Solve the CAPTCHA. ferroxide continues on its own.\n\n")
+
+	select {
+	case token := <-tokens:
+		fmt.Fprintf(os.Stderr, "Verification solved.\n\n")
+		return token, nil
+
+	case <-time.After(captchaBeaconTimeout):
+		// The browser never reached us — an extension or a proxy may have
+		// eaten the request. The dialog on the page still has the token.
+		fmt.Fprintf(os.Stderr, "No result received automatically.\n")
+		return askLine("Verification token")
+	}
+}
+
+// captchaHandler accepts the solved verification token from the browser and
+// hands it to tokens.
+func captchaHandler(tokens chan<- string) http.Handler {
+	mux := http.NewServeMux()
+
+	mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
+		token := r.URL.Query().Get("t")
+		if token == "" {
+			http.Error(w, "no token in the request", http.StatusBadRequest)
+			return
+		}
+
+		select {
+		case tokens <- token:
+		default:
+			// Already solved; nothing more to do.
+		}
+
+		// The response is never rendered — the image is not in a document.
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	return mux
+}
+
+func indent(s, prefix string) string {
+	return prefix + strings.ReplaceAll(s, "\n", "\n"+prefix)
+}
diff --git a/cmd/ferroxide/captcha_browser_test.go b/cmd/ferroxide/captcha_browser_test.go
new file mode 100644
index 00000000..2fa73c6b
--- /dev/null
+++ b/cmd/ferroxide/captcha_browser_test.go
@@ -0,0 +1,135 @@
+package main
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"os/exec"
+	"strings"
+	"testing"
+	"time"
+)
+
+// protonCaptchaCSP is the Content-Security-Policy the challenge page is served
+// under, as sent by the per-app API hosts. The whole approach rests on what it
+// permits, so the test reproduces it verbatim rather than paraphrasing it.
+const protonCaptchaCSP = "default-src 'self'; media-src https:; connect-src https: wss:; " +
+	"script-src 'self' 'unsafe-eval' 'nonce-testnonce' 'strict-dynamic' https:; " +
+	"style-src 'self' 'unsafe-inline'; img-src http: https: data: blob: cid:; frame-src https:"
+
+func findChrome() string {
+	for _, name := range []string{"google-chrome", "chromium", "chromium-browser"} {
+		if path, err := exec.LookPath(name); err == nil {
+			return path
+		}
+	}
+	return ""
+}
+
+func runChrome(t *testing.T, chrome, url string) {
+	t.Helper()
+
+	cmd := exec.Command(chrome,
+		"--headless=new", "--disable-gpu", "--no-sandbox",
+		"--virtual-time-budget=5000", "--dump-dom", url)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("browser failed: %v\n%s", err, out)
+	}
+}
+
+// The snippet reports the token with an image request because the challenge
+// page's CSP allows one to a loopback address. Prove that in a real browser,
+// under that exact policy, rather than trusting the reading of the header.
+func TestCaptchaSnippetReportsTokenUnderProtonCSP(t *testing.T) {
+	chrome := findChrome()
+	if chrome == "" {
+		t.Skip("no Chrome-family browser available")
+	}
+
+	tokens := make(chan string, 1)
+
+	srv := httptest.NewUnstartedServer(nil)
+	beaconURL := "http://" + srv.Listener.Addr().String() + "/token"
+
+	// The page stands in for the challenge: same policy, and it emits the same
+	// message a solved CAPTCHA does. prompt() is stripped, as headless Chrome
+	// dismisses dialogs and the fallback is not what is under test.
+	page := fmt.Sprintf(`<!doctype html><meta charset="utf-8">
+<script nonce="testnonce">
+%v
+window.postMessage({type: 'pm_captcha', token: %q}, '*');
+</script>`,
+		strings.Replace(
+			fmt.Sprintf(captchaSnippet, beaconURL),
+			"  prompt('ferroxide token (only needed if your terminal has not continued)', e.data.token);\n",
+			"", 1),
+		testToken)
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/" {
+			http.NotFound(w, r)
+			return
+		}
+		w.Header().Set("Content-Security-Policy", protonCaptchaCSP)
+		w.Header().Set("Content-Type", "text/html; charset=utf-8")
+		w.Write([]byte(page))
+	})
+	mux.Handle("/token", captchaHandler(tokens))
+
+	srv.Config.Handler = mux
+	srv.Start()
+	defer srv.Close()
+
+	runChrome(t, chrome, srv.URL)
+
+	select {
+	case got := <-tokens:
+		if got != testToken {
+			t.Errorf("got token %q, want %q", got, testToken)
+		}
+	case <-time.After(10 * time.Second):
+		t.Fatal("the snippet never reported the token")
+	}
+}
+
+// The counterpart: a fetch to the same address is refused by that policy. If
+// this ever starts passing, the image request is no longer necessary.
+func TestFetchToLoopbackIsBlockedUnderProtonCSP(t *testing.T) {
+	chrome := findChrome()
+	if chrome == "" {
+		t.Skip("no Chrome-family browser available")
+	}
+
+	reached := make(chan string, 1)
+
+	srv := httptest.NewUnstartedServer(nil)
+	beaconURL := "http://" + srv.Listener.Addr().String() + "/token"
+
+	page := fmt.Sprintf(`<!doctype html><meta charset="utf-8">
+<script nonce="testnonce">fetch(%q + '?t=viafetch');</script>`, beaconURL)
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/" {
+			http.NotFound(w, r)
+			return
+		}
+		w.Header().Set("Content-Security-Policy", protonCaptchaCSP)
+		w.Header().Set("Content-Type", "text/html; charset=utf-8")
+		w.Write([]byte(page))
+	})
+	mux.Handle("/token", captchaHandler(reached))
+
+	srv.Config.Handler = mux
+	srv.Start()
+	defer srv.Close()
+
+	runChrome(t, chrome, srv.URL)
+
+	select {
+	case got := <-reached:
+		t.Fatalf("connect-src no longer blocks a fetch to loopback (got %q)", got)
+	case <-time.After(2 * time.Second):
+	}
+}
diff --git a/cmd/ferroxide/captcha_test.go b/cmd/ferroxide/captcha_test.go
new file mode 100644
index 00000000..f3dc0636
--- /dev/null
+++ b/cmd/ferroxide/captcha_test.go
@@ -0,0 +1,85 @@
+package main
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strings"
+	"testing"
+)
+
+const testToken = "sVL8xLGm-tok:03AFcWeA-solved"
+
+func TestCaptchaHandlerCollectsToken(t *testing.T) {
+	tokens := make(chan string, 1)
+	srv := httptest.NewServer(captchaHandler(tokens))
+	defer srv.Close()
+
+	res, err := http.Get(srv.URL + "/token?t=" + url.QueryEscape(testToken))
+	if err != nil {
+		t.Fatal(err)
+	}
+	res.Body.Close()
+
+	if res.StatusCode != http.StatusNoContent {
+		t.Errorf("got status %v, want %v", res.StatusCode, http.StatusNoContent)
+	}
+
+	select {
+	case got := <-tokens:
+		if got != testToken {
+			t.Errorf("got token %q, want %q", got, testToken)
+		}
+	default:
+		t.Fatal("no token was delivered")
+	}
+}
+
+// The browser may report more than once, and nothing is reading the channel
+// after the first result, so later reports must not wedge the handler.
+func TestCaptchaHandlerSecondTokenDoesNotBlock(t *testing.T) {
+	tokens := make(chan string, 1)
+	srv := httptest.NewServer(captchaHandler(tokens))
+	defer srv.Close()
+
+	for i := range 3 {
+		res, err := http.Get(srv.URL + "/token?t=tok")
+		if err != nil {
+			t.Fatal(err)
+		}
+		res.Body.Close()
+
+		if res.StatusCode != http.StatusNoContent {
+			t.Fatalf("report %v: got status %v", i, res.StatusCode)
+		}
+	}
+}
+
+func TestCaptchaHandlerRejectsEmptyToken(t *testing.T) {
+	srv := httptest.NewServer(captchaHandler(make(chan string, 1)))
+	defer srv.Close()
+
+	res, err := http.Get(srv.URL + "/token")
+	if err != nil {
+		t.Fatal(err)
+	}
+	res.Body.Close()
+
+	if res.StatusCode != http.StatusBadRequest {
+		t.Errorf("got status %v, want %v", res.StatusCode, http.StatusBadRequest)
+	}
+}
+
+// The snippet is pasted verbatim into a browser console, so the beacon address
+// has to end up in it intact.
+func TestCaptchaSnippetCarriesBeaconURL(t *testing.T) {
+	const beacon = "http://127.0.0.1:33505/token"
+
+	snippet := strings.ReplaceAll(captchaSnippet, "%v", beacon)
+	if !strings.Contains(snippet, "'"+beacon+"?t=' + encodeURIComponent(e.data.token)") {
+		t.Errorf("beacon address missing from the snippet:\n%v", snippet)
+	}
+	if strings.Contains(snippet, "%v") {
+		t.Errorf("unfilled placeholder left in the snippet:\n%v", snippet)
+	}
+}
diff --git a/cmd/ferroxide/main.go b/cmd/ferroxide/main.go
index 01782f66..228978a1 100644
--- a/cmd/ferroxide/main.go
+++ b/cmd/ferroxide/main.go
@@ -11,6 +11,7 @@ import (
 	"net/http"
 	"net/url"
 	"os"
+	"strconv"
 	"strings"
 
 	"github.com/ProtonMail/go-crypto/openpgp"
@@ -120,6 +121,105 @@ func askPass(prompt string) ([]byte, error) {
 	return b, err
 }
 
+// stdinScanner is shared so that prompts issued one after another don't lose
+// input to each other's buffering.
+var stdinScanner = bufio.NewScanner(os.Stdin)
+
+func askLine(prompt string) (string, error) {
+	fmt.Fprintf(os.Stderr, "%v: ", prompt)
+	if !stdinScanner.Scan() {
+		if err := stdinScanner.Err(); err != nil {
+			return "", err
+		}
+		return "", io.ErrUnexpectedEOF
+	}
+	return strings.TrimSpace(stdinScanner.Text()), nil
+}
+
+// solveHumanVerification walks the user through a human verification challenge
+// and, on success, attaches the resulting token to c.
+func solveHumanVerification(c *protonmail.Client, apiErr *protonmail.APIError) error {
+	token, methods, _ := apiErr.HumanVerification()
+
+	method, err := chooseVerificationMethod(methods)
+	if err != nil {
+		return err
+	}
+
+	switch method {
+	case protonmail.HumanVerificationCaptcha:
+		solved, err := solveCaptcha(c.CaptchaURL(token))
+		if err != nil {
+			return err
+		}
+		c.SetHumanVerification(solved, method)
+
+	case protonmail.HumanVerificationEmail, protonmail.HumanVerificationSMS:
+		what := "email address"
+		if method == protonmail.HumanVerificationSMS {
+			what = "phone number"
+		}
+
+		destination, err := askLine("Verification " + what)
+		if err != nil {
+			return err
+		}
+		if destination == "" {
+			return fmt.Errorf("no %v provided", what)
+		}
+
+		if err := c.RequestVerificationCode(method, destination); err != nil {
+			return fmt.Errorf("failed to request a verification code: %v", err)
+		}
+
+		code, err := askLine("Verification code")
+		if err != nil {
+			return err
+		}
+		if code == "" {
+			return fmt.Errorf("no verification code provided")
+		}
+		c.SetHumanVerification(destination+":"+code, method)
+	}
+
+	return nil
+}
+
+func chooseVerificationMethod(methods []string) (string, error) {
+	var supported []string
+	for _, method := range methods {
+		switch method {
+		case protonmail.HumanVerificationCaptcha, protonmail.HumanVerificationEmail, protonmail.HumanVerificationSMS:
+			supported = append(supported, method)
+		}
+	}
+
+	switch len(supported) {
+	case 0:
+		return "", fmt.Errorf("none of the verification methods offered by the server are supported: %v",
+			strings.Join(methods, ", "))
+	case 1:
+		return supported[0], nil
+	}
+
+	fmt.Fprintf(os.Stderr, "\nProton requires human verification. Available methods:\n")
+	for i, method := range supported {
+		fmt.Fprintf(os.Stderr, "  %v) %v\n", i+1, method)
+	}
+
+	for {
+		choice, err := askLine("Method")
+		if err != nil {
+			return "", err
+		}
+		n, err := strconv.Atoi(choice)
+		if err == nil && n >= 1 && n <= len(supported) {
+			return supported[n-1], nil
+		}
+		fmt.Fprintf(os.Stderr, "Please enter a number between 1 and %v.\n", len(supported))
+	}
+}
+
 func askBridgePass() (string, error) {
 	if v := os.Getenv("HYDROXIDE_BRIDGE_PASS"); v != "" {
 		return v, nil
@@ -384,19 +484,35 @@ func main() {
 			}
 
 			a, err = c.Auth(username, loginPassword, authInfo)
+			if apiErr, ok := err.(*protonmail.APIError); ok {
+				if _, _, needsVerification := apiErr.HumanVerification(); needsVerification {
+					if err := solveHumanVerification(c, apiErr); err != nil {
+						log.Fatal(err)
+					}
+
+					// The SRP session of the rejected attempt is spent, so the
+					// exchange has to start over — this time with the
+					// verification token attached to every request.
+					if authInfo, err = c.AuthInfo(username); err != nil {
+						log.Fatal(err)
+					}
+					a, err = c.Auth(username, loginPassword, authInfo)
+				}
+			}
 			if err != nil {
 				log.Fatal(err)
 			}
+			c.SetHumanVerification("", "")
 
 			if a.TwoFactor.Enabled != 0 {
 				if a.TwoFactor.TOTP != 1 {
 					log.Fatal("Only TOTP is supported as a 2FA method")
 				}
 
-				scanner := bufio.NewScanner(os.Stdin)
-				fmt.Printf("2FA TOTP code: ")
-				scanner.Scan()
-				code := scanner.Text()
+				code, err := askLine("2FA TOTP code")
+				if err != nil {
+					log.Fatal(err)
+				}
 
 				scope, err := c.AuthTOTP(code)
 				if err != nil {
diff --git a/protonmail/humanverify.go b/protonmail/humanverify.go
new file mode 100644
index 00000000..7a2771e3
--- /dev/null
+++ b/protonmail/humanverify.go
@@ -0,0 +1,95 @@
+package protonmail
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+)
+
+// Human verification methods, as returned in the HumanVerificationMethods list
+// of a CodeHumanVerificationRequired error.
+const (
+	HumanVerificationCaptcha = "captcha"
+	HumanVerificationEmail   = "email"
+	HumanVerificationSMS     = "sms"
+)
+
+// DefaultCaptchaRootURL is the API host CAPTCHA challenges are fetched from.
+//
+// This is deliberately not RootURL. Every host serves the same challenge page,
+// but only some serve one that runs:
+//
+//   - mail.proton.me/api and account.proton.me send a Content-Security-Policy
+//     whose script-src carries a fixed hash and omits the very nonce the page's
+//     own inline script is tagged with, so the browser refuses to run it and no
+//     CAPTCHA ever appears.
+//   - api.protonmail.ch sends a matching nonce, but sits outside proton.me, so
+//     the session cookie the page sets for Domain=proton.me is rejected, and it
+//     does not host the challenge assets either.
+//
+// That leaves the per-app API hosts, which send a matching nonce and a cookie
+// for a domain they belong to. They restrict framing to their own sibling app
+// origin, so the page has to be opened top-level rather than embedded.
+const DefaultCaptchaRootURL = "https://mail-api.proton.me"
+
+// CaptchaURL returns the address of the CAPTCHA challenge page for a
+// verification token obtained from a CodeHumanVerificationRequired error.
+//
+// The page has to be loaded in a browser. Once solved, it hands the resulting
+// verification token to its parent window as a message of the form
+//
+//	{"type": "pm_captcha", "token": "<verification token>"}
+//
+// and that token is what SetHumanVerification expects. It also reports the
+// height it wants as {"type": "pm_height", "height": <pixels>}, and announces
+// a challenge that went stale as {"type": "pm_captcha_expired"}.
+func (c *Client) CaptchaURL(token string) string {
+	root := c.CaptchaRootURL
+	if root == "" {
+		root = DefaultCaptchaRootURL
+	}
+
+	v := make(url.Values)
+	v.Set("Token", token)
+	// Ask for browser-style message passing rather than the native app hooks
+	// the page otherwise sniffs for.
+	v.Set("ForceWebMessaging", "1")
+
+	return root + "/core/v4/captcha?" + v.Encode()
+}
+
+type verificationCodeReq struct {
+	Type        string
+	Destination verificationCodeDestination
+}
+
+type verificationCodeDestination struct {
+	Address string `json:",omitempty"`
+	Phone   string `json:",omitempty"`
+}
+
+// RequestVerificationCode asks the API to send a verification code to
+// destination, which is an email address for HumanVerificationEmail and a
+// phone number for HumanVerificationSMS.
+//
+// The code that arrives forms the verification token together with the
+// destination it was sent to, as "<destination>:<code>".
+func (c *Client) RequestVerificationCode(method, destination string) error {
+	reqData := &verificationCodeReq{Type: method}
+
+	switch method {
+	case HumanVerificationEmail:
+		reqData.Destination.Address = destination
+	case HumanVerificationSMS:
+		reqData.Destination.Phone = destination
+	default:
+		return fmt.Errorf("cannot request a verification code for method %q", method)
+	}
+
+	req, err := c.newJSONRequest(http.MethodPost, "/users/code", reqData)
+	if err != nil {
+		return err
+	}
+
+	return c.doJSON(req, nil)
+}
diff --git a/protonmail/humanverify_test.go b/protonmail/humanverify_test.go
new file mode 100644
index 00000000..676a2320
--- /dev/null
+++ b/protonmail/humanverify_test.go
@@ -0,0 +1,96 @@
+package protonmail
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+func TestHumanVerificationDetails(t *testing.T) {
+	const body = `{
+		"Code": 9001,
+		"Error": "For security reasons, please complete CAPTCHA.",
+		"Details": {
+			"HumanVerificationMethods": ["captcha", "email"],
+			"HumanVerificationToken": "sVL8xLGm-tok"
+		}
+	}`
+
+	var r resp
+	if err := json.Unmarshal([]byte(body), &r); err != nil {
+		t.Fatalf("failed to decode response: %v", err)
+	}
+
+	err, ok := r.Err().(*APIError)
+	if !ok {
+		t.Fatalf("expected an *APIError, got %T", r.Err())
+	}
+	if err.Code != CodeHumanVerificationRequired {
+		t.Errorf("got code %v, want %v", err.Code, CodeHumanVerificationRequired)
+	}
+
+	token, methods, ok := err.HumanVerification()
+	if !ok {
+		t.Fatal("expected a human verification challenge")
+	}
+	if token != "sVL8xLGm-tok" {
+		t.Errorf("got token %q", token)
+	}
+	if len(methods) != 2 || methods[0] != HumanVerificationCaptcha || methods[1] != HumanVerificationEmail {
+		t.Errorf("got methods %v", methods)
+	}
+}
+
+// Details is not an object for every error, and a successful response may carry
+// one without an Error at all. Neither should be mistaken for a challenge.
+func TestHumanVerificationIgnoresUnrelatedDetails(t *testing.T) {
+	for name, body := range map[string]string{
+		"empty details":    `{"Code": 2001, "Error": "Invalid input", "Details": {}}`,
+		"details is array": `{"Code": 2001, "Error": "Invalid input", "Details": ["nope"]}`,
+		"wrong code":       `{"Code": 2001, "Error": "Invalid input", "Details": {"HumanVerificationToken": "t"}}`,
+	} {
+		t.Run(name, func(t *testing.T) {
+			var r resp
+			if err := json.Unmarshal([]byte(body), &r); err != nil {
+				t.Fatalf("failed to decode response: %v", err)
+			}
+			if _, _, ok := r.Err().(*APIError).HumanVerification(); ok {
+				t.Error("expected no human verification challenge")
+			}
+		})
+	}
+
+	t.Run("no error", func(t *testing.T) {
+		var r resp
+		if err := json.Unmarshal([]byte(`{"Code": 1000, "Details": {}}`), &r); err != nil {
+			t.Fatalf("failed to decode response: %v", err)
+		}
+		if err := r.Err(); err != nil {
+			t.Errorf("expected a successful response, got %v", err)
+		}
+	})
+}
+
+// The CAPTCHA host is independent of RootURL, because the host the rest of the
+// API is spoken to serves the challenge page under a CSP that stops it running.
+func TestCaptchaURL(t *testing.T) {
+	c := &Client{RootURL: "https://mail.proton.me/api"}
+
+	got := c.CaptchaURL("sVL8xLGm-tok")
+	want := DefaultCaptchaRootURL + "/core/v4/captcha?ForceWebMessaging=1&Token=sVL8xLGm-tok"
+	if got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+}
+
+func TestCaptchaURLOverride(t *testing.T) {
+	c := &Client{
+		RootURL:        "https://mail.proton.me/api",
+		CaptchaRootURL: "https://verify-api.proton.me",
+	}
+
+	got := c.CaptchaURL("sVL8xLGm-tok")
+	want := "https://verify-api.proton.me/core/v4/captcha?ForceWebMessaging=1&Token=sVL8xLGm-tok"
+	if got != want {
+		t.Errorf("got %v, want %v", got, want)
+	}
+}
diff --git a/protonmail/protonmail.go b/protonmail/protonmail.go
index a5985f90..b6639d86 100644
--- a/protonmail/protonmail.go
+++ b/protonmail/protonmail.go
@@ -20,8 +20,17 @@ const Version = 3
 
 const headerAPIVersion = "X-Pm-Apiversion"
 
+// API error codes.
+const (
+	CodeHumanVerificationRequired = 9001
+	CodeInvalidRefreshToken       = 10013
+)
+
 type resp struct {
 	Code int
+	// Details is an arbitrary object some errors carry extra context in. Its
+	// shape depends on the error, so it is kept raw and decoded on demand.
+	Details json.RawMessage
 	*RawAPIError
 }
 
@@ -30,6 +39,7 @@ func (r *resp) Err() error {
 		return &APIError{
 			Code:    r.Code,
 			Message: err.Message,
+			Details: r.Details,
 		}
 	}
 	return nil
@@ -46,12 +56,39 @@ type RawAPIError struct {
 type APIError struct {
 	Code    int
 	Message string
+	Details json.RawMessage
 }
 
 func (err *APIError) Error() string {
 	return fmt.Sprintf("[%v] %v", err.Code, err.Message)
 }
 
+// HumanVerification reports whether the API answered with a human verification
+// challenge. If it did, it returns the verification token identifying the
+// challenge along with the methods the API is willing to accept to solve it
+// (see the HumanVerification* constants).
+func (err *APIError) HumanVerification() (token string, methods []string, ok bool) {
+	if err.Code != CodeHumanVerificationRequired || len(err.Details) == 0 {
+		return "", nil, false
+	}
+
+	var details struct {
+		HumanVerificationToken   string
+		HumanVerificationMethods []string
+	}
+	if json.Unmarshal(err.Details, &details) != nil {
+		// Details is not an object for every error, and the API is free to
+		// change its shape: a challenge we can't read is a challenge we can't
+		// solve, not a hard failure.
+		return "", nil, false
+	}
+	if details.HumanVerificationToken == "" {
+		return "", nil, false
+	}
+
+	return details.HumanVerificationToken, details.HumanVerificationMethods, true
+}
+
 type Timestamp int64
 
 func NewTimestamp(t time.Time) Timestamp {
@@ -68,12 +105,20 @@ type Client struct {
 	AppVersion string
 	Debug      bool
 
+	// CaptchaRootURL overrides the host CAPTCHA challenges are loaded from.
+	// Defaults to DefaultCaptchaRootURL, which is not necessarily RootURL —
+	// see that constant for why.
+	CaptchaRootURL string
+
 	HTTPClient *http.Client
 	ReAuth     func() error
 
 	uid         string
 	accessToken string
 	keyRing     openpgp.EntityList
+
+	hvToken     string
+	hvTokenType string
 }
 
 func (c *Client) setRequestAuthorization(req *http.Request) {
@@ -83,6 +128,26 @@ func (c *Client) setRequestAuthorization(req *http.Request) {
 	}
 }
 
+// SetHumanVerification attaches a solved human verification token to every
+// subsequent request, which is how a request rejected with
+// CodeHumanVerificationRequired is retried. tokenType is the method the token
+// was obtained with (see the HumanVerification* constants).
+//
+// An empty token clears the verification, which callers should do once the
+// request that needed it has gone through.
+func (c *Client) SetHumanVerification(token, tokenType string) {
+	c.hvToken = token
+	c.hvTokenType = tokenType
+}
+
+func (c *Client) setRequestHumanVerification(req *http.Request) {
+	if c.hvToken == "" {
+		return
+	}
+	req.Header.Set("X-Pm-Human-Verification-Token", c.hvToken)
+	req.Header.Set("X-Pm-Human-Verification-Token-Type", c.hvTokenType)
+}
+
 func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request, error) {
 	req, err := http.NewRequest(method, c.RootURL+path, body)
 	if err != nil {
@@ -96,6 +161,7 @@ func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request,
 	req.Header.Set("X-Pm-Appversion", c.AppVersion)
 	req.Header.Set(headerAPIVersion, strconv.Itoa(Version))
 	c.setRequestAuthorization(req)
+	c.setRequestHumanVerification(req)
 	return req, nil
 }
 
