Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Add browser auth, contributor access, and gated CI

6c84d387e5f608360e30484a1a5b1cff41076850 · Keenan Nemetz · 16d ago · parent c36d5073

Add browser auth, contributor access, and gated CI

Browser sign-in now uses passkeys. Enrollment links are one-time, sessions are hashed in users.db, and issue/comment/approval writes use the session account. HTTPS public_url is required for WebAuthn.

Add the contributor role between read and maintain. Contributors can discuss and open change requests, and may push only refs/heads/contrib/<account-id>/. Contributor CI is held per commit until a maintainer allows the exact tip from the repository page.

Rename the write role to maintain. write remains accepted as the historical spelling, and access.Write remains as a Go alias so existing callers and ACL rows keep working.

Update the web, CLI, queue schema, and guides for the new flows, and cover the role, namespace, auth, and CI-gating paths in tests.

forge/identity/passkey_test.go +284

@@ -0,0 +1,284 @@
1 package identity
2
3 import (
4 "crypto/rand"
5 "errors"
6 "testing"
7 "time"
8 )
9
10 func testCredential(t *testing.T, label string) Credential {
11 t.Helper()
12 id := make([]byte, 20)
13 pub := make([]byte, 32)
14 if _, err := rand.Read(id); err != nil {
15 t.Fatal(err)
16 }
17 if _, err := rand.Read(pub); err != nil {
18 t.Fatal(err)
19 }
20 return Credential{
21 ID: id,
22 PublicKey: pub,
23 AttestationType: "none",
24 Transport: []string{"internal", "hybrid"},
25 Flags: CredentialFlags{UserPresent: true, UserVerified: true, BackupEligible: true, BackupState: true},
26 Authenticator: Authenticator{
27 AAGUID: []byte{1, 2, 3, 4},
28 SignCount: 7,
29 CloneWarning: false,
30 Attachment: "platform",
31 },
32 Label: label,
33 }
34 }
35
36 func TestCreateWithInviteAndAcceptPasskey(t *testing.T) {
37 t.Parallel()
38 s := newStore(t)
39 a, inv, err := s.CreateWithInvite("alice", "operator", time.Hour)
40 if err != nil {
41 t.Fatal(err)
42 }
43 if a.ID == "" || len(a.Keys) != 0 {
44 t.Fatalf("created account = %+v, want an id and no SSH key", a)
45 }
46 if inv.Token == "" || !inv.Expires.After(time.Now()) {
47 t.Fatalf("invite = %+v", inv)
48 }
49 got, err := s.InviteAccount(inv.Token)
50 if err != nil {
51 t.Fatal(err)
52 }
53 if got.ID != a.ID || got.Name != "alice" {
54 t.Fatalf("InviteAccount = %+v, want %s", got, a.ID)
55 }
56
57 cred := testCredential(t, "laptop")
58 redeemed, err := s.AcceptInvite(inv.Token, cred, "alice")
59 if err != nil {
60 t.Fatal(err)
61 }
62 if redeemed.ID != a.ID {
63 t.Fatalf("redeemed account = %s, want %s", redeemed.ID, a.ID)
64 }
65 creds, err := s.Credentials("alice")
66 if err != nil {
67 t.Fatal(err)
68 }
69 if len(creds) != 1 {
70 t.Fatalf("credentials = %d, want 1", len(creds))
71 }
72 if string(creds[0].ID) != string(cred.ID) || creds[0].Label != "laptop" {
73 t.Fatalf("stored credential = %+v, want id %x label laptop", creds[0], cred.ID)
74 }
75 if !creds[0].Flags.UserVerified || !creds[0].Flags.BackupEligible || !creds[0].Flags.BackupState {
76 t.Fatalf("credential flags lost: %+v", creds[0].Flags)
77 }
78 if creds[0].Authenticator.SignCount != 7 || string(creds[0].Authenticator.AAGUID) != string([]byte{1, 2, 3, 4}) {
79 t.Fatalf("authenticator fields lost: %+v", creds[0].Authenticator)
80 }
81
82 found, owner, err := s.FindCredential(cred.ID)
83 if err != nil {
84 t.Fatal(err)
85 }
86 if owner.ID != a.ID || string(found.ID) != string(cred.ID) {
87 t.Fatalf("FindCredential = %s/%x, want %s/%x", owner.ID, found.ID, a.ID, cred.ID)
88 }
89
90 // The link is spent, including a second attempt with a fresh credential.
91 if _, err := s.InviteAccount(inv.Token); !errors.Is(err, ErrInviteInvalid) {
92 t.Fatalf("used invite still resolves: %v", err)
93 }
94 if _, err := s.AcceptInvite(inv.Token, testCredential(t, "other"), "alice"); !errors.Is(err, ErrInviteInvalid) {
95 t.Fatalf("used invite still redeems: %v", err)
96 }
97 }
98
99 func TestNewInviteSupersedesTheOldOne(t *testing.T) {
100 t.Parallel()
101 s := newStore(t)
102 _, first, err := s.CreateWithInvite("alice", "operator", time.Hour)
103 if err != nil {
104 t.Fatal(err)
105 }
106 second, err := s.Invite("alice", "operator", time.Hour)
107 if err != nil {
108 t.Fatal(err)
109 }
110 if _, err := s.InviteAccount(first.Token); !errors.Is(err, ErrInviteInvalid) {
111 t.Fatalf("superseded invite still resolves: %v", err)
112 }
113 if _, err := s.InviteAccount(second.Token); err != nil {
114 t.Fatalf("new invite does not resolve: %v", err)
115 }
116 }
117
118 func TestExpiredInviteIsInvalid(t *testing.T) {
119 t.Parallel()
120 s := newStore(t)
121 _, inv, err := s.CreateWithInvite("alice", "operator", time.Hour)
122 if err != nil {
123 t.Fatal(err)
124 }
125 if _, err := s.db.Exec(`UPDATE user_invites SET expires=? WHERE token_hash=?`,
126 time.Now().Add(-time.Minute).Unix(), hashToken(inv.Token)); err != nil {
127 t.Fatal(err)
128 }
129 if _, err := s.InviteAccount(inv.Token); !errors.Is(err, ErrInviteInvalid) {
130 t.Fatalf("expired invite resolves: %v", err)
131 }
132 if _, err := s.AcceptInvite(inv.Token, testCredential(t, "laptop"), "alice"); !errors.Is(err, ErrInviteInvalid) {
133 t.Fatalf("expired invite redeems: %v", err)
134 }
135 }
136
137 func TestPasskeyOnlyAccountSurvivesReopen(t *testing.T) {
138 t.Parallel()
139 s := newStore(t)
140 a, inv, err := s.CreateWithInvite("alice", "operator", time.Hour)
141 if err != nil {
142 t.Fatal(err)
143 }
144 cred := testCredential(t, "phone")
145 if _, err := s.AcceptInvite(inv.Token, cred, "alice"); err != nil {
146 t.Fatal(err)
147 }
148 reopened, err := Open(s.Path())
149 if err != nil {
150 t.Fatal(err)
151 }
152 defer reopened.Close()
153 creds, err := reopened.Credentials(a.ID)
154 if err != nil {
155 t.Fatal(err)
156 }
157 if len(creds) != 1 || string(creds[0].ID) != string(cred.ID) {
158 t.Fatalf("reopened credentials = %+v", creds)
159 }
160 }
161
162 func TestDuplicatePasskeyRejected(t *testing.T) {
163 t.Parallel()
164 s := newStore(t)
165 alice := mustCreate(t, s, "alice")
166 bob := mustCreate(t, s, "bob")
167 cred := testCredential(t, "laptop")
168 if err := s.AddCredential(alice.ID, cred, "test"); err != nil {
169 t.Fatal(err)
170 }
171 if err := s.AddCredential(bob.ID, cred, "test"); err == nil {
172 t.Fatal("the same passkey was attached to two accounts")
173 }
174 }
175
176 func TestUpdatePasskeyCounterAndRemove(t *testing.T) {
177 t.Parallel()
178 s := newStore(t)
179 a := mustCreate(t, s, "alice")
180 cred := testCredential(t, "laptop")
181 // A minimal credential has no AAGUID and no transports; both columns are
182 // nullable and must not break a later read.
183 cred.Authenticator.AAGUID = nil
184 cred.Transport = nil
185 if err := s.AddCredential(a.ID, cred, "test"); err != nil {
186 t.Fatal(err)
187 }
188 used := time.Now().UTC().Truncate(time.Second)
189 if err := s.UpdateCredential(cred.ID, 42, used); err != nil {
190 t.Fatal(err)
191 }
192 creds, err := s.Credentials(a.ID)
193 if err != nil {
194 t.Fatal(err)
195 }
196 if creds[0].Authenticator.SignCount != 42 || !creds[0].LastUsed.Equal(used) {
197 t.Fatalf("counter update lost: %+v", creds[0])
198 }
199 if err := s.RemoveCredential(a.ID, cred.ID, "test"); err != nil {
200 t.Fatal(err)
201 }
202 if _, _, err := s.FindCredential(cred.ID); !errors.Is(err, ErrCredentialNotFound) {
203 t.Fatalf("removed credential still resolves: %v", err)
204 }
205 if err := s.RemoveCredential(a.ID, cred.ID, "test"); !errors.Is(err, ErrCredentialNotFound) {
206 t.Fatalf("removing twice = %v, want ErrCredentialNotFound", err)
207 }
208 }
209
210 func TestRetireEndsInvitesAndSessionsAndHidesCredentials(t *testing.T) {
211 t.Parallel()
212 s := newStore(t)
213 a, inv, err := s.CreateWithInvite("alice", "operator", time.Hour)
214 if err != nil {
215 t.Fatal(err)
216 }
217 cred := testCredential(t, "laptop")
218 if _, err := s.AcceptInvite(inv.Token, cred, "alice"); err != nil {
219 t.Fatal(err)
220 }
221 if _, err := s.db.Exec(`INSERT INTO sessions(token_hash, account_id, credential_id, created, last_seen, expires)
222 VALUES('hash', ?, ?, 1, 1, 9999999999)`, a.ID, credentialIDString(cred.ID)); err != nil {
223 t.Fatal(err)
224 }
225 // An outstanding invite must also die with the account.
226 if _, err := s.Invite("alice", "operator", time.Hour); err != nil {
227 t.Fatal(err)
228 }
229 if _, err := s.Retire("alice", "operator"); err != nil {
230 t.Fatal(err)
231 }
232 var sessions, invites int
233 if err := s.db.QueryRow(`SELECT count(*) FROM sessions WHERE account_id=?`, a.ID).Scan(&sessions); err != nil {
234 t.Fatal(err)
235 }
236 if sessions != 0 {
237 t.Fatalf("retire left %d session(s)", sessions)
238 }
239 if err := s.db.QueryRow(`SELECT count(*) FROM user_invites WHERE user_id=?`, a.ID).Scan(&invites); err != nil {
240 t.Fatal(err)
241 }
242 if invites != 0 {
243 t.Fatalf("retire left %d invite(s)", invites)
244 }
245 if _, _, err := s.FindCredential(cred.ID); !errors.Is(err, ErrCredentialNotFound) {
246 t.Fatalf("retired account's passkey still resolves: %v", err)
247 }
248 }
249
250 func TestRemovingPasskeyCascadesItsSessions(t *testing.T) {
251 t.Parallel()
252 s := newStore(t)
253 a := mustCreate(t, s, "alice")
254 cred := testCredential(t, "laptop")
255 if err := s.AddCredential(a.ID, cred, "test"); err != nil {
256 t.Fatal(err)
257 }
258 other := testCredential(t, "phone")
259 if err := s.AddCredential(a.ID, other, "test"); err != nil {
260 t.Fatal(err)
261 }
262 for _, id := range [][]byte{cred.ID, other.ID} {
263 if _, err := s.db.Exec(`INSERT INTO sessions(token_hash, account_id, credential_id, created, last_seen, expires)
264 VALUES(?, ?, ?, 1, 1, 9999999999)`, "hash-"+credentialIDString(id), a.ID, credentialIDString(id)); err != nil {
265 t.Fatal(err)
266 }
267 }
268 if err := s.RemoveCredential(a.ID, cred.ID, "test"); err != nil {
269 t.Fatal(err)
270 }
271 var n int
272 if err := s.db.QueryRow(`SELECT count(*) FROM sessions WHERE credential_id=?`, credentialIDString(cred.ID)).Scan(&n); err != nil {
273 t.Fatal(err)
274 }
275 if n != 0 {
276 t.Fatalf("removed passkey left %d session(s)", n)
277 }
278 if err := s.db.QueryRow(`SELECT count(*) FROM sessions WHERE credential_id=?`, credentialIDString(other.ID)).Scan(&n); err != nil {
279 t.Fatal(err)
280 }
281 if n != 1 {
282 t.Fatalf("removing one passkey removed another's session (count=%d)", n)
283 }
284 }