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.

Identify accounts by their registered name

62576fa8157d431dcf2d360f3ebe481de77bda03 · Keenan Nemetz · 14d ago · parent 642e5994

Identify accounts by their registered name

Drop the internal account id and display name. Users, keys, passkeys,
invites, sessions, ACL entries, approvals, locks and CI records now all
use the registered name as the single identity.

The roster migrates in place: old id-keyed tables are rebuilt around the
name, and access-rule/lock references are rewritten through the same
mapping. Display-name editing and whoami --id are removed.

forge/identity/passkey.go +55−73

@@ -4,7 +4,7 @@ package identity
4 4 //
5 5 // A passkey is the browser counterpart of an SSH key: the authenticator holds
6 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
7 // resolves to the same account name the SSH handshake produces. An account
8 8 // created without an SSH key starts with an invite instead; the invite is a
9 9 // one-time bearer secret, stored only as a hash, that lets the account holder
10 10 // register their first passkey.
@@ -25,8 +25,6 @@ import (
25 25 "sort"
26 26 "strings"
27 27 "time"
28
29 "github.com/rodent-software/vole/core/id"
30 28 )
31 29
32 30 const (
@@ -84,66 +82,50 @@ type Invite struct {
84 82 }
85 83
86 84 // CreatePasskey creates a self-registered account and its first passkey in one
87 // transaction. The account id is chosen by the caller because the WebAuthn user
88 // handle is fixed when the ceremony begins; the row is only written when the
89 // authenticator has actually produced a credential, so an abandoned registration
90 // reserves no name.
91 func (s *Store) CreatePasskey(name, display, accountID string, cred Credential, actor string) (*Account, error) {
85 // transaction. The name is fixed when the ceremony begins; the row is only
86 // written when the authenticator has actually produced a credential, so an
87 // abandoned registration reserves no name.
88 func (s *Store) CreatePasskey(name string, cred Credential, actor string) (*Account, error) {
92 89 if err := ValidateName(name); err != nil {
93 90 return nil, err
94 91 }
95 display = strings.TrimSpace(display)
96 if err := ValidateDisplay(display); err != nil {
97 return nil, err
98 }
99 if !id.Valid(accountID) {
100 return nil, fmt.Errorf("identity: %q is not a valid account id", accountID)
101 }
102 92 if err := validateCredential(cred); err != nil {
103 93 return nil, err
104 94 }
105 95 now := time.Now().UTC()
106 err := s.mutate("identity.create", actor, accountID, name+" passkey", func(tx *sql.Tx) error {
107 if err := createAccount(tx, name, display, accountID, now, nil, nil); err != nil {
96 err := s.mutate("identity.create", actor, name, name+" passkey", func(tx *sql.Tx) error {
97 if err := createAccount(tx, name, now, nil, nil); err != nil {
108 98 return err
109 99 }
110 return insertCredential(tx, accountID, cred, now)
100 return insertCredential(tx, name, cred, now)
111 101 })
112 102 if err != nil {
113 103 return nil, err
114 104 }
115 return s.Find(accountID)
105 return s.Find(name)
116 106 }
117 107
118 108 // CreateWithInvite creates an account with no SSH key and an enrollment invite
119 109 // in the same transaction. This is how a browser-only account — somebody who
120 110 // files issues but does not push — comes into existence.
121 func (s *Store) CreateWithInvite(name, display, actor string, ttl time.Duration) (*Account, *Invite, error) {
111 func (s *Store) CreateWithInvite(name, actor string, ttl time.Duration) (*Account, *Invite, error) {
122 112 if err := ValidateName(name); err != nil {
123 113 return nil, nil, err
124 114 }
125 display = strings.TrimSpace(display)
126 if err := ValidateDisplay(display); err != nil {
127 return nil, nil, err
128 }
129 115 now := time.Now().UTC()
130 accountID, err := id.New(now)
131 if err != nil {
132 return nil, nil, err
133 }
134 116 token, tokenHash, err := newInviteToken()
135 117 if err != nil {
136 118 return nil, nil, err
137 119 }
138 120 inv := &Invite{Token: token, Expires: now.Add(inviteTTL(ttl))}
139 121 pending := &pendingInvite{tokenHash: tokenHash, expires: inv.Expires, createdBy: actor}
140 err = s.mutate("identity.create", actor, accountID, name+" invite", func(tx *sql.Tx) error {
141 return createAccount(tx, name, display, accountID, now, nil, pending)
122 err = s.mutate("identity.create", actor, name, name+" invite", func(tx *sql.Tx) error {
123 return createAccount(tx, name, now, nil, pending)
142 124 })
143 125 if err != nil {
144 126 return nil, nil, err
145 127 }
146 a, err := s.Find(accountID)
128 a, err := s.Find(name)
147 129 if err != nil {
148 130 return nil, nil, err
149 131 }
@@ -152,7 +134,7 @@ func (s *Store) CreateWithInvite(name, display, actor string, ttl time.Duration)
152 134
153 135 // Invite issues a fresh enrollment link for an existing account and supersedes
154 136 // any unused link that came before it.
155 func (s *Store) Invite(idOrName, actor string, ttl time.Duration) (*Invite, error) {
137 func (s *Store) Invite(name, actor string, ttl time.Duration) (*Invite, error) {
156 138 now := time.Now().UTC()
157 139 token, tokenHash, err := newInviteToken()
158 140 if err != nil {
@@ -160,8 +142,8 @@ func (s *Store) Invite(idOrName, actor string, ttl time.Duration) (*Invite, erro
160 142 }
161 143 inv := &Invite{Token: token, Expires: now.Add(inviteTTL(ttl))}
162 144 pending := &pendingInvite{tokenHash: tokenHash, expires: inv.Expires, createdBy: actor}
163 err = s.mutate("identity.invite", actor, idOrName, "expires "+inv.Expires.Format(time.RFC3339), func(tx *sql.Tx) error {
164 a, err := findTx(tx, idOrName)
145 err = s.mutate("identity.invite", actor, name, "expires "+inv.Expires.Format(time.RFC3339), func(tx *sql.Tx) error {
146 a, err := findTx(tx, name)
165 147 if err != nil {
166 148 return err
167 149 }
@@ -170,10 +152,10 @@ func (s *Store) Invite(idOrName, actor string, ttl time.Duration) (*Invite, erro
170 152 }
171 153 // One live link per account. Marking the old ones used rather than
172 154 // deleting them leaves an audit trail without leaving a usable secret.
173 if _, err := tx.Exec(`UPDATE user_invites SET used_at=? WHERE user_id=? AND used_at IS NULL`, now.Unix(), a.ID); err != nil {
155 if _, err := tx.Exec(`UPDATE user_invites SET used_at=? WHERE user_name=? AND used_at IS NULL`, now.Unix(), a.Name); err != nil {
174 156 return fmt.Errorf("identity: replace invite: %w", err)
175 157 }
176 return insertInvite(tx, a.ID, pending, now)
158 return insertInvite(tx, a.Name, pending, now)
177 159 })
178 160 if err != nil {
179 161 return nil, err
@@ -192,11 +174,11 @@ func (s *Store) InviteAccount(token string) (*Account, error) {
192 174 var a Account
193 175 var created int64
194 176 err := s.db.QueryRow(`
195 SELECT u.id, u.name, u.status, u.created
196 FROM user_invites i JOIN users u ON u.id = i.user_id
177 SELECT u.name, u.status, u.created
178 FROM user_invites i JOIN users u ON u.name = i.user_name
197 179 WHERE i.token_hash = ? AND i.used_at IS NULL AND i.expires > ? AND u.status = 'active'`,
198 180 hashToken(token), time.Now().UTC().Unix()).
199 Scan(&a.ID, &a.Name, &a.Status, &created)
181 Scan(&a.Name, &a.Status, &created)
200 182 if errors.Is(err, sql.ErrNoRows) {
201 183 return nil, ErrInviteInvalid
202 184 }
@@ -221,16 +203,16 @@ func (s *Store) AcceptInvite(token string, cred Credential, actor string) (*Acco
221 203 }
222 204 token = strings.TrimSpace(token)
223 205 now := time.Now().UTC()
224 accountID := account.ID
225 err = s.mutate("identity.credential-add", actor, accountID, "invite", func(tx *sql.Tx) error {
226 var userID string
206 accountName := account.Name
207 err = s.mutate("identity.credential-add", actor, accountName, "invite", func(tx *sql.Tx) error {
208 var userName string
227 209 var expires int64
228 210 var used sql.NullInt64
229 211 err := tx.QueryRow(`
230 SELECT i.user_id, i.expires, i.used_at
231 FROM user_invites i JOIN users u ON u.id = i.user_id
212 SELECT i.user_name, i.expires, i.used_at
213 FROM user_invites i JOIN users u ON u.name = i.user_name
232 214 WHERE i.token_hash = ? AND u.status = 'active'`, hashToken(token)).
233 Scan(&userID, &expires, &used)
215 Scan(&userName, &expires, &used)
234 216 if errors.Is(err, sql.ErrNoRows) {
235 217 return ErrInviteInvalid
236 218 }
@@ -253,58 +235,58 @@ WHERE i.token_hash = ? AND u.status = 'active'`, hashToken(token)).
253 235 }
254 236 // A second live link for the same account must not survive the first
255 237 // successful enrollment.
256 if _, err := tx.Exec(`UPDATE user_invites SET used_at=? WHERE user_id=? AND used_at IS NULL`, now.Unix(), userID); err != nil {
238 if _, err := tx.Exec(`UPDATE user_invites SET used_at=? WHERE user_name=? AND used_at IS NULL`, now.Unix(), userName); err != nil {
257 239 return fmt.Errorf("identity: close sibling invites: %w", err)
258 240 }
259 if err := insertCredential(tx, userID, cred, now); err != nil {
241 if err := insertCredential(tx, userName, cred, now); err != nil {
260 242 return err
261 243 }
262 accountID = userID
244 accountName = userName
263 245 return nil
264 246 })
265 247 if err != nil {
266 248 return nil, err
267 249 }
268 return s.Find(accountID)
250 return s.Find(accountName)
269 251 }
270 252
271 253 // AddCredential attaches another passkey to an account that is already logged
272 254 // in or already has an SSH key.
273 func (s *Store) AddCredential(idOrName string, cred Credential, actor string) error {
255 func (s *Store) AddCredential(name string, cred Credential, actor string) error {
274 256 if err := validateCredential(cred); err != nil {
275 257 return err
276 258 }
277 259 now := time.Now().UTC()
278 return s.mutate("identity.credential-add", actor, idOrName, strings.TrimSpace(cred.Label), func(tx *sql.Tx) error {
279 a, err := findTx(tx, idOrName)
260 return s.mutate("identity.credential-add", actor, name, strings.TrimSpace(cred.Label), func(tx *sql.Tx) error {
261 a, err := findTx(tx, name)
280 262 if err != nil {
281 263 return err
282 264 }
283 265 if !a.Active() {
284 266 return fmt.Errorf("identity: %s is retired", a.Name)
285 267 }
286 return insertCredential(tx, a.ID, cred, now)
268 return insertCredential(tx, a.Name, cred, now)
287 269 })
288 270 }
289 271
290 272 // Credentials lists an account's passkeys. Retired accounts are included so the
291 273 // operator can still see what was registered.
292 func (s *Store) Credentials(idOrName string) ([]Credential, error) {
293 a, err := s.Find(idOrName)
274 func (s *Store) Credentials(name string) ([]Credential, error) {
275 a, err := s.Find(name)
294 276 if err != nil {
295 277 return nil, err
296 278 }
297 return s.credentialsFor(a.ID)
279 return s.credentialsFor(a.Name)
298 280 }
299 281
300 282 // FindCredential resolves a credential id to its account. A retired account's
301 283 // credential behaves as if it no longer exists.
302 284 func (s *Store) FindCredential(credentialID []byte) (*Credential, *Account, error) {
303 cred, accountID, err := s.credentialByID(credentialID)
285 cred, accountName, err := s.credentialByID(credentialID)
304 286 if err != nil {
305 287 return nil, nil, err
306 288 }
307 a, err := s.Find(accountID)
289 a, err := s.Find(accountName)
308 290 if err != nil || !a.Active() {
309 291 return nil, nil, ErrCredentialNotFound
310 292 }
@@ -335,17 +317,17 @@ func (s *Store) UpdateCredential(credentialID []byte, signCount uint32, lastUsed
335 317
336 318 // RemoveCredential revokes one passkey. Sessions created by that passkey are
337 319 // removed by the foreign key in sessions.
338 func (s *Store) RemoveCredential(idOrName string, credentialID []byte, actor string) error {
320 func (s *Store) RemoveCredential(name string, credentialID []byte, actor string) error {
339 321 if len(credentialID) == 0 {
340 322 return ErrCredentialNotFound
341 323 }
342 return s.mutate("identity.credential-remove", actor, idOrName, credentialIDString(credentialID), func(tx *sql.Tx) error {
343 a, err := findTx(tx, idOrName)
324 return s.mutate("identity.credential-remove", actor, name, credentialIDString(credentialID), func(tx *sql.Tx) error {
325 a, err := findTx(tx, name)
344 326 if err != nil {
345 327 return err
346 328 }
347 res, err := tx.Exec(`DELETE FROM user_credentials WHERE credential_id=? AND user_id=?`,
348 credentialIDString(credentialID), a.ID)
329 res, err := tx.Exec(`DELETE FROM user_credentials WHERE credential_id=? AND user_name=?`,
330 credentialIDString(credentialID), a.Name)
349 331 if err != nil {
350 332 return fmt.Errorf("identity: remove passkey: %w", err)
351 333 }
@@ -399,10 +381,10 @@ func validateCredential(c Credential) error {
399 381 return nil
400 382 }
401 383
402 func insertCredential(tx *sql.Tx, accountID string, cred Credential, now time.Time) error {
384 func insertCredential(tx *sql.Tx, name string, cred Credential, now time.Time) error {
403 385 id := credentialIDString(cred.ID)
404 386 var existing string
405 err := tx.QueryRow(`SELECT user_id FROM user_credentials WHERE credential_id=?`, id).Scan(&existing)
387 err := tx.QueryRow(`SELECT user_name FROM user_credentials WHERE credential_id=?`, id).Scan(&existing)
406 388 if err == nil {
407 389 return errors.New("identity: that passkey is already registered")
408 390 }
@@ -418,10 +400,10 @@ func insertCredential(tx *sql.Tx, accountID string, cred Credential, now time.Ti
418 400 created = now
419 401 }
420 402 _, err = tx.Exec(`INSERT INTO user_credentials(
421 credential_id, user_id, public_key, attestation_type, transports, flags, aaguid,
403 credential_id, user_name, public_key, attestation_type, transports, flags, aaguid,
422 404 sign_count, clone_warning, attachment, label, created, last_used)
423 405 VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
424 id, accountID, cred.PublicKey, cred.AttestationType, string(transports),
406 id, name, cred.PublicKey, cred.AttestationType, string(transports),
425 407 flagsValue(cred.Flags), cred.Authenticator.AAGUID, int64(cred.Authenticator.SignCount),
426 408 boolValue(cred.Authenticator.CloneWarning), cred.Authenticator.Attachment,
427 409 strings.TrimSpace(cred.Label), created.Unix(), unixOrZero(cred.LastUsed))
@@ -471,8 +453,8 @@ func scanCredential(scan func(dest ...any) error) (Credential, error) {
471 453 return cred, nil
472 454 }
473 455
474 func (s *Store) credentialsFor(accountID string) ([]Credential, error) {
475 rows, err := s.db.Query(`SELECT `+credentialColumns+` FROM user_credentials WHERE user_id=? ORDER BY created, credential_id`, accountID)
456 func (s *Store) credentialsFor(name string) ([]Credential, error) {
457 rows, err := s.db.Query(`SELECT `+credentialColumns+` FROM user_credentials WHERE user_name=? ORDER BY created, credential_id`, name)
476 458 if err != nil {
477 459 return nil, fmt.Errorf("identity: read passkeys: %w", err)
478 460 }
@@ -496,11 +478,11 @@ func (s *Store) credentialByID(credentialID []byte) (*Credential, string, error)
496 478 if len(credentialID) == 0 {
497 479 return nil, "", ErrCredentialNotFound
498 480 }
499 var accountID string
481 var accountName string
500 482 var cred Credential
501 483 // Scan the credential columns and the owner in one row, so a credential
502 484 // cannot disappear between two queries.
503 row := s.db.QueryRow(`SELECT `+credentialColumns+`, user_id FROM user_credentials WHERE credential_id=?`,
485 row := s.db.QueryRow(`SELECT `+credentialColumns+`, user_name FROM user_credentials WHERE credential_id=?`,
504 486 credentialIDString(credentialID))
505 487 var (
506 488 id, transports, label string
@@ -511,7 +493,7 @@ func (s *Store) credentialByID(credentialID []byte) (*Credential, string, error)
511 493 created, last int64
512 494 )
513 495 err := row.Scan(&id, &cred.PublicKey, &cred.AttestationType, &transports, &flags, &aaguid,
514 &signCount, &cloneWarning, &cred.Authenticator.Attachment, &label, &created, &last, &accountID)
496 &signCount, &cloneWarning, &cred.Authenticator.Attachment, &label, &created, &last, &accountName)
515 497 if errors.Is(err, sql.ErrNoRows) {
516 498 return nil, "", ErrCredentialNotFound
517 499 }
@@ -537,7 +519,7 @@ func (s *Store) credentialByID(credentialID []byte) (*Credential, string, error)
537 519 if last > 0 {
538 520 cred.LastUsed = time.Unix(last, 0).UTC()
539 521 }
540 return &cred, accountID, nil
522 return &cred, accountName, nil
541 523 }
542 524
543 525 // CredentialIDString renders a credential id the way the CLI and URLs carry it.