carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

1package contratest
2
3import (
4 "encoding/hex"
5 "net/http"
6 "strings"
7 "testing"
8
9 "github.com/carlosframework/contra/internal/contra/crypto"
10 "github.com/carlosframework/contra/internal/contra/model"
11)
12
13// Reissuing the recovery code — the one destructive write in the app.
14//
15// The thing worth testing is not that a row changed. It is the pair of facts
16// the copy promises: the NEW code opens the vault, and the OLD one does not,
17// with no window in which neither does.
18
19// newRecovery is the client's half: mint a code, derive its branch, seal RK
20// under it. Exactly what reissue.js does, in Go.
21type newRecovery struct {
22 code []byte
23 kek []byte
24 wrapID []byte
25 nonce []byte
26 sealed []byte
27 publicKey []byte
28}
29
30func mintRecovery(t *testing.T, accountID, rk []byte) newRecovery {
31 t.Helper()
32 n := newRecovery{
33 code: randBytes(t, 16),
34 wrapID: randBytes(t, 16),
35 nonce: randBytes(t, 12),
36 }
37 seed, err := crypto.Derive(n.code, nil, crypto.LabelRCLogin, 32)
38 if err != nil {
39 t.Fatal(err)
40 }
41 key, err := crypto.DeriveP256(seed)
42 if err != nil {
43 t.Fatal(err)
44 }
45 n.publicKey = key.PublicKey().Bytes()
46
47 n.kek, err = crypto.Derive(n.code, nil, crypto.LabelRCKEK, 32)
48 if err != nil {
49 t.Fatal(err)
50 }
51 n.sealed, err = crypto.Seal(n.kek, n.nonce,
52 crypto.AAD(accountID, crypto.ObjectWrapRC, n.wrapID, 1), rk)
53 if err != nil {
54 t.Fatal(err)
55 }
56 return n
57}
58
59func (n newRecovery) post(t *testing.T, h http.Handler, cookie string) int {
60 t.Helper()
61 rec := as(t, h, cookie, http.MethodPost, "/api/recovery", map[string]any{
62 "wrap_id_hex": hex.EncodeToString(n.wrapID),
63 "kcv_hex": hex.EncodeToString(crypto.KCV(n.kek)),
64 "kdf_params": `{"alg":"argon2id","m_kib":65536,"t":3,"p":1}`,
65 "login_public_key_hex": hex.EncodeToString(n.publicKey),
66 "sealed": map[string]string{
67 "nonce_hex": hex.EncodeToString(n.nonce),
68 "ciphertext_hex": hex.EncodeToString(n.sealed),
69 },
70 })
71 return rec.Code
72}
73
74func TestReissuingRecoveryReplacesTheBranch(t *testing.T) {
75 h, gdb := newAppWithDB(t)
76 p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c")
77
78 var before model.Wrap
79 if err := gdb.Where("account_id = ? AND kind = ?",
80 p.id, crypto.ObjectWrapRC).Take(&before).Error; err != nil {
81 t.Fatalf("no recovery wrap to begin with: %v", err)
82 }
83
84 fresh := mintRecovery(t, p.id, p.rk)
85 if code := fresh.post(t, h, p.cookie); code != http.StatusOK {
86 t.Fatalf("reissue: status %d", code)
87 }
88
89 // Exactly one recovery wrap, and it is the new one. Two would mean two
90 // live sheets, which is the failure this whole flow exists to avoid.
91 var wraps []model.Wrap
92 if err := gdb.Where("account_id = ? AND kind = ?",
93 p.id, crypto.ObjectWrapRC).Find(&wraps).Error; err != nil {
94 t.Fatal(err)
95 }
96 if len(wraps) != 1 {
97 t.Fatalf("%d recovery wraps after a reissue, want 1", len(wraps))
98 }
99 if hex.EncodeToString(wraps[0].ID) == hex.EncodeToString(before.ID) {
100 t.Error("the old recovery wrap is still the one stored")
101 }
102
103 // The new code opens RK.
104 got, err := crypto.Open(fresh.kek, wraps[0].Nonce,
105 crypto.AAD(p.id, crypto.ObjectWrapRC, wraps[0].ID, wraps[0].Version),
106 wraps[0].Ciphertext)
107 if err != nil {
108 t.Fatalf("the new recovery code does not open the new wrap: %v", err)
109 }
110 if hex.EncodeToString(got) != hex.EncodeToString(p.rk) {
111 t.Error("the new wrap does not contain RK")
112 }
113
114 // And the old one does not — which is what the copy promises before the
115 // person commits, and the only reason this is allowed to delete.
116 oldKEK, err := crypto.Derive(p.recovery, nil, crypto.LabelRCKEK, 32)
117 if err != nil {
118 t.Fatal(err)
119 }
120 if _, err := crypto.Open(oldKEK, wraps[0].Nonce,
121 crypto.AAD(p.id, crypto.ObjectWrapRC, wraps[0].ID, wraps[0].Version),
122 wraps[0].Ciphertext); err == nil {
123 t.Error("the old recovery code still opens the vault")
124 }
125
126 // The recovery LOGIN key is replaced too. Leaving the old one would let
127 // the old sheet still pass sign-in and then fail at unlock, which is a
128 // much worse way to find out.
129 var logins []model.LoginKey
130 if err := gdb.Where("account_id = ? AND branch = ?", p.id, "rc").
131 Find(&logins).Error; err != nil {
132 t.Fatal(err)
133 }
134 if len(logins) != 1 {
135 t.Fatalf("%d recovery login keys, want 1", len(logins))
136 }
137 if hex.EncodeToString(logins[0].PublicKey) != hex.EncodeToString(fresh.publicKey) {
138 t.Error("the recovery login key was not replaced")
139 }
140}
141
142// The passphrase branch is not in the transaction and must not be touched.
143// PROTOCOL.md: W_pw and W_rc are never removed, so nothing here may be able to
144// strand an account.
145func TestReissuingRecoveryLeavesThePassphraseBranchAlone(t *testing.T) {
146 h, gdb := newAppWithDB(t)
147 p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c")
148
149 var before model.Wrap
150 if err := gdb.Where("account_id = ? AND kind = ?",
151 p.id, crypto.ObjectWrapPW).Take(&before).Error; err != nil {
152 t.Fatal(err)
153 }
154
155 if code := mintRecovery(t, p.id, p.rk).post(t, h, p.cookie); code != http.StatusOK {
156 t.Fatalf("reissue: status %d", code)
157 }
158
159 var after model.Wrap
160 if err := gdb.Where("account_id = ? AND kind = ?",
161 p.id, crypto.ObjectWrapPW).Take(&after).Error; err != nil {
162 t.Fatalf("the passphrase wrap is gone: %v", err)
163 }
164 if hex.EncodeToString(after.ID) != hex.EncodeToString(before.ID) ||
165 hex.EncodeToString(after.Ciphertext) != hex.EncodeToString(before.Ciphertext) {
166 t.Error("the passphrase wrap changed during a recovery reissue")
167 }
168}
169
170// One account's reissue must not reach another's branch. This is a delete, so
171// a dropped owner filter here would not return the wrong data — it would
172// destroy somebody else's only spare key.
173func TestReissuingRecoveryCannotReachAnotherAccount(t *testing.T) {
174 h, gdb := newAppWithDB(t)
175 mine := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c")
176 theirs := provision(t, h, "other@example.invalid", "a different generated one 4d2e9b")
177
178 var before model.Wrap
179 if err := gdb.Where("account_id = ? AND kind = ?",
180 theirs.id, crypto.ObjectWrapRC).Take(&before).Error; err != nil {
181 t.Fatal(err)
182 }
183
184 if code := mintRecovery(t, mine.id, mine.rk).post(t, h, mine.cookie); code != http.StatusOK {
185 t.Fatalf("reissue: status %d", code)
186 }
187
188 var after model.Wrap
189 if err := gdb.Where("account_id = ? AND kind = ?",
190 theirs.id, crypto.ObjectWrapRC).Take(&after).Error; err != nil {
191 t.Fatalf("another account's recovery wrap was destroyed: %v", err)
192 }
193 if hex.EncodeToString(after.ID) != hex.EncodeToString(before.ID) {
194 t.Error("another account's recovery wrap changed")
195 }
196}
197
198func TestReissuingRecoveryRefusesAStranger(t *testing.T) {
199 h := newApp(t)
200 rec := as(t, h, "", http.MethodPost, "/api/recovery", map[string]string{})
201 if rec.Code == http.StatusOK {
202 t.Error("a signed-out browser reissued a recovery code")
203 }
204}
205
206// The kit page has to say what is about to happen BEFORE the control that
207// does it. This is the one screen where wording is a safety feature.
208func TestKitPageWarnsBeforeItOffersToReissue(t *testing.T) {
209 h := newApp(t)
210 p := provision(t, h, "owner@example.invalid", "a generated test passphrase 8f3a1c")
211
212 body := as(t, h, p.cookie, http.MethodGet, "/kit", nil).Body.String()
213 warn := strings.Index(body, "stops working immediately")
214 control := strings.Index(body, "data-regenerate")
215 if warn < 0 {
216 t.Fatal("the kit page does not say the old sheet stops working")
217 }
218 if control < 0 {
219 t.Fatal("the kit page has no reissue control")
220 }
221 if warn > control {
222 t.Error("the warning comes after the button that acts on it")
223 }
224 if !strings.Contains(body, "data-confirm-recovery") {
225 t.Error("the reissue has no explicit confirmation")
226 }
227}
228