Commit 4b94fa96a1
Verified · cmc
Layout: unified · split
internal/store/migrations/0060_credential_origin.down.sql added +2
| @@ -0,0 +1,2 @@ | |||
| 1 | ALTER TABLE ssh_keys DROP COLUMN created_by_token; | ||
| 2 | ALTER TABLE api_tokens DROP COLUMN created_by_token; | ||
internal/store/migrations/0060_credential_origin.up.sql added +4
| @@ -0,0 +1,4 @@ | |||
| 1 | -- The API token a credential was created through. NULL when it was not, | ||
| 2 | -- and once that token is revoked. | ||
| 3 | ALTER TABLE api_tokens ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL; | ||
| 4 | ALTER TABLE ssh_keys ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL; | ||
internal/store/tokens.go +117 −22
| @@ -4,55 +4,71 @@ import ( | |||
| 4 | "database/sql" | 4 | "database/sql" |
| 5 | "errors" | 5 | "errors" |
| 6 | "fmt" | 6 | "fmt" |
| 7 | "strings" | ||
| 7 | "time" | 8 | "time" |
| 8 | ) | 9 | ) |
| 9 | 10 | ||
| 10 | type APIToken struct { | 11 | type APIToken struct { |
| 12 | ID int64 | ||
| 11 | Name string | 13 | Name string |
| 12 | Scope string | 14 | Scope string |
| 13 | CreatedAt string | 15 | CreatedAt string |
| 14 | ExpiresAt *time.Time | 16 | ExpiresAt *time.Time |
| 15 | LastUsedAt *time.Time | 17 | LastUsedAt *time.Time |
| 18 | CreatedBy string // name of the token that created this one; "" for none | ||
| 16 | } | 19 | } |
| 17 | 20 | ||
| 18 | // CreateAPIToken stores a token hash; expires nil means no expiry. | 21 | // nullID stores 0 as NULL. |
| 19 | func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time) error { | 22 | func nullID(id int64) any { |
| 23 | if id == 0 { | ||
| 24 | return nil | ||
| 25 | } | ||
| 26 | return id | ||
| 27 | } | ||
| 28 | |||
| 29 | // CreateAPIToken stores a token hash; expires nil means no expiry, | ||
| 30 | // createdByToken 0 means it was not created through a token. | ||
| 31 | func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time, createdByToken int64) error { | ||
| 20 | var exp any | 32 | var exp any |
| 21 | if expires != nil { | 33 | if expires != nil { |
| 22 | exp = fmtTime(*expires) | 34 | exp = fmtTime(*expires) |
| 23 | } | 35 | } |
| 24 | _, err := s.DB.Exec( | 36 | _, err := s.DB.Exec( |
| 25 | "INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at) VALUES (?, ?, ?, ?, ?)", | 37 | "INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at, created_by_token) VALUES (?, ?, ?, ?, ?, ?)", |
| 26 | userID, name, tokenHash, scope, exp) | 38 | userID, name, tokenHash, scope, exp, nullID(createdByToken)) |
| 27 | if isUniqueErr(err) { | 39 | if isUniqueErr(err) { |
| 28 | return fmt.Errorf("you already have a token named %q", name) | 40 | return fmt.Errorf("you already have a token named %q", name) |
| 29 | } | 41 | } |
| 30 | return err | 42 | return err |
| 31 | } | 43 | } |
| 32 | 44 | ||
| 33 | // APITokenUser resolves a presented token to its user and scope; expired and | 45 | // APITokenUser resolves a presented token to its user and the token; |
| 34 | // unknown tokens fail identically. | 46 | // expired and unknown tokens fail identically. |
| 35 | func (s *Store) APITokenUser(tokenHash string) (User, string, error) { | 47 | func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error) { |
| 36 | var userID int64 | 48 | var userID int64 |
| 37 | var scope string | 49 | var t APIToken |
| 50 | var exp sql.NullString | ||
| 38 | err := s.DB.QueryRow(` | 51 | err := s.DB.QueryRow(` |
| 39 | SELECT user_id, scope FROM api_tokens | 52 | SELECT user_id, id, name, scope, expires_at FROM api_tokens |
| 40 | WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)`, | 53 | WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)`, |
| 41 | tokenHash, fmtTime(time.Now())).Scan(&userID, &scope) | 54 | tokenHash, fmtTime(time.Now())).Scan(&userID, &t.ID, &t.Name, &t.Scope, &exp) |
| 42 | if errors.Is(err, sql.ErrNoRows) { | 55 | if errors.Is(err, sql.ErrNoRows) { |
| 43 | return User{}, "", ErrNotFound | 56 | return User{}, APIToken{}, ErrNotFound |
| 44 | } | 57 | } |
| 45 | if err != nil { | 58 | if err != nil { |
| 46 | return User{}, "", err | 59 | return User{}, APIToken{}, err |
| 47 | } | 60 | } |
| 61 | t.ExpiresAt = parseTime(exp) | ||
| 48 | s.DB.Exec("UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token_hash = ?", tokenHash) | 62 | s.DB.Exec("UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token_hash = ?", tokenHash) |
| 49 | u, err := s.UserByID(userID) | 63 | u, err := s.UserByID(userID) |
| 50 | return u, scope, err | 64 | return u, t, err |
| 51 | } | 65 | } |
| 52 | 66 | ||
| 53 | func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) { | 67 | func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) { |
| 54 | rows, err := s.DB.Query( | 68 | rows, err := s.DB.Query(` |
| 55 | "SELECT name, scope, created_at, expires_at, last_used_at FROM api_tokens WHERE user_id = ? ORDER BY name", userID) | 69 | SELECT t.id, t.name, t.scope, t.created_at, t.expires_at, t.last_used_at, COALESCE(p.name, '') |
| 70 | FROM api_tokens t LEFT JOIN api_tokens p ON p.id = t.created_by_token | ||
| 71 | WHERE t.user_id = ? ORDER BY t.name`, userID) | ||
| 56 | if err != nil { | 72 | if err != nil { |
| 57 | return nil, err | 73 | return nil, err |
| 58 | } | 74 | } |
| @@ -61,7 +77,7 @@ func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) { | |||
| 61 | for rows.Next() { | 77 | for rows.Next() { |
| 62 | var t APIToken | 78 | var t APIToken |
| 63 | var exp, used sql.NullString | 79 | var exp, used sql.NullString |
| 64 | if err := rows.Scan(&t.Name, &t.Scope, &t.CreatedAt, &exp, &used); err != nil { | 80 | if err := rows.Scan(&t.ID, &t.Name, &t.Scope, &t.CreatedAt, &exp, &used, &t.CreatedBy); err != nil { |
| 65 | return nil, err | 81 | return nil, err |
| 66 | } | 82 | } |
| 67 | t.ExpiresAt = parseTime(exp) | 83 | t.ExpiresAt = parseTime(exp) |
| @@ -71,13 +87,92 @@ func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) { | |||
| 71 | return out, rows.Err() | 87 | return out, rows.Err() |
| 72 | } | 88 | } |
| 73 | 89 | ||
| 74 | func (s *Store) RevokeAPIToken(userID int64, name string) error { | 90 | // Created is what a token made, directly or through tokens it made: |
| 75 | res, err := s.DB.Exec("DELETE FROM api_tokens WHERE user_id = ? AND name = ?", userID, name) | 91 | // token names and SSH key fingerprints. |
| 92 | type Created struct { | ||
| 93 | Tokens []string `json:"tokens"` | ||
| 94 | Keys []string `json:"keys"` | ||
| 95 | } | ||
| 96 | |||
| 97 | // chainCTE selects the token named by the first argument and every | ||
| 98 | // token created from it, at any depth. | ||
| 99 | const chainCTE = `WITH RECURSIVE chain(id) AS ( | ||
| 100 | SELECT ? UNION SELECT t.id FROM api_tokens t JOIN chain ON t.created_by_token = chain.id)` | ||
| 101 | |||
| 102 | // RevokeAPIToken deletes the user's token by name and returns what it | ||
| 103 | // created. withCreated deletes those too; otherwise they stay and lose | ||
| 104 | // the link to the revoked token. | ||
| 105 | func (s *Store) RevokeAPIToken(userID int64, name string, withCreated bool) (Created, error) { | ||
| 106 | tx, err := s.DB.Begin() | ||
| 76 | if err != nil { | 107 | if err != nil { |
| 77 | return err | 108 | return Created{}, err |
| 109 | } | ||
| 110 | defer tx.Rollback() | ||
| 111 | var id int64 | ||
| 112 | err = tx.QueryRow("SELECT id FROM api_tokens WHERE user_id = ? AND name = ?", userID, name).Scan(&id) | ||
| 113 | if errors.Is(err, sql.ErrNoRows) { | ||
| 114 | return Created{}, ErrNotFound | ||
| 115 | } | ||
| 116 | if err != nil { | ||
| 117 | return Created{}, err | ||
| 118 | } | ||
| 119 | var c Created | ||
| 120 | rows, err := tx.Query(chainCTE+` SELECT name FROM api_tokens WHERE id IN (SELECT id FROM chain) AND id != ? ORDER BY name`, id, id) | ||
| 121 | if err != nil { | ||
| 122 | return Created{}, err | ||
| 123 | } | ||
| 124 | for rows.Next() { | ||
| 125 | var n string | ||
| 126 | if err := rows.Scan(&n); err != nil { | ||
| 127 | rows.Close() | ||
| 128 | return Created{}, err | ||
| 129 | } | ||
| 130 | c.Tokens = append(c.Tokens, n) | ||
| 131 | } | ||
| 132 | rows.Close() | ||
| 133 | var keyIDs []int64 | ||
| 134 | rows, err = tx.Query(chainCTE+` SELECT id, fingerprint FROM ssh_keys WHERE created_by_token IN (SELECT id FROM chain) ORDER BY id`, id) | ||
| 135 | if err != nil { | ||
| 136 | return Created{}, err | ||
| 137 | } | ||
| 138 | for rows.Next() { | ||
| 139 | var kid int64 | ||
| 140 | var fp string | ||
| 141 | if err := rows.Scan(&kid, &fp); err != nil { | ||
| 142 | rows.Close() | ||
| 143 | return Created{}, err | ||
| 144 | } | ||
| 145 | keyIDs = append(keyIDs, kid) | ||
| 146 | c.Keys = append(c.Keys, fp) | ||
| 147 | } | ||
| 148 | rows.Close() | ||
| 149 | |||
| 150 | if !withCreated { | ||
| 151 | if _, err := tx.Exec("DELETE FROM api_tokens WHERE id = ?", id); err != nil { | ||
| 152 | return Created{}, err | ||
| 153 | } | ||
| 154 | return c, tx.Commit() | ||
| 155 | } | ||
| 156 | if len(keyIDs) > 0 { | ||
| 157 | args := make([]any, len(keyIDs)) | ||
| 158 | for i, k := range keyIDs { | ||
| 159 | args[i] = k | ||
| 160 | } | ||
| 161 | if _, err := tx.Exec("DELETE FROM ssh_keys WHERE id IN (?"+strings.Repeat(", ?", len(keyIDs)-1)+")", args...); err != nil { | ||
| 162 | return Created{}, err | ||
| 163 | } | ||
| 164 | if err := bumpKeyEpoch(tx); err != nil { | ||
| 165 | return Created{}, err | ||
| 166 | } | ||
| 167 | } | ||
| 168 | if _, err := tx.Exec(chainCTE+` DELETE FROM api_tokens WHERE id IN (SELECT id FROM chain)`, id); err != nil { | ||
| 169 | return Created{}, err | ||
| 170 | } | ||
| 171 | if err := tx.Commit(); err != nil { | ||
| 172 | return Created{}, err | ||
| 78 | } | 173 | } |
| 79 | if n, _ := res.RowsAffected(); n == 0 { | 174 | if len(keyIDs) > 0 { |
| 80 | return ErrNotFound | 175 | s.announce(Revoked{KeyIDs: keyIDs}) |
| 81 | } | 176 | } |
| 82 | return nil | 177 | return c, nil |
| 83 | } | 178 | } |
internal/store/tokens_test.go added +115
| @@ -0,0 +1,115 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "slices" | ||
| 5 | "testing" | ||
| 6 | "time" | ||
| 7 | ) | ||
| 8 | |||
| 9 | func tokenID(t *testing.T, s *Store, hash string) int64 { | ||
| 10 | t.Helper() | ||
| 11 | _, tok, err := s.APITokenUser(hash) | ||
| 12 | if err != nil { | ||
| 13 | t.Fatal(err) | ||
| 14 | } | ||
| 15 | return tok.ID | ||
| 16 | } | ||
| 17 | |||
| 18 | // parent made child, child made grandchild and a key; the key belongs | ||
| 19 | // to another account, as admin user create --key makes one. | ||
| 20 | func tokenChain(t *testing.T) (*Store, int64, *[]Revoked) { | ||
| 21 | t.Helper() | ||
| 22 | s, uid, got := revokeFixture(t) | ||
| 23 | bob, err := s.CreateUser("bob", false) | ||
| 24 | if err != nil { | ||
| 25 | t.Fatal(err) | ||
| 26 | } | ||
| 27 | if err := s.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil { | ||
| 28 | t.Fatal(err) | ||
| 29 | } | ||
| 30 | if err := s.CreateAPIToken(uid, "child", "h-child", "full", nil, tokenID(t, s, "h-parent")); err != nil { | ||
| 31 | t.Fatal(err) | ||
| 32 | } | ||
| 33 | child := tokenID(t, s, "h-child") | ||
| 34 | if err := s.CreateAPIToken(uid, "grandchild", "h-grand", "read", nil, child); err != nil { | ||
| 35 | t.Fatal(err) | ||
| 36 | } | ||
| 37 | if err := s.AddSSHKeyFrom(bob, "SHA256:k", "ssh-ed25519", []byte("k"), "full", "", KeyOrigin{CreatedByToken: child}); err != nil { | ||
| 38 | t.Fatal(err) | ||
| 39 | } | ||
| 40 | return s, uid, got | ||
| 41 | } | ||
| 42 | |||
| 43 | func TestTokenRecordsItsCreator(t *testing.T) { | ||
| 44 | s, uid, _ := tokenChain(t) | ||
| 45 | toks, err := s.ListAPITokens(uid) | ||
| 46 | if err != nil { | ||
| 47 | t.Fatal(err) | ||
| 48 | } | ||
| 49 | by := map[string]string{} | ||
| 50 | for _, tk := range toks { | ||
| 51 | by[tk.Name] = tk.CreatedBy | ||
| 52 | } | ||
| 53 | if by["parent"] != "" || by["child"] != "parent" || by["grandchild"] != "child" { | ||
| 54 | t.Fatalf("created by: %v", by) | ||
| 55 | } | ||
| 56 | bob, _ := s.UserByUsername("bob") | ||
| 57 | keys, err := s.ListSSHKeys(bob.ID) | ||
| 58 | if err != nil || len(keys) != 1 || keys[0].CreatedBy != "child" { | ||
| 59 | t.Fatalf("key created by: %+v %v", keys, err) | ||
| 60 | } | ||
| 61 | } | ||
| 62 | |||
| 63 | func TestRevokeAPITokenListsWhatItCreated(t *testing.T) { | ||
| 64 | s, uid, got := tokenChain(t) | ||
| 65 | c, err := s.RevokeAPIToken(uid, "parent", false) | ||
| 66 | if err != nil { | ||
| 67 | t.Fatal(err) | ||
| 68 | } | ||
| 69 | if !slices.Equal(c.Tokens, []string{"child", "grandchild"}) || !slices.Equal(c.Keys, []string{"SHA256:k"}) { | ||
| 70 | t.Fatalf("created = %+v", c) | ||
| 71 | } | ||
| 72 | // Listed, not removed; the link to the revoked parent is gone. | ||
| 73 | toks, _ := s.ListAPITokens(uid) | ||
| 74 | if len(toks) != 2 || toks[0].Name != "child" || toks[0].CreatedBy != "" { | ||
| 75 | t.Fatalf("tokens after revoke: %+v", toks) | ||
| 76 | } | ||
| 77 | if _, err := s.SSHKeyByFingerprint("SHA256:k"); err != nil { | ||
| 78 | t.Fatalf("the key went: %v", err) | ||
| 79 | } | ||
| 80 | if len(*got) != 0 { | ||
| 81 | t.Fatalf("announced %+v with nothing revoked but the token", *got) | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | func TestRevokeAPITokenWithCreated(t *testing.T) { | ||
| 86 | s, uid, got := tokenChain(t) | ||
| 87 | k, _ := s.SSHKeyByFingerprint("SHA256:k") | ||
| 88 | if _, err := s.RevokeAPIToken(uid, "parent", true); err != nil { | ||
| 89 | t.Fatal(err) | ||
| 90 | } | ||
| 91 | if toks, _ := s.ListAPITokens(uid); len(toks) != 0 { | ||
| 92 | t.Fatalf("tokens left: %+v", toks) | ||
| 93 | } | ||
| 94 | if _, err := s.SSHKeyByFingerprint("SHA256:k"); err != ErrNotFound { | ||
| 95 | t.Fatalf("key left: %v", err) | ||
| 96 | } | ||
| 97 | if len(*got) != 1 || !slices.Equal((*got)[0].KeyIDs, []int64{k.ID}) { | ||
| 98 | t.Fatalf("announced %+v", *got) | ||
| 99 | } | ||
| 100 | if _, err := s.RevokeAPIToken(uid, "parent", true); err != ErrNotFound { | ||
| 101 | t.Fatalf("second revoke: %v", err) | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | func TestAPITokenUserCarriesExpiry(t *testing.T) { | ||
| 106 | s, uid, _ := revokeFixture(t) | ||
| 107 | exp := time.Now().Add(time.Hour) | ||
| 108 | if err := s.CreateAPIToken(uid, "brief", "h-brief", "full", &exp, 0); err != nil { | ||
| 109 | t.Fatal(err) | ||
| 110 | } | ||
| 111 | _, tok, err := s.APITokenUser("h-brief") | ||
| 112 | if err != nil || tok.ExpiresAt == nil || tok.Name != "brief" || tok.ID == 0 { | ||
| 113 | t.Fatalf("token %+v %v", tok, err) | ||
| 114 | } | ||
| 115 | } | ||
internal/store/users.go +18 −5
| @@ -25,6 +25,7 @@ type SSHKey struct { | |||
| 25 | Label string // "" when the key was added with no name | 25 | Label string // "" when the key was added with no name |
| 26 | CreatedAt string | 26 | CreatedAt string |
| 27 | LastUsedAt string // "" when the key has never authenticated | 27 | LastUsedAt string // "" when the key has never authenticated |
| 28 | CreatedBy string // name of the API token that added the key; "" for none. ListSSHKeys only. | ||
| 28 | } | 29 | } |
| 29 | 30 | ||
| 30 | // ErrDuplicateKey carries the exact user-facing message from the spec. It | 31 | // ErrDuplicateKey carries the exact user-facing message from the spec. It |
| @@ -270,16 +271,26 @@ func (s *Store) UserByID(id int64) (User, error) { | |||
| 270 | return u, err | 271 | return u, err |
| 271 | } | 272 | } |
| 272 | 273 | ||
| 274 | // KeyOrigin is how a key came to be. | ||
| 275 | type KeyOrigin struct { | ||
| 276 | CreatedByToken int64 // the API token that added it; 0 for none | ||
| 277 | } | ||
| 278 | |||
| 273 | // AddSSHKey registers a key and bumps the key epoch in one transaction. | 279 | // AddSSHKey registers a key and bumps the key epoch in one transaction. |
| 274 | func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope, label string) error { | 280 | func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope, label string) error { |
| 281 | return s.AddSSHKeyFrom(userID, fingerprint, algo, blob, scope, label, KeyOrigin{}) | ||
| 282 | } | ||
| 283 | |||
| 284 | // AddSSHKeyFrom is AddSSHKey recording where the key came from. | ||
| 285 | func (s *Store) AddSSHKeyFrom(userID int64, fingerprint, algo string, blob []byte, scope, label string, o KeyOrigin) error { | ||
| 275 | tx, err := s.DB.Begin() | 286 | tx, err := s.DB.Begin() |
| 276 | if err != nil { | 287 | if err != nil { |
| 277 | return err | 288 | return err |
| 278 | } | 289 | } |
| 279 | defer tx.Rollback() | 290 | defer tx.Rollback() |
| 280 | if _, err := tx.Exec( | 291 | if _, err := tx.Exec( |
| 281 | "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label) VALUES (?, ?, ?, ?, ?, ?)", | 292 | "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token) VALUES (?, ?, ?, ?, ?, ?, ?)", |
| 282 | userID, fingerprint, algo, blob, scope, label); err != nil { | 293 | userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken)); err != nil { |
| 283 | if isUniqueErr(err) { | 294 | if isUniqueErr(err) { |
| 284 | return ErrDuplicateKey | 295 | return ErrDuplicateKey |
| 285 | } | 296 | } |
| @@ -343,8 +354,10 @@ func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) { | |||
| 343 | 354 | ||
| 344 | func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) { | 355 | func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) { |
| 345 | rows, err := s.DB.Query( | 356 | rows, err := s.DB.Query( |
| 346 | `SELECT id, user_id, fingerprint, algo, blob, scope, label, created_at, COALESCE(last_used_at, '') | 357 | `SELECT k.id, k.user_id, k.fingerprint, k.algo, k.blob, k.scope, k.label, k.created_at, |
| 347 | FROM ssh_keys WHERE user_id = ? ORDER BY id`, | 358 | COALESCE(k.last_used_at, ''), COALESCE(t.name, '') |
| 359 | FROM ssh_keys k LEFT JOIN api_tokens t ON t.id = k.created_by_token | ||
| 360 | WHERE k.user_id = ? ORDER BY k.id`, | ||
| 348 | userID) | 361 | userID) |
| 349 | if err != nil { | 362 | if err != nil { |
| 350 | return nil, err | 363 | return nil, err |
| @@ -353,7 +366,7 @@ func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) { | |||
| 353 | var keys []SSHKey | 366 | var keys []SSHKey |
| 354 | for rows.Next() { | 367 | for rows.Next() { |
| 355 | var k SSHKey | 368 | var k SSHKey |
| 356 | if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.CreatedAt, &k.LastUsedAt); err != nil { | 369 | if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.CreatedAt, &k.LastUsedAt, &k.CreatedBy); err != nil { |
| 357 | return nil, err | 370 | return nil, err |
| 358 | } | 371 | } |
| 359 | keys = append(keys, k) | 372 | keys = append(keys, k) |