//go:build browser package contratest import ( "strings" "testing" "github.com/chromedp/chromedp" ) // The fresh-device ladder in a real engine. // // The HTTP tests prove the handoff relays the right bytes. What only a real // browser can show is the part a person actually experiences: that a browser // with no cached Account Key is offered a way out instead of a dead end, that // typing the code off the card gets it in, and that a single mistyped // character still lands — which is the whole reason repairCode exists. // forgetTheAccountKey turns the current browser into a fresh device without // signing it out: the session survives, the cached key does not. That is // exactly the state a person is in on a new laptop, and it is the state the // vault used to dead-end in. func forgetTheAccountKey(t *testing.T, rig interface { Run(...chromedp.Action) }) { t.Helper() rig.Run(chromedp.Evaluate(`(() => { for (const k of Object.keys(localStorage)) { if (k.startsWith("contra/v1/account-key/")) localStorage.removeItem(k); } return true; })()`, nil)) } func TestBrowserFreshDeviceIsNotADeadEnd(t *testing.T) { rig, box := newRig(t) const email = "fresh-device@example.invalid" const passphrase = "a generated test passphrase 8f3a1c" signUpInBrowser(t, rig, box, email, passphrase) // Read the printed Account Key off the kit screen — the same string that // is on the card and inside the QR — then forget it, as a new machine // would never have had it. var printed string rig.Run(chromedp.Text(`[data-kit="account-key"]`, &printed, chromedp.ByQuery)) printed = strings.TrimSpace(printed) if !strings.HasPrefix(printed, "A1") { t.Fatalf("the kit did not show an Account Key: %q", printed) } forgetTheAccountKey(t, rig) // The vault gate must send them somewhere rather than explaining that a // feature is missing. rig.Run( chromedp.Navigate(rig.Origin+"/vault"), chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery), chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery), chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery), chromedp.WaitVisible(`[data-unlock] [data-status] a[href="/device"]`, chromedp.ByQuery), ) rig.Screen("[data-unlock]", "a device with no Account Key is offered the ladder") // And the ladder itself offers everything it can. rig.Run( chromedp.Navigate(rig.Origin+"/device"), chromedp.WaitVisible(`[data-rung="handoff"]`, chromedp.ByQuery), chromedp.WaitVisible(`[data-rung="type"]`, chromedp.ByQuery), ) rig.Screen("[data-need]", "the fresh-device ladder") // Rung 3: type it off the card. This is the rung that always works, so it // is the one that must never be broken. rig.Run( chromedp.SendKeys(`#account-key`, printed, chromedp.ByQuery), chromedp.Click(`[data-type-form] button[type=submit]`, chromedp.ByQuery), chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery), ) // It landed on the vault, and the key really is cached now: the vault // opens with the passphrase alone, which is the promise the whole // Account Key design is built around. rig.Run( chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery), chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery), chromedp.WaitVisible(`[data-item-form]`, chromedp.ByQuery), ) rig.Screen("[data-vault]", "unlocked after typing the Account Key") } // One character wrong, on paper, at the worst moment. The checksum is twenty // bits and cannot identify which character, so the repair enumerates about a // thousand candidates and the KCV picks the real one — which is why the // passphrase field appears only after the first attempt fails. // // This is the most intricate path in the app and the least likely to be // exercised by hand. func TestBrowserMistypedAccountKeyStillWorks(t *testing.T) { rig, box := newRig(t) const email = "typo@example.invalid" const passphrase = "a generated test passphrase 4d2e9b" signUpInBrowser(t, rig, box, email, passphrase) var printed string rig.Run(chromedp.Text(`[data-kit="account-key"]`, &printed, chromedp.ByQuery)) printed = strings.TrimSpace(printed) forgetTheAccountKey(t, rig) // Change one payload character to something else in the alphabet. The // tag stays, so this is a mistyped key rather than a mistyped format. runes := []rune(printed) pos := len(runes) - 3 if runes[pos] == 'X' { runes[pos] = 'Y' } else { runes[pos] = 'X' } mistyped := string(runes) if mistyped == printed { t.Fatal("failed to introduce a typo") } rig.Run( chromedp.Navigate(rig.Origin+"/device"), chromedp.WaitVisible(`[data-type-form]`, chromedp.ByQuery), chromedp.SendKeys(`#account-key`, mistyped, chromedp.ByQuery), chromedp.Click(`[data-type-form] button[type=submit]`, chromedp.ByQuery), // The passphrase field is revealed, not present from the start: the // common case is a code typed correctly and should not be taxed. chromedp.WaitVisible(`[data-repair]`, chromedp.ByQuery), ) rig.Screen("[data-need]", "a mistyped Account Key asks for the passphrase") rig.Run( chromedp.SendKeys(`#repair-passphrase`, passphrase, chromedp.ByQuery), chromedp.Click(`[data-type-form] button[type=submit]`, chromedp.ByQuery), chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery), ) // Repaired, cached, and the vault opens — so the candidate the KCV chose // was the real key and not a twenty-bit collision. rig.Run( chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery), chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery), chromedp.WaitVisible(`[data-item-form]`, chromedp.ByQuery), ) } // The reprint page, which is the mitigation for the one loss mode the Account // Key introduced. The Account Key card reprints; the recovery sheet cannot, // and the screen has to be honest about that before it offers the button. func TestBrowserKitReprintsTheCardAndGuardsTheSheet(t *testing.T) { rig, box := newRig(t) const email = "reprint@example.invalid" const passphrase = "a generated test passphrase 1a7c5e" signUpInBrowser(t, rig, box, email, passphrase) rig.Run( chromedp.Navigate(rig.Origin+"/kit"), chromedp.WaitVisible(`[data-card-ready]`, chromedp.ByQuery), ) rig.Screen("[data-card]", "the reprint page") // The reissue button is disabled until the consequence is acknowledged. var disabled bool rig.Run(chromedp.Evaluate( `document.querySelector('[data-regenerate]').disabled`, &disabled)) if !disabled { t.Error("the recovery reissue is live before anything is acknowledged") } rig.Run( chromedp.Click(`[data-confirm-recovery]`, chromedp.ByQuery), chromedp.Poll(`!document.querySelector('[data-regenerate]').disabled`, nil), ) // And the nag is there, because this browser is the only one holding the // key and has no way to know otherwise. var nagHidden bool rig.Run(chromedp.Evaluate(`document.querySelector('[data-nag]').hidden`, &nagHidden)) if nagHidden { t.Error("the only enrolled device is not being nagged to enrol another") } }