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.go +576

@@ -0,0 +1,576 @@
1 package identity
2
3 // Passkeys and enrollment invites.
4 //
5 // A passkey is the browser counterpart of an SSH key: the authenticator holds
6 // the private half, Vole stores the public half, and a successful assertion
7 // resolves to the same stable account id the SSH handshake produces. An account
8 // created without an SSH key starts with an invite instead; the invite is a
9 // one-time bearer secret, stored only as a hash, that lets the account holder
10 // register their first passkey.
11 //
12 // Invites are deliberately short-lived and single-use. Losing every passkey is
13 // recovered by an administrator issuing another invite out of band, not by an
14 // email reset flow.
15
16 import (
17 "crypto/rand"
18 "crypto/sha256"
19 "database/sql"
20 "encoding/base64"
21 "encoding/hex"
22 "encoding/json"
23 "errors"
24 "fmt"
25 "sort"
26 "strings"
27 "time"
28
29 "github.com/rodent-software/vole/core/id"
30 )
31
32 const (
33 // DefaultInviteTTL is how long an enrollment link stays usable. Long enough
34 // for an administrator to hand it over; short enough that a leaked link is
35 // not an indefinite key to the account.
36 DefaultInviteTTL = 72 * time.Hour
37
38 inviteTokenBytes = 32
39 )
40
41 var (
42 // ErrInviteInvalid covers missing, expired, used and superseded invites. It
43 // is deliberately one error so a page cannot be used to probe which links
44 // once existed.
45 ErrInviteInvalid = errors.New("identity: invite is invalid or expired")
46 // ErrCredentialNotFound covers unknown and retired-account credentials.
47 ErrCredentialNotFound = errors.New("identity: no such passkey")
48 )
49
50 // Credential is the public half of a passkey. The field names follow the
51 // WebAuthn credential shape so the server's ceremony code can convert without a
52 // second vocabulary.
53 type Credential struct {
54 ID []byte
55 PublicKey []byte
56 AttestationType string
57 Transport []string
58 Flags CredentialFlags
59 Authenticator Authenticator
60 Label string
61 Created time.Time
62 LastUsed time.Time
63 }
64
65 type CredentialFlags struct {
66 UserPresent bool
67 UserVerified bool
68 BackupEligible bool
69 BackupState bool
70 }
71
72 type Authenticator struct {
73 AAGUID []byte
74 SignCount uint32
75 CloneWarning bool
76 Attachment string
77 }
78
79 // Invite is returned once, when it is created. The Token is the plaintext part
80 // of the enrollment URL; the database only has its hash.
81 type Invite struct {
82 Token string
83 Expires time.Time
84 }
85
86 // CreateWithInvite creates an account with no SSH key and an enrollment invite
87 // in the same transaction. This is how a browser-only account — somebody who
88 // files issues but does not push — comes into existence.
89 func (s *Store) CreateWithInvite(name, actor string, ttl time.Duration) (*Account, *Invite, error) {
90 if err := ValidateName(name); err != nil {
91 return nil, nil, err
92 }
93 now := time.Now().UTC()
94 accountID, err := id.New(now)
95 if err != nil {
96 return nil, nil, err
97 }
98 token, tokenHash, err := newInviteToken()
99 if err != nil {
100 return nil, nil, err
101 }
102 inv := &Invite{Token: token, Expires: now.Add(inviteTTL(ttl))}
103 pending := &pendingInvite{tokenHash: tokenHash, expires: inv.Expires, createdBy: actor}
104 err = s.mutate("identity.create", actor, accountID, name+" invite", func(tx *sql.Tx) error {
105 return createAccount(tx, name, accountID, now, nil, pending)
106 })
107 if err != nil {
108 return nil, nil, err
109 }
110 a, err := s.Find(accountID)
111 if err != nil {
112 return nil, nil, err
113 }
114 return a, inv, nil
115 }
116
117 // Invite issues a fresh enrollment link for an existing account and supersedes
118 // any unused link that came before it.
119 func (s *Store) Invite(idOrName, actor string, ttl time.Duration) (*Invite, error) {
120 now := time.Now().UTC()
121 token, tokenHash, err := newInviteToken()
122 if err != nil {
123 return nil, err
124 }
125 inv := &Invite{Token: token, Expires: now.Add(inviteTTL(ttl))}
126 pending := &pendingInvite{tokenHash: tokenHash, expires: inv.Expires, createdBy: actor}
127 err = s.mutate("identity.invite", actor, idOrName, "expires "+inv.Expires.Format(time.RFC3339), func(tx *sql.Tx) error {
128 a, err := findTx(tx, idOrName)
129 if err != nil {
130 return err
131 }
132 if !a.Active() {
133 return fmt.Errorf("identity: %s is retired", a.Name)
134 }
135 // One live link per account. Marking the old ones used rather than
136 // deleting them leaves an audit trail without leaving a usable secret.
137 if _, err := tx.Exec(`UPDATE user_invites SET used_at=? WHERE user_id=? AND used_at IS NULL`, now.Unix(), a.ID); err != nil {
138 return fmt.Errorf("identity: replace invite: %w", err)
139 }
140 return insertInvite(tx, a.ID, pending, now)
141 })
142 if err != nil {
143 return nil, err
144 }
145 return inv, nil
146 }
147
148 // InviteAccount validates an enrollment token without consuming it. The invite
149 // page uses this to show whose account is being claimed; nothing is changed
150 // until the passkey ceremony succeeds.
151 func (s *Store) InviteAccount(token string) (*Account, error) {
152 token = strings.TrimSpace(token)
153 if token == "" {
154 return nil, ErrInviteInvalid
155 }
156 var a Account
157 var created int64
158 err := s.db.QueryRow(`
159 SELECT u.id, u.name, u.status, u.created
160 FROM user_invites i JOIN users u ON u.id = i.user_id
161 WHERE i.token_hash = ? AND i.used_at IS NULL AND i.expires > ? AND u.status = 'active'`,
162 hashToken(token), time.Now().UTC().Unix()).
163 Scan(&a.ID, &a.Name, &a.Status, &created)
164 if errors.Is(err, sql.ErrNoRows) {
165 return nil, ErrInviteInvalid
166 }
167 if err != nil {
168 return nil, fmt.Errorf("identity: read invite: %w", err)
169 }
170 a.Created = time.Unix(created, 0).UTC()
171 return &a, nil
172 }
173
174 // AcceptInvite consumes an enrollment token and attaches the first passkey. The
175 // conditional update and the credential insert are one transaction, so two
176 // browsers racing on the same link cannot both claim the account.
177 func (s *Store) AcceptInvite(token string, cred Credential, actor string) (*Account, error) {
178 if err := validateCredential(cred); err != nil {
179 return nil, err
180 }
181 // Resolve once for the audit line; the transaction checks everything again.
182 account, err := s.InviteAccount(token)
183 if err != nil {
184 return nil, err
185 }
186 token = strings.TrimSpace(token)
187 now := time.Now().UTC()
188 accountID := account.ID
189 err = s.mutate("identity.credential-add", actor, accountID, "invite", func(tx *sql.Tx) error {
190 var userID string
191 var expires int64
192 var used sql.NullInt64
193 err := tx.QueryRow(`
194 SELECT i.user_id, i.expires, i.used_at
195 FROM user_invites i JOIN users u ON u.id = i.user_id
196 WHERE i.token_hash = ? AND u.status = 'active'`, hashToken(token)).
197 Scan(&userID, &expires, &used)
198 if errors.Is(err, sql.ErrNoRows) {
199 return ErrInviteInvalid
200 }
201 if err != nil {
202 return fmt.Errorf("identity: read invite: %w", err)
203 }
204 if used.Valid || expires <= now.Unix() {
205 return ErrInviteInvalid
206 }
207 res, err := tx.Exec(`UPDATE user_invites SET used_at=? WHERE token_hash=? AND used_at IS NULL`, now.Unix(), hashToken(token))
208 if err != nil {
209 return fmt.Errorf("identity: consume invite: %w", err)
210 }
211 n, err := res.RowsAffected()
212 if err != nil {
213 return err
214 }
215 if n != 1 {
216 return ErrInviteInvalid
217 }
218 // A second live link for the same account must not survive the first
219 // successful enrollment.
220 if _, err := tx.Exec(`UPDATE user_invites SET used_at=? WHERE user_id=? AND used_at IS NULL`, now.Unix(), userID); err != nil {
221 return fmt.Errorf("identity: close sibling invites: %w", err)
222 }
223 if err := insertCredential(tx, userID, cred, now); err != nil {
224 return err
225 }
226 accountID = userID
227 return nil
228 })
229 if err != nil {
230 return nil, err
231 }
232 return s.Find(accountID)
233 }
234
235 // AddCredential attaches another passkey to an account that is already logged
236 // in or already has an SSH key.
237 func (s *Store) AddCredential(idOrName string, cred Credential, actor string) error {
238 if err := validateCredential(cred); err != nil {
239 return err
240 }
241 now := time.Now().UTC()
242 return s.mutate("identity.credential-add", actor, idOrName, strings.TrimSpace(cred.Label), func(tx *sql.Tx) error {
243 a, err := findTx(tx, idOrName)
244 if err != nil {
245 return err
246 }
247 if !a.Active() {
248 return fmt.Errorf("identity: %s is retired", a.Name)
249 }
250 return insertCredential(tx, a.ID, cred, now)
251 })
252 }
253
254 // Credentials lists an account's passkeys. Retired accounts are included so the
255 // operator can still see what was registered.
256 func (s *Store) Credentials(idOrName string) ([]Credential, error) {
257 a, err := s.Find(idOrName)
258 if err != nil {
259 return nil, err
260 }
261 return s.credentialsFor(a.ID)
262 }
263
264 // FindCredential resolves a credential id to its account. A retired account's
265 // credential behaves as if it no longer exists.
266 func (s *Store) FindCredential(credentialID []byte) (*Credential, *Account, error) {
267 cred, accountID, err := s.credentialByID(credentialID)
268 if err != nil {
269 return nil, nil, err
270 }
271 a, err := s.Find(accountID)
272 if err != nil || !a.Active() {
273 return nil, nil, ErrCredentialNotFound
274 }
275 return cred, a, nil
276 }
277
278 // UpdateCredential records an assertion counter and the time the key was last
279 // used. Counter policy belongs to the ceremony: this layer stores what it is
280 // given so an authenticator that always reports zero is not locked out.
281 func (s *Store) UpdateCredential(credentialID []byte, signCount uint32, lastUsed time.Time) error {
282 if len(credentialID) == 0 {
283 return ErrCredentialNotFound
284 }
285 res, err := s.db.Exec(`UPDATE user_credentials SET sign_count=?, last_used=? WHERE credential_id=?`,
286 int64(signCount), unixOrZero(lastUsed), credentialIDString(credentialID))
287 if err != nil {
288 return fmt.Errorf("identity: update passkey: %w", err)
289 }
290 n, err := res.RowsAffected()
291 if err != nil {
292 return err
293 }
294 if n == 0 {
295 return ErrCredentialNotFound
296 }
297 return nil
298 }
299
300 // RemoveCredential revokes one passkey. Sessions created by that passkey are
301 // removed by the foreign key in sessions.
302 func (s *Store) RemoveCredential(idOrName string, credentialID []byte, actor string) error {
303 if len(credentialID) == 0 {
304 return ErrCredentialNotFound
305 }
306 return s.mutate("identity.credential-remove", actor, idOrName, credentialIDString(credentialID), func(tx *sql.Tx) error {
307 a, err := findTx(tx, idOrName)
308 if err != nil {
309 return err
310 }
311 res, err := tx.Exec(`DELETE FROM user_credentials WHERE credential_id=? AND user_id=?`,
312 credentialIDString(credentialID), a.ID)
313 if err != nil {
314 return fmt.Errorf("identity: remove passkey: %w", err)
315 }
316 n, err := res.RowsAffected()
317 if err != nil {
318 return err
319 }
320 if n == 0 {
321 return ErrCredentialNotFound
322 }
323 return nil
324 })
325 }
326
327 // ---------- internal ----------
328
329 func inviteTTL(ttl time.Duration) time.Duration {
330 if ttl <= 0 {
331 return DefaultInviteTTL
332 }
333 return ttl
334 }
335
336 func newInviteToken() (token, hash string, err error) {
337 b := make([]byte, inviteTokenBytes)
338 if _, err := rand.Read(b); err != nil {
339 return "", "", fmt.Errorf("identity: invite token: %w", err)
340 }
341 token = base64.RawURLEncoding.EncodeToString(b)
342 return token, hashToken(token), nil
343 }
344
345 func hashToken(token string) string {
346 sum := sha256.Sum256([]byte(token))
347 return hex.EncodeToString(sum[:])
348 }
349
350 func validateCredential(c Credential) error {
351 switch {
352 case len(c.ID) == 0:
353 return errors.New("identity: passkey id is empty")
354 case len(c.ID) > 2048:
355 return errors.New("identity: passkey id is too long")
356 case len(c.PublicKey) == 0:
357 return errors.New("identity: passkey public key is empty")
358 case len(c.PublicKey) > 8192:
359 return errors.New("identity: passkey public key is too long")
360 case len(c.Authenticator.AAGUID) > 256:
361 return errors.New("identity: passkey AAGUID is too long")
362 }
363 return nil
364 }
365
366 func insertCredential(tx *sql.Tx, accountID string, cred Credential, now time.Time) error {
367 id := credentialIDString(cred.ID)
368 var existing string
369 err := tx.QueryRow(`SELECT user_id FROM user_credentials WHERE credential_id=?`, id).Scan(&existing)
370 if err == nil {
371 return errors.New("identity: that passkey is already registered")
372 }
373 if !errors.Is(err, sql.ErrNoRows) {
374 return fmt.Errorf("identity: read passkey: %w", err)
375 }
376 transports, err := json.Marshal(cred.Transport)
377 if err != nil {
378 return fmt.Errorf("identity: passkey transports: %w", err)
379 }
380 created := cred.Created
381 if created.IsZero() {
382 created = now
383 }
384 _, err = tx.Exec(`INSERT INTO user_credentials(
385 credential_id, user_id, public_key, attestation_type, transports, flags, aaguid,
386 sign_count, clone_warning, attachment, label, created, last_used)
387 VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
388 id, accountID, cred.PublicKey, cred.AttestationType, string(transports),
389 flagsValue(cred.Flags), cred.Authenticator.AAGUID, int64(cred.Authenticator.SignCount),
390 boolValue(cred.Authenticator.CloneWarning), cred.Authenticator.Attachment,
391 strings.TrimSpace(cred.Label), created.Unix(), unixOrZero(cred.LastUsed))
392 if err != nil {
393 return fmt.Errorf("identity: add passkey: %w", err)
394 }
395 return nil
396 }
397
398 const credentialColumns = `credential_id, public_key, attestation_type, transports, flags, aaguid,
399 sign_count, clone_warning, attachment, label, created, last_used`
400
401 func scanCredential(scan func(dest ...any) error) (Credential, error) {
402 var (
403 cred Credential
404 id, transports string
405 flags int64
406 aaguid []byte
407 signCount int64
408 cloneWarning int64
409 created, last int64
410 credentialLabel string
411 )
412 if err := scan(&id, &cred.PublicKey, &cred.AttestationType, &transports, &flags, &aaguid,
413 &signCount, &cloneWarning, &cred.Authenticator.Attachment, &credentialLabel, &created, &last); err != nil {
414 return Credential{}, err
415 }
416 rawID, err := ParseCredentialID(id)
417 if err != nil {
418 return Credential{}, fmt.Errorf("identity: stored passkey id: %w", err)
419 }
420 if transports != "" {
421 if err := json.Unmarshal([]byte(transports), &cred.Transport); err != nil {
422 return Credential{}, fmt.Errorf("identity: stored passkey transports: %w", err)
423 }
424 }
425 cred.ID = rawID
426 cred.Flags = flagsFrom(flags)
427 cred.Authenticator.AAGUID = aaguid
428 cred.Authenticator.SignCount = uint32(signCount)
429 cred.Authenticator.CloneWarning = cloneWarning != 0
430 cred.Label = credentialLabel
431 cred.Created = time.Unix(created, 0).UTC()
432 if last > 0 {
433 cred.LastUsed = time.Unix(last, 0).UTC()
434 }
435 return cred, nil
436 }
437
438 func (s *Store) credentialsFor(accountID string) ([]Credential, error) {
439 rows, err := s.db.Query(`SELECT `+credentialColumns+` FROM user_credentials WHERE user_id=? ORDER BY created, credential_id`, accountID)
440 if err != nil {
441 return nil, fmt.Errorf("identity: read passkeys: %w", err)
442 }
443 defer rows.Close()
444 var out []Credential
445 for rows.Next() {
446 cred, err := scanCredential(rows.Scan)
447 if err != nil {
448 return nil, fmt.Errorf("identity: read passkeys: %w", err)
449 }
450 out = append(out, cred)
451 }
452 if err := rows.Err(); err != nil {
453 return nil, err
454 }
455 sort.Slice(out, func(i, j int) bool { return out[i].Created.Before(out[j].Created) })
456 return out, nil
457 }
458
459 func (s *Store) credentialByID(credentialID []byte) (*Credential, string, error) {
460 if len(credentialID) == 0 {
461 return nil, "", ErrCredentialNotFound
462 }
463 var accountID string
464 var cred Credential
465 // Scan the credential columns and the owner in one row, so a credential
466 // cannot disappear between two queries.
467 row := s.db.QueryRow(`SELECT `+credentialColumns+`, user_id FROM user_credentials WHERE credential_id=?`,
468 credentialIDString(credentialID))
469 var (
470 id, transports, label string
471 flags int64
472 aaguid []byte
473 signCount int64
474 cloneWarning int64
475 created, last int64
476 )
477 err := row.Scan(&id, &cred.PublicKey, &cred.AttestationType, &transports, &flags, &aaguid,
478 &signCount, &cloneWarning, &cred.Authenticator.Attachment, &label, &created, &last, &accountID)
479 if errors.Is(err, sql.ErrNoRows) {
480 return nil, "", ErrCredentialNotFound
481 }
482 if err != nil {
483 return nil, "", fmt.Errorf("identity: read passkey: %w", err)
484 }
485 rawID, err := ParseCredentialID(id)
486 if err != nil {
487 return nil, "", fmt.Errorf("identity: stored passkey id: %w", err)
488 }
489 if transports != "" {
490 if err := json.Unmarshal([]byte(transports), &cred.Transport); err != nil {
491 return nil, "", fmt.Errorf("identity: stored passkey transports: %w", err)
492 }
493 }
494 cred.ID = rawID
495 cred.Flags = flagsFrom(flags)
496 cred.Authenticator.AAGUID = aaguid
497 cred.Authenticator.SignCount = uint32(signCount)
498 cred.Authenticator.CloneWarning = cloneWarning != 0
499 cred.Label = label
500 cred.Created = time.Unix(created, 0).UTC()
501 if last > 0 {
502 cred.LastUsed = time.Unix(last, 0).UTC()
503 }
504 return &cred, accountID, nil
505 }
506
507 // CredentialIDString renders a credential id the way the CLI and URLs carry it.
508 func CredentialIDString(id []byte) string { return credentialIDString(id) }
509
510 func credentialIDString(id []byte) string {
511 return base64.RawURLEncoding.EncodeToString(id)
512 }
513
514 // ParseCredentialID accepts the unpadded base64url form Vole prints, and the
515 // padded form people paste from other tools.
516 func ParseCredentialID(s string) ([]byte, error) {
517 s = strings.TrimSpace(s)
518 if s == "" {
519 return nil, errors.New("empty credential id")
520 }
521 if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
522 return b, nil
523 }
524 b, err := base64.URLEncoding.DecodeString(s)
525 if err != nil {
526 return nil, errors.New("credential id is not base64url")
527 }
528 return b, nil
529 }
530
531 const (
532 flagUserPresent = 1 << iota
533 flagUserVerified
534 flagBackupEligible
535 flagBackupState
536 )
537
538 func flagsValue(f CredentialFlags) int64 {
539 var v int64
540 if f.UserPresent {
541 v |= flagUserPresent
542 }
543 if f.UserVerified {
544 v |= flagUserVerified
545 }
546 if f.BackupEligible {
547 v |= flagBackupEligible
548 }
549 if f.BackupState {
550 v |= flagBackupState
551 }
552 return v
553 }
554
555 func flagsFrom(v int64) CredentialFlags {
556 return CredentialFlags{
557 UserPresent: v&flagUserPresent != 0,
558 UserVerified: v&flagUserVerified != 0,
559 BackupEligible: v&flagBackupEligible != 0,
560 BackupState: v&flagBackupState != 0,
561 }
562 }
563
564 func boolValue(b bool) int64 {
565 if b {
566 return 1
567 }
568 return 0
569 }
570
571 func unixOrZero(t time.Time) int64 {
572 if t.IsZero() {
573 return 0
574 }
575 return t.Unix()
576 }