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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

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

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 2h ago 4b8c9e1d
history blame raw 22.8 KiB
1package 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 account name 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
16import (
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 "rodent.software/vole/forge/access"
30)
31
32const (
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 // MaxCredentialLabel bounds the name a person gives a passkey. The label is
41 // shown on the account page and stored beside the credential, so it is a
42 // label, not prose.
43 MaxCredentialLabel = 128
44
45 // Credential fields come from the authenticator and a client, so each is
46 // bounded before it reaches a ceremony or the roster. A value that is not a
47 // passkey shape is refused rather than stored at whatever size the client
48 // chose.
49 maxCredentialTransports = 16
50 maxCredentialTransport = 32
51 maxCredentialAttachment = 32
52 maxCredentialAttestation = 64
53)
54
55var (
56 // ErrInviteInvalid covers missing, expired, used and superseded invites. It
57 // is deliberately one error so a page cannot be used to probe which links
58 // once existed.
59 ErrInviteInvalid = errors.New("identity: invite is invalid or expired")
60 // ErrCredentialNotFound covers unknown and retired-account credentials.
61 ErrCredentialNotFound = errors.New("identity: no such passkey")
62)
63
64// Credential is the public half of a passkey. The field names follow the
65// WebAuthn credential shape so the server's ceremony code can convert without a
66// second vocabulary.
67type Credential struct {
68 ID []byte
69 PublicKey []byte
70 AttestationType string
71 Transport []string
72 Flags CredentialFlags
73 Authenticator Authenticator
74 Label string
75 Created time.Time
76 LastUsed time.Time
77}
78
79type CredentialFlags struct {
80 UserPresent bool
81 UserVerified bool
82 BackupEligible bool
83 BackupState bool
84}
85
86type Authenticator struct {
87 AAGUID []byte
88 SignCount uint32
89 CloneWarning bool
90 Attachment string
91}
92
93// Invite is returned once, when it is created. The Token is the plaintext part
94// of the enrollment URL; the database only has its hash.
95type Invite struct {
96 Token string
97 Expires time.Time
98}
99
100// CreatePasskey creates a self-registered account and its first passkey in one
101// transaction. The name is fixed when the ceremony begins; the row is only
102// written when the authenticator has actually produced a credential, so an
103// abandoned registration reserves no name.
104func (s *Store) CreatePasskey(name string, cred Credential, actor string) (*Account, error) {
105 return s.CreatePasskeyWithRole(name, access.None, cred, actor)
106}
107
108// CreatePasskeyWithRole is self-registration with an instance-wide role. The
109// role is chosen by instance policy, never by the person registering.
110func (s *Store) CreatePasskeyWithRole(name string, role access.Role, cred Credential, actor string) (*Account, error) {
111 if err := ValidateName(name); err != nil {
112 return nil, err
113 }
114 if err := validateCredential(cred); err != nil {
115 return nil, err
116 }
117 now := time.Now().UTC()
118 err := s.mutate("identity.create", actor, name, name+" passkey role="+role.String(), func(tx *sql.Tx) error {
119 // A passkey is a browser credential, and a bot has no browser sign-in.
120 if err := createAccount(tx, name, KindPerson, role, now, nil, nil); err != nil {
121 return err
122 }
123 return insertCredential(tx, name, cred, now)
124 })
125 if err != nil {
126 return nil, err
127 }
128 return s.Find(name)
129}
130
131// CreateWithInvite creates an account with no SSH key and an enrollment invite
132// in the same transaction. This is how a browser-only account — somebody who
133// files issues but does not push — comes into existence.
134func (s *Store) CreateWithInvite(name, actor string, ttl time.Duration) (*Account, *Invite, error) {
135 return s.CreateWithInviteRole(name, access.None, actor, ttl)
136}
137
138// CreateWithInviteRole creates an account with an instance-wide role and an
139// enrollment invite in the same transaction. This is how an invited
140// contributor comes into existence without being added to every repository.
141func (s *Store) CreateWithInviteRole(name string, role access.Role, actor string, ttl time.Duration) (*Account, *Invite, error) {
142 if err := ValidateName(name); err != nil {
143 return nil, nil, err
144 }
145 now := time.Now().UTC()
146 token, tokenHash, err := newInviteToken()
147 if err != nil {
148 return nil, nil, err
149 }
150 inv := &Invite{Token: token, Expires: now.Add(inviteTTL(ttl))}
151 pending := &pendingInvite{tokenHash: tokenHash, expires: inv.Expires, createdBy: actor}
152 err = s.mutate("identity.create", actor, name, name+" invite role="+role.String(), func(tx *sql.Tx) error {
153 return createAccount(tx, name, KindPerson, role, now, nil, pending)
154 })
155 if err != nil {
156 return nil, nil, err
157 }
158 a, err := s.Find(name)
159 if err != nil {
160 return nil, nil, err
161 }
162 return a, inv, nil
163}
164
165// Invite issues a fresh enrollment link for an existing account and supersedes
166// any unused link that came before it.
167func (s *Store) Invite(name, actor string, ttl time.Duration) (*Invite, error) {
168 now := time.Now().UTC()
169 token, tokenHash, err := newInviteToken()
170 if err != nil {
171 return nil, err
172 }
173 inv := &Invite{Token: token, Expires: now.Add(inviteTTL(ttl))}
174 pending := &pendingInvite{tokenHash: tokenHash, expires: inv.Expires, createdBy: actor}
175 err = s.mutate("identity.invite", actor, name, "expires "+inv.Expires.Format(time.RFC3339), func(tx *sql.Tx) error {
176 a, err := findTx(tx, name)
177 if err != nil {
178 return err
179 }
180 if !a.Active() {
181 return fmt.Errorf("identity: %s is retired", a.Name)
182 }
183 // A bot has no browser sign-in, so an enrollment link would be a credential
184 // nobody can use and a secret in the roster for no reason.
185 if a.Bot() {
186 return fmt.Errorf("identity: %s is a bot account; it signs in with a key, not a browser", a.Name)
187 }
188 // One live link per account. Marking the old ones used rather than
189 // deleting them leaves an audit trail without leaving a usable secret.
190 if _, err := tx.Exec(`UPDATE user_invites SET used_at=? WHERE user_name=? AND used_at IS NULL`, now.Unix(), a.Name); err != nil {
191 return fmt.Errorf("identity: replace invite: %w", err)
192 }
193 return insertInvite(tx, a.Name, pending, now)
194 })
195 if err != nil {
196 return nil, err
197 }
198 return inv, nil
199}
200
201// InviteAccount validates an enrollment token without consuming it. The invite
202// page uses this to show whose account is being claimed; nothing is changed
203// until the passkey ceremony succeeds.
204func (s *Store) InviteAccount(token string) (*Account, error) {
205 token = strings.TrimSpace(token)
206 if token == "" {
207 return nil, ErrInviteInvalid
208 }
209 var a Account
210 var created int64
211 err := s.db.QueryRow(`
212SELECT u.name, u.status, u.created
213FROM user_invites i JOIN users u ON u.name = i.user_name
214WHERE i.token_hash = ? AND i.used_at IS NULL AND i.expires > ? AND u.status = 'active'`,
215 hashToken(token), time.Now().UTC().Unix()).
216 Scan(&a.Name, &a.Status, &created)
217 if errors.Is(err, sql.ErrNoRows) {
218 return nil, ErrInviteInvalid
219 }
220 if err != nil {
221 return nil, fmt.Errorf("identity: read invite: %w", err)
222 }
223 a.Created = time.Unix(created, 0).UTC()
224 return &a, nil
225}
226
227// AcceptInvite consumes an enrollment token and attaches the first passkey. The
228// conditional update and the credential insert are one transaction, so two
229// browsers racing on the same link cannot both claim the account.
230func (s *Store) AcceptInvite(token string, cred Credential, actor string) (*Account, error) {
231 if err := validateCredential(cred); err != nil {
232 return nil, err
233 }
234 // Resolve once for the audit line; the transaction checks everything again.
235 account, err := s.InviteAccount(token)
236 if err != nil {
237 return nil, err
238 }
239 if account.Bot() {
240 // No link should exist for a bot; refuse rather than spend one if one does.
241 return nil, fmt.Errorf("identity: %s is a bot account; it signs in with a key, not a browser", account.Name)
242 }
243 token = strings.TrimSpace(token)
244 now := time.Now().UTC()
245 accountName := account.Name
246 err = s.mutate("identity.credential-add", actor, accountName, "invite", func(tx *sql.Tx) error {
247 var userName string
248 var expires int64
249 var used sql.NullInt64
250 err := tx.QueryRow(`
251SELECT i.user_name, i.expires, i.used_at
252FROM user_invites i JOIN users u ON u.name = i.user_name
253WHERE i.token_hash = ? AND u.status = 'active'`, hashToken(token)).
254 Scan(&userName, &expires, &used)
255 if errors.Is(err, sql.ErrNoRows) {
256 return ErrInviteInvalid
257 }
258 if err != nil {
259 return fmt.Errorf("identity: read invite: %w", err)
260 }
261 if used.Valid || expires <= now.Unix() {
262 return ErrInviteInvalid
263 }
264 res, err := tx.Exec(`UPDATE user_invites SET used_at=? WHERE token_hash=? AND used_at IS NULL`, now.Unix(), hashToken(token))
265 if err != nil {
266 return fmt.Errorf("identity: consume invite: %w", err)
267 }
268 n, err := res.RowsAffected()
269 if err != nil {
270 return err
271 }
272 if n != 1 {
273 return ErrInviteInvalid
274 }
275 // A second live link for the same account must not survive the first
276 // successful enrollment.
277 if _, err := tx.Exec(`UPDATE user_invites SET used_at=? WHERE user_name=? AND used_at IS NULL`, now.Unix(), userName); err != nil {
278 return fmt.Errorf("identity: close sibling invites: %w", err)
279 }
280 if err := insertCredential(tx, userName, cred, now); err != nil {
281 return err
282 }
283 accountName = userName
284 return nil
285 })
286 if err != nil {
287 return nil, err
288 }
289 return s.Find(accountName)
290}
291
292// AddCredential attaches another passkey to an account that is already logged
293// in or already has an SSH key.
294func (s *Store) AddCredential(name string, cred Credential, actor string) error {
295 if err := validateCredential(cred); err != nil {
296 return err
297 }
298 now := time.Now().UTC()
299 return s.mutate("identity.credential-add", actor, name, strings.TrimSpace(cred.Label), func(tx *sql.Tx) error {
300 a, err := findTx(tx, name)
301 if err != nil {
302 return err
303 }
304 if a.Bot() {
305 return fmt.Errorf("identity: %s is a bot account; it has no browser sign-in", a.Name)
306 }
307 if !a.Active() {
308 return fmt.Errorf("identity: %s is retired", a.Name)
309 }
310 return insertCredential(tx, a.Name, cred, now)
311 })
312}
313
314// Credentials lists an account's passkeys. Retired accounts are included so the
315// operator can still see what was registered.
316func (s *Store) Credentials(name string) ([]Credential, error) {
317 a, err := s.Find(name)
318 if err != nil {
319 return nil, err
320 }
321 return s.credentialsFor(a.Name)
322}
323
324// FindCredential resolves a credential id to its account. A retired account's
325// credential behaves as if it no longer exists.
326func (s *Store) FindCredential(credentialID []byte) (*Credential, *Account, error) {
327 cred, accountName, err := s.credentialByID(credentialID)
328 if err != nil {
329 return nil, nil, err
330 }
331 a, err := s.Find(accountName)
332 if err != nil || !a.Active() {
333 return nil, nil, ErrCredentialNotFound
334 }
335 return cred, a, nil
336}
337
338// UpdateCredential records an assertion counter and the time the key was last
339// used. Counter policy belongs to the ceremony: this layer stores what it is
340// given so an authenticator that always reports zero is not locked out.
341func (s *Store) UpdateCredential(credentialID []byte, signCount uint32, lastUsed time.Time) error {
342 if len(credentialID) == 0 {
343 return ErrCredentialNotFound
344 }
345 res, err := s.db.Exec(`UPDATE user_credentials SET sign_count=?, last_used=? WHERE credential_id=?`,
346 int64(signCount), unixOrZero(lastUsed), credentialIDString(credentialID))
347 if err != nil {
348 return fmt.Errorf("identity: update passkey: %w", err)
349 }
350 n, err := res.RowsAffected()
351 if err != nil {
352 return err
353 }
354 if n == 0 {
355 return ErrCredentialNotFound
356 }
357 return nil
358}
359
360// RemoveCredential revokes one passkey. Sessions created by that passkey are
361// removed by the foreign key in sessions.
362func (s *Store) RemoveCredential(name string, credentialID []byte, actor string) error {
363 if len(credentialID) == 0 {
364 return ErrCredentialNotFound
365 }
366 return s.mutate("identity.credential-remove", actor, name, credentialIDString(credentialID), func(tx *sql.Tx) error {
367 a, err := findTx(tx, name)
368 if err != nil {
369 return err
370 }
371 // The sessions table has a cascading foreign key, and this says the same thing
372 // on purpose: a passkey that is gone cannot keep a browser session alive,
373 // including the one the person is using at this moment.
374 if _, err := tx.Exec(`DELETE FROM sessions WHERE credential_id=?`,
375 credentialIDString(credentialID)); err != nil {
376 return fmt.Errorf("identity: end the sessions of a removed passkey: %w", err)
377 }
378 res, err := tx.Exec(`DELETE FROM user_credentials WHERE credential_id=? AND user_name=?`,
379 credentialIDString(credentialID), a.Name)
380 if err != nil {
381 return fmt.Errorf("identity: remove passkey: %w", err)
382 }
383 n, err := res.RowsAffected()
384 if err != nil {
385 return err
386 }
387 if n == 0 {
388 return ErrCredentialNotFound
389 }
390 return nil
391 })
392}
393
394// RenameCredential changes the label on one of an account's passkeys. A label is
395// display text, not identity: the credential id is what authenticates, so a rename
396// cannot affect signing in, and an empty label is allowed and means "no label".
397func (s *Store) RenameCredential(name string, credentialID []byte, label, actor string) error {
398 if len(credentialID) == 0 {
399 return ErrCredentialNotFound
400 }
401 label = strings.TrimSpace(label)
402 if err := ValidateCredentialLabel(label); err != nil {
403 return err
404 }
405 return s.mutate("identity.credential-rename", actor, name,
406 credentialIDString(credentialID)+" "+label, func(tx *sql.Tx) error {
407 a, err := findTx(tx, name)
408 if err != nil {
409 return err
410 }
411 res, err := tx.Exec(`UPDATE user_credentials SET label=? WHERE credential_id=? AND user_name=?`,
412 label, credentialIDString(credentialID), a.Name)
413 if err != nil {
414 return fmt.Errorf("identity: rename passkey: %w", err)
415 }
416 n, err := res.RowsAffected()
417 if err != nil {
418 return err
419 }
420 if n == 0 {
421 return ErrCredentialNotFound
422 }
423 return nil
424 })
425}
426
427// ---------- internal ----------
428
429func inviteTTL(ttl time.Duration) time.Duration {
430 if ttl <= 0 {
431 return DefaultInviteTTL
432 }
433 return ttl
434}
435
436func newInviteToken() (token, hash string, err error) {
437 b := make([]byte, inviteTokenBytes)
438 if _, err := rand.Read(b); err != nil {
439 return "", "", fmt.Errorf("identity: invite token: %w", err)
440 }
441 token = base64.RawURLEncoding.EncodeToString(b)
442 return token, hashToken(token), nil
443}
444
445func hashToken(token string) string {
446 sum := sha256.Sum256([]byte(token))
447 return hex.EncodeToString(sum[:])
448}
449
450func validateCredential(c Credential) error {
451 if err := ValidateCredentialLabel(c.Label); err != nil {
452 return err
453 }
454 switch {
455 case len(c.ID) == 0:
456 return errors.New("identity: passkey id is empty")
457 case len(c.ID) > 2048:
458 return errors.New("identity: passkey id is too long")
459 case len(c.PublicKey) == 0:
460 return errors.New("identity: passkey public key is empty")
461 case len(c.PublicKey) > 8192:
462 return errors.New("identity: passkey public key is too long")
463 case len(c.AttestationType) > maxCredentialAttestation:
464 return fmt.Errorf("identity: passkey attestation type is too long")
465 case len(c.Transport) > maxCredentialTransports:
466 return fmt.Errorf("identity: passkey has %d transports, limit %d", len(c.Transport), maxCredentialTransports)
467 case len(c.Authenticator.Attachment) > maxCredentialAttachment:
468 return fmt.Errorf("identity: passkey attachment is too long")
469 case len(c.Authenticator.AAGUID) > 256:
470 return errors.New("identity: passkey AAGUID is too long")
471 }
472 for _, transport := range c.Transport {
473 if len(transport) > maxCredentialTransport {
474 return fmt.Errorf("identity: passkey transport is %d bytes, limit %d", len(transport), maxCredentialTransport)
475 }
476 }
477 return nil
478}
479
480// ValidateCredentialLabel checks the label before a ceremony stores it. A label
481// is display text, not prose, so it is capped where it is accepted rather than
482// only when the finished credential is written.
483func ValidateCredentialLabel(label string) error {
484 label = strings.TrimSpace(label)
485 if len(label) > MaxCredentialLabel {
486 return fmt.Errorf("identity: passkey label is %d bytes, limit %d", len(label), MaxCredentialLabel)
487 }
488 return nil
489}
490
491func insertCredential(tx *sql.Tx, name string, cred Credential, now time.Time) error {
492 id := credentialIDString(cred.ID)
493 var existing string
494 err := tx.QueryRow(`SELECT user_name FROM user_credentials WHERE credential_id=?`, id).Scan(&existing)
495 if err == nil {
496 return errors.New("identity: that passkey is already registered")
497 }
498 if !errors.Is(err, sql.ErrNoRows) {
499 return fmt.Errorf("identity: read passkey: %w", err)
500 }
501 transports, err := json.Marshal(cred.Transport)
502 if err != nil {
503 return fmt.Errorf("identity: passkey transports: %w", err)
504 }
505 created := cred.Created
506 if created.IsZero() {
507 created = now
508 }
509 _, err = tx.Exec(`INSERT INTO user_credentials(
510credential_id, user_name, public_key, attestation_type, transports, flags, aaguid,
511sign_count, clone_warning, attachment, label, created, last_used)
512VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
513 id, name, cred.PublicKey, cred.AttestationType, string(transports),
514 flagsValue(cred.Flags), cred.Authenticator.AAGUID, int64(cred.Authenticator.SignCount),
515 boolValue(cred.Authenticator.CloneWarning), cred.Authenticator.Attachment,
516 strings.TrimSpace(cred.Label), created.Unix(), unixOrZero(cred.LastUsed))
517 if err != nil {
518 return fmt.Errorf("identity: add passkey: %w", err)
519 }
520 return nil
521}
522
523const credentialColumns = `credential_id, public_key, attestation_type, transports, flags, aaguid,
524sign_count, clone_warning, attachment, label, created, last_used`
525
526func scanCredential(scan func(dest ...any) error) (Credential, error) {
527 var (
528 cred Credential
529 id, transports string
530 flags int64
531 aaguid []byte
532 signCount int64
533 cloneWarning int64
534 created, last int64
535 credentialLabel string
536 )
537 if err := scan(&id, &cred.PublicKey, &cred.AttestationType, &transports, &flags, &aaguid,
538 &signCount, &cloneWarning, &cred.Authenticator.Attachment, &credentialLabel, &created, &last); err != nil {
539 return Credential{}, err
540 }
541 rawID, err := ParseCredentialID(id)
542 if err != nil {
543 return Credential{}, fmt.Errorf("identity: stored passkey id: %w", err)
544 }
545 if transports != "" {
546 if err := json.Unmarshal([]byte(transports), &cred.Transport); err != nil {
547 return Credential{}, fmt.Errorf("identity: stored passkey transports: %w", err)
548 }
549 }
550 cred.ID = rawID
551 cred.Flags = flagsFrom(flags)
552 cred.Authenticator.AAGUID = aaguid
553 cred.Authenticator.SignCount = uint32(signCount)
554 cred.Authenticator.CloneWarning = cloneWarning != 0
555 cred.Label = credentialLabel
556 cred.Created = time.Unix(created, 0).UTC()
557 if last > 0 {
558 cred.LastUsed = time.Unix(last, 0).UTC()
559 }
560 return cred, nil
561}
562
563func (s *Store) credentialsFor(name string) ([]Credential, error) {
564 rows, err := s.db.Query(`SELECT `+credentialColumns+` FROM user_credentials WHERE user_name=? ORDER BY created, credential_id`, name)
565 if err != nil {
566 return nil, fmt.Errorf("identity: read passkeys: %w", err)
567 }
568 defer rows.Close()
569 var out []Credential
570 for rows.Next() {
571 cred, err := scanCredential(rows.Scan)
572 if err != nil {
573 return nil, fmt.Errorf("identity: read passkeys: %w", err)
574 }
575 out = append(out, cred)
576 }
577 if err := rows.Err(); err != nil {
578 return nil, err
579 }
580 sort.Slice(out, func(i, j int) bool { return out[i].Created.Before(out[j].Created) })
581 return out, nil
582}
583
584func (s *Store) credentialByID(credentialID []byte) (*Credential, string, error) {
585 if len(credentialID) == 0 {
586 return nil, "", ErrCredentialNotFound
587 }
588 var accountName string
589 var cred Credential
590 // Scan the credential columns and the owner in one row, so a credential
591 // cannot disappear between two queries.
592 row := s.db.QueryRow(`SELECT `+credentialColumns+`, user_name FROM user_credentials WHERE credential_id=?`,
593 credentialIDString(credentialID))
594 var (
595 id, transports, label string
596 flags int64
597 aaguid []byte
598 signCount int64
599 cloneWarning int64
600 created, last int64
601 )
602 err := row.Scan(&id, &cred.PublicKey, &cred.AttestationType, &transports, &flags, &aaguid,
603 &signCount, &cloneWarning, &cred.Authenticator.Attachment, &label, &created, &last, &accountName)
604 if errors.Is(err, sql.ErrNoRows) {
605 return nil, "", ErrCredentialNotFound
606 }
607 if err != nil {
608 return nil, "", fmt.Errorf("identity: read passkey: %w", err)
609 }
610 rawID, err := ParseCredentialID(id)
611 if err != nil {
612 return nil, "", fmt.Errorf("identity: stored passkey id: %w", err)
613 }
614 if transports != "" {
615 if err := json.Unmarshal([]byte(transports), &cred.Transport); err != nil {
616 return nil, "", fmt.Errorf("identity: stored passkey transports: %w", err)
617 }
618 }
619 cred.ID = rawID
620 cred.Flags = flagsFrom(flags)
621 cred.Authenticator.AAGUID = aaguid
622 cred.Authenticator.SignCount = uint32(signCount)
623 cred.Authenticator.CloneWarning = cloneWarning != 0
624 cred.Label = label
625 cred.Created = time.Unix(created, 0).UTC()
626 if last > 0 {
627 cred.LastUsed = time.Unix(last, 0).UTC()
628 }
629 return &cred, accountName, nil
630}
631
632// CredentialIDString renders a credential id the way the CLI and URLs carry it.
633func CredentialIDString(id []byte) string { return credentialIDString(id) }
634
635func credentialIDString(id []byte) string {
636 return base64.RawURLEncoding.EncodeToString(id)
637}
638
639// ParseCredentialID accepts the unpadded base64url form Vole prints, and the
640// padded form people paste from other tools.
641func ParseCredentialID(s string) ([]byte, error) {
642 s = strings.TrimSpace(s)
643 if s == "" {
644 return nil, errors.New("empty credential id")
645 }
646 if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
647 return b, nil
648 }
649 b, err := base64.URLEncoding.DecodeString(s)
650 if err != nil {
651 return nil, errors.New("credential id is not base64url")
652 }
653 return b, nil
654}
655
656const (
657 flagUserPresent = 1 << iota
658 flagUserVerified
659 flagBackupEligible
660 flagBackupState
661)
662
663func flagsValue(f CredentialFlags) int64 {
664 var v int64
665 if f.UserPresent {
666 v |= flagUserPresent
667 }
668 if f.UserVerified {
669 v |= flagUserVerified
670 }
671 if f.BackupEligible {
672 v |= flagBackupEligible
673 }
674 if f.BackupState {
675 v |= flagBackupState
676 }
677 return v
678}
679
680func flagsFrom(v int64) CredentialFlags {
681 return CredentialFlags{
682 UserPresent: v&flagUserPresent != 0,
683 UserVerified: v&flagUserVerified != 0,
684 BackupEligible: v&flagBackupEligible != 0,
685 BackupState: v&flagBackupState != 0,
686 }
687}
688
689func boolValue(b bool) int64 {
690 if b {
691 return 1
692 }
693 return 0
694}
695
696func unixOrZero(t time.Time) int64 {
697 if t.IsZero() {
698 return 0
699 }
700 return t.Unix()
701}
702