carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package contratest
4
5import (
6 "strings"
7 "testing"
8
9 "github.com/chromedp/chromedp"
10)
11
12// The fresh-device ladder in a real engine.
13//
14// The HTTP tests prove the handoff relays the right bytes. What only a real
15// browser can show is the part a person actually experiences: that a browser
16// with no cached Account Key is offered a way out instead of a dead end, that
17// typing the code off the card gets it in, and that a single mistyped
18// character still lands — which is the whole reason repairCode exists.
19
20// forgetTheAccountKey turns the current browser into a fresh device without
21// signing it out: the session survives, the cached key does not. That is
22// exactly the state a person is in on a new laptop, and it is the state the
23// vault used to dead-end in.
24func forgetTheAccountKey(t *testing.T, rig interface {
25 Run(...chromedp.Action)
26}) {
27 t.Helper()
28 rig.Run(chromedp.Evaluate(`(() => {
29 for (const k of Object.keys(localStorage)) {
30 if (k.startsWith("contra/v1/account-key/")) localStorage.removeItem(k);
31 }
32 return true;
33 })()`, nil))
34}
35
36func TestBrowserFreshDeviceIsNotADeadEnd(t *testing.T) {
37 rig, box := newRig(t)
38 const email = "fresh-device@example.invalid"
39 const passphrase = "a generated test passphrase 8f3a1c"
40
41 signUpInBrowser(t, rig, box, email, passphrase)
42
43 // Read the printed Account Key off the kit screen — the same string that
44 // is on the card and inside the QR — then forget it, as a new machine
45 // would never have had it.
46 var printed string
47 rig.Run(chromedp.Text(`[data-kit="account-key"]`, &printed, chromedp.ByQuery))
48 printed = strings.TrimSpace(printed)
49 if !strings.HasPrefix(printed, "A1") {
50 t.Fatalf("the kit did not show an Account Key: %q", printed)
51 }
52
53 forgetTheAccountKey(t, rig)
54
55 // The vault gate must send them somewhere rather than explaining that a
56 // feature is missing.
57 rig.Run(
58 chromedp.Navigate(rig.Origin+"/vault"),
59 chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery),
60 chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery),
61 chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery),
62 chromedp.WaitVisible(`[data-unlock] [data-status] a[href="/device"]`, chromedp.ByQuery),
63 )
64 rig.Screen("[data-unlock]", "a device with no Account Key is offered the ladder")
65
66 // And the ladder itself offers everything it can.
67 rig.Run(
68 chromedp.Navigate(rig.Origin+"/device"),
69 chromedp.WaitVisible(`[data-rung="handoff"]`, chromedp.ByQuery),
70 chromedp.WaitVisible(`[data-rung="type"]`, chromedp.ByQuery),
71 )
72 rig.Screen("[data-need]", "the fresh-device ladder")
73
74 // Rung 3: type it off the card. This is the rung that always works, so it
75 // is the one that must never be broken.
76 rig.Run(
77 chromedp.SendKeys(`#account-key`, printed, chromedp.ByQuery),
78 chromedp.Click(`[data-type-form] button[type=submit]`, chromedp.ByQuery),
79 chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery),
80 )
81
82 // It landed on the vault, and the key really is cached now: the vault
83 // opens with the passphrase alone, which is the promise the whole
84 // Account Key design is built around.
85 rig.Run(
86 chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery),
87 chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery),
88 chromedp.WaitVisible(`[data-item-form]`, chromedp.ByQuery),
89 )
90 rig.Screen("[data-vault]", "unlocked after typing the Account Key")
91}
92
93// One character wrong, on paper, at the worst moment. The checksum is twenty
94// bits and cannot identify which character, so the repair enumerates about a
95// thousand candidates and the KCV picks the real one — which is why the
96// passphrase field appears only after the first attempt fails.
97//
98// This is the most intricate path in the app and the least likely to be
99// exercised by hand.
100func TestBrowserMistypedAccountKeyStillWorks(t *testing.T) {
101 rig, box := newRig(t)
102 const email = "typo@example.invalid"
103 const passphrase = "a generated test passphrase 4d2e9b"
104
105 signUpInBrowser(t, rig, box, email, passphrase)
106
107 var printed string
108 rig.Run(chromedp.Text(`[data-kit="account-key"]`, &printed, chromedp.ByQuery))
109 printed = strings.TrimSpace(printed)
110 forgetTheAccountKey(t, rig)
111
112 // Change one payload character to something else in the alphabet. The
113 // tag stays, so this is a mistyped key rather than a mistyped format.
114 runes := []rune(printed)
115 pos := len(runes) - 3
116 if runes[pos] == 'X' {
117 runes[pos] = 'Y'
118 } else {
119 runes[pos] = 'X'
120 }
121 mistyped := string(runes)
122 if mistyped == printed {
123 t.Fatal("failed to introduce a typo")
124 }
125
126 rig.Run(
127 chromedp.Navigate(rig.Origin+"/device"),
128 chromedp.WaitVisible(`[data-type-form]`, chromedp.ByQuery),
129 chromedp.SendKeys(`#account-key`, mistyped, chromedp.ByQuery),
130 chromedp.Click(`[data-type-form] button[type=submit]`, chromedp.ByQuery),
131 // The passphrase field is revealed, not present from the start: the
132 // common case is a code typed correctly and should not be taxed.
133 chromedp.WaitVisible(`[data-repair]`, chromedp.ByQuery),
134 )
135 rig.Screen("[data-need]", "a mistyped Account Key asks for the passphrase")
136
137 rig.Run(
138 chromedp.SendKeys(`#repair-passphrase`, passphrase, chromedp.ByQuery),
139 chromedp.Click(`[data-type-form] button[type=submit]`, chromedp.ByQuery),
140 chromedp.WaitVisible(`[data-unlock]`, chromedp.ByQuery),
141 )
142
143 // Repaired, cached, and the vault opens — so the candidate the KCV chose
144 // was the real key and not a twenty-bit collision.
145 rig.Run(
146 chromedp.SendKeys(`[data-unlock] input[name=passphrase]`, passphrase, chromedp.ByQuery),
147 chromedp.Click(`[data-unlock] button[type=submit]`, chromedp.ByQuery),
148 chromedp.WaitVisible(`[data-item-form]`, chromedp.ByQuery),
149 )
150}
151
152// The reprint page, which is the mitigation for the one loss mode the Account
153// Key introduced. The Account Key card reprints; the recovery sheet cannot,
154// and the screen has to be honest about that before it offers the button.
155func TestBrowserKitReprintsTheCardAndGuardsTheSheet(t *testing.T) {
156 rig, box := newRig(t)
157 const email = "reprint@example.invalid"
158 const passphrase = "a generated test passphrase 1a7c5e"
159
160 signUpInBrowser(t, rig, box, email, passphrase)
161
162 rig.Run(
163 chromedp.Navigate(rig.Origin+"/kit"),
164 chromedp.WaitVisible(`[data-card-ready]`, chromedp.ByQuery),
165 )
166 rig.Screen("[data-card]", "the reprint page")
167
168 // The reissue button is disabled until the consequence is acknowledged.
169 var disabled bool
170 rig.Run(chromedp.Evaluate(
171 `document.querySelector('[data-regenerate]').disabled`, &disabled))
172 if !disabled {
173 t.Error("the recovery reissue is live before anything is acknowledged")
174 }
175
176 rig.Run(
177 chromedp.Click(`[data-confirm-recovery]`, chromedp.ByQuery),
178 chromedp.Poll(`!document.querySelector('[data-regenerate]').disabled`, nil),
179 )
180
181 // And the nag is there, because this browser is the only one holding the
182 // key and has no way to know otherwise.
183 var nagHidden bool
184 rig.Run(chromedp.Evaluate(`document.querySelector('[data-nag]').hidden`, &nagHidden))
185 if nagHidden {
186 t.Error("the only enrolled device is not being nagged to enrol another")
187 }
188}
189