internal/store/users.go
657 lines · 20217 bytes
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7 "strings"
8 "time"
9)
10
11type User struct {
12 ID int64
13 Username string
14 IsAdmin bool
15 Pending bool // self-registered, email not yet verified
16 Disabled bool // administratively suspended
17 // SignedInAt is when the browser session this user came from was
18 // created by a login. Set by WebSessionUser only; zero elsewhere.
19 SignedInAt time.Time
20}
21
22type SSHKey struct {
23 ID int64
24 UserID int64
25 Fingerprint string
26 Algo string
27 Blob []byte
28 Scope string
29 Label string // "" when the key was added with no name
30 CreatedAt string
31 LastUsedAt string // "" when the key has never authenticated
32 CreatedBy string // name of the API token that added the key; "" for none. ListSSHKeys only.
33 ExpiresAt *time.Time // nil when the key never expires
34}
35
36// Expired reports whether the key has lapsed at now.
37func (k SSHKey) Expired(now time.Time) bool {
38 return k.ExpiresAt != nil && !k.ExpiresAt.After(now)
39}
40
41// ErrDuplicateKey carries the exact user-facing message from the spec. It
42// deliberately does not name the owning account (enumeration oracle).
43var ErrDuplicateKey = errors.New("that key is already registered to another account; remove it there first or use a different key")
44
45var ErrNotFound = errors.New("not found")
46
47func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) {
48 if taken, err := ownerNameTaken(s.DB, username); err != nil {
49 return 0, err
50 } else if taken {
51 return 0, fmt.Errorf("username %q is taken", username)
52 }
53 res, err := s.DB.Exec("INSERT INTO users (username, is_admin) VALUES (?, ?)", username, boolInt(isAdmin))
54 if err != nil {
55 if isUniqueErr(err) {
56 return 0, fmt.Errorf("username %q is taken", username)
57 }
58 return 0, err
59 }
60 return res.LastInsertId()
61}
62
63// DeleteUser removes an account whose removal orphans nothing: no owned
64// repositories, no authored issues, MRs, comments, or reviews, and not the
65// only admin of an org. Everything else (keys, emails, sessions, tokens,
66// pins, memberships, activity) cascades. Blockers come back as an error
67// naming what stands in the way, so the operator can transfer, delete, or
68// disable instead.
69func (s *Store) DeleteUser(id int64) error {
70 var blockers []string
71 var checkErr error
72 count := func(q string, what string) {
73 var n int
74 if err := s.DB.QueryRow(q, id).Scan(&n); err != nil {
75 if checkErr == nil {
76 checkErr = fmt.Errorf("checking %s: %w", what, err)
77 }
78 return
79 }
80 if n > 0 {
81 blockers = append(blockers, fmt.Sprintf("%d %s", n, what))
82 }
83 }
84 count("SELECT COUNT(*) FROM repos WHERE owner_kind = 'user' AND owner_id = ?", "owned repositories")
85 count("SELECT COUNT(*) FROM issues WHERE author_id = ?", "authored issues")
86 count("SELECT COUNT(*) FROM merge_requests WHERE author_id = ?", "authored merge requests")
87 count("SELECT COUNT(*) FROM issue_comments WHERE author_id = ?", "issue comments")
88 count("SELECT COUNT(*) FROM mr_comments WHERE author_id = ?", "MR comments")
89 count("SELECT COUNT(*) FROM mr_diff_comments WHERE author_id = ?", "diff comments")
90 count("SELECT COUNT(*) FROM mr_reviews WHERE reviewer_id = ?", "reviews")
91 count(`SELECT COUNT(*) FROM org_members m WHERE m.user_id = ? AND m.role = 'admin'
92 AND NOT EXISTS (SELECT 1 FROM org_members o
93 WHERE o.org_id = m.org_id AND o.role = 'admin' AND o.user_id != m.user_id)`,
94 "organizations with no other admin")
95 if checkErr != nil {
96 return checkErr
97 }
98 if len(blockers) > 0 {
99 return fmt.Errorf("account still anchors: %s — transfer or delete those first, or disable the account instead",
100 strings.Join(blockers, ", "))
101 }
102 // Grants and a parked about text name the account by id with no
103 // foreign key, so they go in the same transaction (#306).
104 tx, err := s.DB.Begin()
105 if err != nil {
106 return err
107 }
108 defer tx.Rollback()
109 if _, err := tx.Exec("DELETE FROM repo_access WHERE subject_kind = 'user' AND subject_id = ?", id); err != nil {
110 return err
111 }
112 if _, err := tx.Exec("DELETE FROM profile_about_backfill WHERE owner_kind = 'user' AND owner_id = ?", id); err != nil {
113 return err
114 }
115 res, err := tx.Exec("DELETE FROM users WHERE id = ?", id)
116 if err != nil {
117 return err
118 }
119 if n, _ := res.RowsAffected(); n == 0 {
120 return ErrNotFound
121 }
122 if err := tx.Commit(); err != nil {
123 return err
124 }
125 s.announce(Revoked{UserID: id})
126 return nil
127}
128
129// OwnerExists reports whether a user or org owns the name — the ACME host
130// policy check for pages subdomains.
131func (s *Store) OwnerExists(name string) bool {
132 var n int
133 s.DB.QueryRow(`SELECT (SELECT COUNT(*) FROM users WHERE username = ?1)
134 + (SELECT COUNT(*) FROM orgs WHERE name = ?1)`, name).Scan(&n)
135 return n > 0
136}
137
138func (s *Store) UserByUsername(name string) (User, error) {
139 var u User
140 var admin, pending, disabled int
141 err := s.DB.QueryRow("SELECT id, username, is_admin, pending, disabled FROM users WHERE username = ?", name).
142 Scan(&u.ID, &u.Username, &admin, &pending, &disabled)
143 if errors.Is(err, sql.ErrNoRows) {
144 return u, ErrNotFound
145 }
146 u.IsAdmin = admin != 0
147 u.Pending = pending != 0
148 u.Disabled = disabled != 0
149 return u, err
150}
151
152// UserEmailAddresses returns every address on the account, verified or not.
153func (s *Store) UserEmailAddresses(userID int64) ([]string, error) {
154 rows, err := s.DB.Query("SELECT address FROM emails WHERE user_id = ? ORDER BY is_primary DESC, address", userID)
155 if err != nil {
156 return nil, err
157 }
158 defer rows.Close()
159 var out []string
160 for rows.Next() {
161 var a string
162 if err := rows.Scan(&a); err != nil {
163 return nil, err
164 }
165 out = append(out, a)
166 }
167 return out, rows.Err()
168}
169
170// Email is one address on an account, with the state the signature rules
171// and notification routing depend on.
172type Email struct {
173 Address string
174 Verified bool
175 VerifiedBy string // smtp | admin, empty when unverified
176 Primary bool
177}
178
179// ListEmails returns every address on the account with its state.
180func (s *Store) ListEmails(userID int64) ([]Email, error) {
181 rows, err := s.DB.Query(`SELECT address, verified_at IS NOT NULL,
182 COALESCE(verified_by, ''), is_primary
183 FROM emails WHERE user_id = ? ORDER BY is_primary DESC, address`, userID)
184 if err != nil {
185 return nil, err
186 }
187 defer rows.Close()
188 var out []Email
189 for rows.Next() {
190 var e Email
191 if err := rows.Scan(&e.Address, &e.Verified, &e.VerifiedBy, &e.Primary); err != nil {
192 return nil, err
193 }
194 out = append(out, e)
195 }
196 return out, rows.Err()
197}
198
199// SetUserDisabled suspends or restores an account. Disabling drops every
200// credential that would grant a session on its own — web sessions, API
201// tokens, unclaimed login links — and leaves the SSH keys registered but
202// refused at every entry point until re-enabled; connections they opened
203// are closed.
204func (s *Store) SetUserDisabled(userID int64, disabled bool) error {
205 v := 0
206 if disabled {
207 v = 1
208 }
209 res, err := s.DB.Exec("UPDATE users SET disabled = ? WHERE id = ?", v, userID)
210 if err != nil {
211 return err
212 }
213 if n, _ := res.RowsAffected(); n == 0 {
214 return ErrNotFound
215 }
216 if disabled {
217 // A pending login link is a session in waiting, so it goes with
218 // the sessions and API tokens. Re-enabling means minting again.
219 for _, table := range []string{"web_sessions", "api_tokens", "login_tokens"} {
220 if _, err = s.DB.Exec("DELETE FROM "+table+" WHERE user_id = ?", userID); err != nil {
221 return err
222 }
223 }
224 s.announce(Revoked{UserID: userID})
225 }
226 return err
227}
228
229// MailEnabled reports whether activity notifications reach the account
230// by mail as well as the inbox.
231func (s *Store) MailEnabled(userID int64) (bool, error) {
232 var on int
233 err := s.DB.QueryRow("SELECT notify_mail FROM users WHERE id = ?", userID).Scan(&on)
234 if errors.Is(err, sql.ErrNoRows) {
235 return false, ErrNotFound
236 }
237 return on != 0, err
238}
239
240func (s *Store) SetMailEnabled(userID int64, on bool) error {
241 v := 0
242 if on {
243 v = 1
244 }
245 _, err := s.DB.Exec("UPDATE users SET notify_mail = ? WHERE id = ?", v, userID)
246 return err
247}
248
249// ReplyEnabled reports whether the account's issue and merge request
250// mail carries a reply address (#295).
251func (s *Store) ReplyEnabled(userID int64) (bool, error) {
252 var on int
253 err := s.DB.QueryRow("SELECT notify_reply FROM users WHERE id = ?", userID).Scan(&on)
254 if errors.Is(err, sql.ErrNoRows) {
255 return false, ErrNotFound
256 }
257 return on != 0, err
258}
259
260func (s *Store) SetReplyEnabled(userID int64, on bool) error {
261 v := 0
262 if on {
263 v = 1
264 }
265 _, err := s.DB.Exec("UPDATE users SET notify_reply = ? WHERE id = ?", v, userID)
266 return err
267}
268
269// WatchEnabled reports whether the account hears about every issue and
270// merge request on the repositories it can write to, without a
271// repo_watchers row on each (#194).
272func (s *Store) WatchEnabled(userID int64) (bool, error) {
273 var on int
274 err := s.DB.QueryRow("SELECT notify_watch FROM users WHERE id = ?", userID).Scan(&on)
275 if errors.Is(err, sql.ErrNoRows) {
276 return false, ErrNotFound
277 }
278 return on != 0, err
279}
280
281func (s *Store) SetWatchEnabled(userID int64, on bool) error {
282 v := 0
283 if on {
284 v = 1
285 }
286 _, err := s.DB.Exec("UPDATE users SET notify_watch = ? WHERE id = ?", v, userID)
287 return err
288}
289
290// Theme is the web colour scheme the account chose: system, light or
291// dark (#232).
292func (s *Store) Theme(userID int64) (string, error) {
293 var theme string
294 err := s.DB.QueryRow("SELECT theme FROM users WHERE id = ?", userID).Scan(&theme)
295 if errors.Is(err, sql.ErrNoRows) {
296 return "", ErrNotFound
297 }
298 return theme, err
299}
300
301func (s *Store) SetTheme(userID int64, theme string) error {
302 _, err := s.DB.Exec("UPDATE users SET theme = ? WHERE id = ?", theme, userID)
303 return err
304}
305
306// DiffLayout is how the account wants diffs drawn on the web: unified or
307// split (#290).
308func (s *Store) DiffLayout(userID int64) (string, error) {
309 var l string
310 err := s.DB.QueryRow("SELECT diff_layout FROM users WHERE id = ?", userID).Scan(&l)
311 if errors.Is(err, sql.ErrNoRows) {
312 return "", ErrNotFound
313 }
314 return l, err
315}
316
317func (s *Store) SetDiffLayout(userID int64, layout string) error {
318 _, err := s.DB.Exec("UPDATE users SET diff_layout = ? WHERE id = ?", layout, userID)
319 return err
320}
321
322func (s *Store) UserByID(id int64) (User, error) {
323 var u User
324 var admin, pending, disabled int
325 err := s.DB.QueryRow("SELECT id, username, is_admin, pending, disabled FROM users WHERE id = ?", id).
326 Scan(&u.ID, &u.Username, &admin, &pending, &disabled)
327 if errors.Is(err, sql.ErrNoRows) {
328 return u, ErrNotFound
329 }
330 u.IsAdmin = admin != 0
331 u.Pending = pending != 0
332 u.Disabled = disabled != 0
333 return u, err
334}
335
336// KeyOrigin is how a key came to be.
337type KeyOrigin struct {
338 CreatedByToken int64 // the API token that added it; 0 for none
339 ExpiresAt *time.Time // when it stops authenticating; nil for never
340}
341
342// AddSSHKey registers a key and bumps the key epoch in one transaction.
343func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope, label string) error {
344 return s.AddSSHKeyFrom(userID, fingerprint, algo, blob, scope, label, KeyOrigin{})
345}
346
347// AddSSHKeyFrom is AddSSHKey recording where the key came from.
348func (s *Store) AddSSHKeyFrom(userID int64, fingerprint, algo string, blob []byte, scope, label string, o KeyOrigin) error {
349 tx, err := s.DB.Begin()
350 if err != nil {
351 return err
352 }
353 defer tx.Rollback()
354 var exp any
355 if o.ExpiresAt != nil {
356 exp = fmtTime(*o.ExpiresAt)
357 }
358 if _, err := tx.Exec(
359 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
360 userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken), exp); err != nil {
361 if isUniqueErr(err) {
362 return ErrDuplicateKey
363 }
364 return err
365 }
366 if err := bumpKeyEpoch(tx); err != nil {
367 return err
368 }
369 return tx.Commit()
370}
371
372// RemoveSSHKey removes a key owned by userID, bumps the key epoch, and
373// announces the revocation.
374func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error {
375 tx, err := s.DB.Begin()
376 if err != nil {
377 return err
378 }
379 defer tx.Rollback()
380 var id int64
381 err = tx.QueryRow("DELETE FROM ssh_keys WHERE user_id = ? AND fingerprint = ? RETURNING id", userID, fingerprint).Scan(&id)
382 if errors.Is(err, sql.ErrNoRows) {
383 return ErrNotFound
384 }
385 if err != nil {
386 return err
387 }
388 if err := bumpKeyEpoch(tx); err != nil {
389 return err
390 }
391 if err := tx.Commit(); err != nil {
392 return err
393 }
394 s.announce(Revoked{KeyIDs: []int64{id}})
395 return nil
396}
397
398// SetSSHKeyLabel renames a key owned by userID. Labels do not touch the
399// key epoch: nothing about authentication changes.
400func (s *Store) SetSSHKeyLabel(userID int64, fingerprint, label string) error {
401 res, err := s.DB.Exec("UPDATE ssh_keys SET label = ? WHERE user_id = ? AND fingerprint = ?", label, userID, fingerprint)
402 if err != nil {
403 return err
404 }
405 if n, _ := res.RowsAffected(); n == 0 {
406 return ErrNotFound
407 }
408 return nil
409}
410
411func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
412 var k SSHKey
413 var exp sql.NullString
414 err := s.DB.QueryRow(
415 "SELECT id, user_id, fingerprint, algo, blob, scope, label, expires_at FROM ssh_keys WHERE fingerprint = ?",
416 fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &exp)
417 if errors.Is(err, sql.ErrNoRows) {
418 return k, ErrNotFound
419 }
420 k.ExpiresAt = parseTime(exp)
421 return k, err
422}
423
424func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
425 rows, err := s.DB.Query(
426 `SELECT k.id, k.user_id, k.fingerprint, k.algo, k.blob, k.scope, k.label, k.created_at,
427 COALESCE(k.last_used_at, ''), COALESCE(t.name, ''), k.expires_at
428 FROM ssh_keys k LEFT JOIN api_tokens t ON t.id = k.created_by_token
429 WHERE k.user_id = ? ORDER BY k.id`,
430 userID)
431 if err != nil {
432 return nil, err
433 }
434 defer rows.Close()
435 var keys []SSHKey
436 for rows.Next() {
437 var k SSHKey
438 var exp sql.NullString
439 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.CreatedAt, &k.LastUsedAt, &k.CreatedBy, &exp); err != nil {
440 return nil, err
441 }
442 k.ExpiresAt = parseTime(exp)
443 keys = append(keys, k)
444 }
445 return keys, rows.Err()
446}
447
448// TouchSSHKey records key use; best-effort, callers ignore the error.
449func (s *Store) TouchSSHKey(id int64) error {
450 _, err := s.DB.Exec(
451 "UPDATE ssh_keys SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id)
452 return err
453}
454
455// AddEmail adds an address; verifiedBy is "" (unverified), "smtp", or "admin".
456// Adding an already-verified address bumps the key epoch: it is a trust input
457// for signature states.
458func (s *Store) AddEmail(userID int64, address, verifiedBy string, primary bool) error {
459 tx, err := s.DB.Begin()
460 if err != nil {
461 return err
462 }
463 defer tx.Rollback()
464 var vAt, vBy any
465 if verifiedBy != "" {
466 vAt = "now"
467 vBy = verifiedBy
468 }
469 _, err = tx.Exec(
470 `INSERT INTO emails (user_id, address, verified_at, verified_by, is_primary)
471 VALUES (?, ?, CASE WHEN ? IS NULL THEN NULL ELSE strftime('%Y-%m-%dT%H:%M:%fZ','now') END, ?, ?)`,
472 userID, address, vAt, vBy, boolInt(primary))
473 if isUniqueErr(err) {
474 return fmt.Errorf("address %q is already in use", address)
475 }
476 if err != nil {
477 return err
478 }
479 if verifiedBy != "" {
480 if err := bumpKeyEpoch(tx); err != nil {
481 return err
482 }
483 }
484 return tx.Commit()
485}
486
487var (
488 ErrPrimaryEmail = errors.New("that is the primary address; make another address primary first")
489 ErrLastVerifiedEmail = errors.New("that is the only verified address on the account; verify another first")
490 ErrUnverifiedEmail = errors.New("that address is not verified")
491)
492
493// RemoveEmail drops an address from the account, and any verification
494// code pending for it. The primary and the last verified address stay:
495// activation, login links and commit identity all resolve through
496// verified addresses. Removing a verified address bumps the key epoch,
497// since the signature cache keys on verified addresses too.
498func (s *Store) RemoveEmail(userID int64, address string) error {
499 tx, err := s.DB.Begin()
500 if err != nil {
501 return err
502 }
503 defer tx.Rollback()
504 var primary, verified bool
505 err = tx.QueryRow("SELECT is_primary, verified_at IS NOT NULL FROM emails WHERE user_id = ? AND address = ?",
506 userID, address).Scan(&primary, &verified)
507 if errors.Is(err, sql.ErrNoRows) {
508 return ErrNotFound
509 }
510 if err != nil {
511 return err
512 }
513 if primary {
514 return ErrPrimaryEmail
515 }
516 if verified {
517 var others int
518 if err := tx.QueryRow("SELECT count(*) FROM emails WHERE user_id = ? AND verified_at IS NOT NULL AND address != ?",
519 userID, address).Scan(&others); err != nil {
520 return err
521 }
522 if others == 0 {
523 return ErrLastVerifiedEmail
524 }
525 }
526 if _, err := tx.Exec("DELETE FROM email_tokens WHERE user_id = ? AND address = ?", userID, address); err != nil {
527 return err
528 }
529 if _, err := tx.Exec("DELETE FROM emails WHERE user_id = ? AND address = ?", userID, address); err != nil {
530 return err
531 }
532 if verified {
533 if err := bumpKeyEpoch(tx); err != nil {
534 return err
535 }
536 }
537 return tx.Commit()
538}
539
540// SetPrimaryEmail makes a verified address the account's primary. The
541// verified set is unchanged, so the key epoch is not.
542func (s *Store) SetPrimaryEmail(userID int64, address string) error {
543 tx, err := s.DB.Begin()
544 if err != nil {
545 return err
546 }
547 defer tx.Rollback()
548 var verified bool
549 err = tx.QueryRow("SELECT verified_at IS NOT NULL FROM emails WHERE user_id = ? AND address = ?",
550 userID, address).Scan(&verified)
551 if errors.Is(err, sql.ErrNoRows) {
552 return ErrNotFound
553 }
554 if err != nil {
555 return err
556 }
557 if !verified {
558 return ErrUnverifiedEmail
559 }
560 if _, err := tx.Exec("UPDATE emails SET is_primary = 0 WHERE user_id = ?", userID); err != nil {
561 return err
562 }
563 if _, err := tx.Exec("UPDATE emails SET is_primary = 1 WHERE user_id = ? AND address = ?", userID, address); err != nil {
564 return err
565 }
566 return tx.Commit()
567}
568
569func (s *Store) KeyEpoch() (int64, error) {
570 var v int64
571 err := s.DB.QueryRow("SELECT value FROM settings WHERE key = 'key_epoch'").Scan(&v)
572 return v, err
573}
574
575type execer interface {
576 Exec(query string, args ...any) (sql.Result, error)
577}
578
579func bumpKeyEpoch(tx execer) error {
580 _, err := tx.Exec("UPDATE settings SET value = value + 1 WHERE key = 'key_epoch'")
581 return err
582}
583
584func boolInt(b bool) int {
585 if b {
586 return 1
587 }
588 return 0
589}
590
591func isUniqueErr(err error) bool {
592 return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
593}
594
595func (s *Store) SSHKeyByID(id int64) (SSHKey, error) {
596 var k SSHKey
597 var exp sql.NullString
598 err := s.DB.QueryRow(
599 "SELECT id, user_id, fingerprint, algo, blob, scope, label, expires_at FROM ssh_keys WHERE id = ?",
600 id).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &exp)
601 if errors.Is(err, sql.ErrNoRows) {
602 return k, ErrNotFound
603 }
604 k.ExpiresAt = parseTime(exp)
605 return k, err
606}
607
608// ListDeployKeys returns the deploy keys bound to a repository.
609func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) {
610 rows, err := s.DB.Query(
611 `SELECT id, user_id, fingerprint, algo, blob, scope, label, COALESCE(last_used_at, ''), expires_at
612 FROM ssh_keys WHERE scope LIKE 'deploy:' || ? || ':%' ORDER BY id`,
613 repoID)
614 if err != nil {
615 return nil, err
616 }
617 defer rows.Close()
618 var keys []SSHKey
619 for rows.Next() {
620 var k SSHKey
621 var exp sql.NullString
622 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.LastUsedAt, &exp); err != nil {
623 return nil, err
624 }
625 k.ExpiresAt = parseTime(exp)
626 keys = append(keys, k)
627 }
628 return keys, rows.Err()
629}
630
631// RemoveDeployKey removes a deploy key from a repository by fingerprint;
632// any repo admin may remove it regardless of who added it.
633func (s *Store) RemoveDeployKey(repoID int64, fingerprint string) error {
634 tx, err := s.DB.Begin()
635 if err != nil {
636 return err
637 }
638 defer tx.Rollback()
639 var id int64
640 err = tx.QueryRow(
641 "DELETE FROM ssh_keys WHERE fingerprint = ? AND scope LIKE 'deploy:' || ? || ':%' RETURNING id",
642 fingerprint, repoID).Scan(&id)
643 if errors.Is(err, sql.ErrNoRows) {
644 return ErrNotFound
645 }
646 if err != nil {
647 return err
648 }
649 if err := bumpKeyEpoch(tx); err != nil {
650 return err
651 }
652 if err := tx.Commit(); err != nil {
653 return err
654 }
655 s.announce(Revoked{KeyIDs: []int64{id}})
656 return nil
657}