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. | |