go-gitea/models/ssh_key.go
Gibheer 12403bdfb0 allow native and ssh-keygen public key check
This commit adds the possibibility to use either the native golang
libraries or ssh-keygen to check public keys. The check is adjusted
depending on the settings, so that only supported keys are let through.

This commit also brings back the blacklist feature, which was removed in
7ef9a05588. This allows to blacklist
algorythms or keys based on the key length. This works with the native
and the ssh-keygen way.

Because of #2179 it also includes a way to adjust the path to
ssh-keygen and the working directory for ssh-keygen. With this,
sysadmins should be able to adjust the settings in a way, that SELinux
is okay with it. In the worst case, they can switch to the native
implementation and only loose support for ed25519 keys at the moment.
There are some other places which need adjustment to utilize the
parameters and the native implementation, but this sets the ground work.
2016-02-16 23:01:56 +01:00

810 lines
22 KiB
Go

// Copyright 2014 The Gogs Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package models
import (
"bufio"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"io"
"io/ioutil"
"math/big"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/Unknwon/com"
"github.com/go-xorm/xorm"
"golang.org/x/crypto/ssh"
"github.com/gogits/gogs/modules/log"
"github.com/gogits/gogs/modules/process"
"github.com/gogits/gogs/modules/setting"
)
const (
// "### autogenerated by gitgos, DO NOT EDIT\n"
_TPL_PUBLICK_KEY = `command="%s serv key-%d --config='%s'",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s` + "\n"
)
var (
sshOpLocker = sync.Mutex{}
SSH_UNKNOWN_KEY_TYPE = fmt.Errorf("unknown key type")
)
type KeyType int
const (
KEY_TYPE_USER = iota + 1
KEY_TYPE_DEPLOY
)
// PublicKey represents a SSH or deploy key.
type PublicKey struct {
ID int64 `xorm:"pk autoincr"`
OwnerID int64 `xorm:"INDEX NOT NULL"`
Name string `xorm:"NOT NULL"`
Fingerprint string `xorm:"NOT NULL"`
Content string `xorm:"TEXT NOT NULL"`
Mode AccessMode `xorm:"NOT NULL DEFAULT 2"`
Type KeyType `xorm:"NOT NULL DEFAULT 1"`
Created time.Time `xorm:"CREATED"`
Updated time.Time // Note: Updated must below Created for AfterSet.
HasRecentActivity bool `xorm:"-"`
HasUsed bool `xorm:"-"`
}
func (k *PublicKey) AfterSet(colName string, _ xorm.Cell) {
switch colName {
case "created":
k.HasUsed = k.Updated.After(k.Created)
k.HasRecentActivity = k.Updated.Add(7 * 24 * time.Hour).After(time.Now())
}
}
// OmitEmail returns content of public key but without e-mail address.
func (k *PublicKey) OmitEmail() string {
return strings.Join(strings.Split(k.Content, " ")[:2], " ")
}
// GetAuthorizedString generates and returns formatted public key string for authorized_keys file.
func (key *PublicKey) GetAuthorizedString() string {
return fmt.Sprintf(_TPL_PUBLICK_KEY, setting.AppPath, key.ID, setting.CustomConf, key.Content)
}
func extractTypeFromBase64Key(key string) (string, error) {
b, err := base64.StdEncoding.DecodeString(key)
if err != nil || len(b) < 4 {
return "", errors.New("Invalid key format")
}
keyLength := int(binary.BigEndian.Uint32(b))
if len(b) < 4+keyLength {
return "", errors.New("Invalid key format")
}
return string(b[4 : 4+keyLength]), nil
}
// parseKeyString parses any key string in openssh or ssh2 format to clean openssh string (rfc4253)
func parseKeyString(content string) (string, error) {
// Transform all legal line endings to a single "\n"
s := strings.Replace(strings.Replace(strings.TrimSpace(content), "\r\n", "\n", -1), "\r", "\n", -1)
lines := strings.Split(s, "\n")
var keyType, keyContent, keyComment string
if len(lines) == 1 {
// Parse openssh format
parts := strings.SplitN(lines[0], " ", 3)
switch len(parts) {
case 0:
return "", errors.New("Empty key")
case 1:
keyContent = parts[0]
case 2:
keyType = parts[0]
keyContent = parts[1]
default:
keyType = parts[0]
keyContent = parts[1]
keyComment = parts[2]
}
// If keyType is not given, extract it from content. If given, validate it
if len(keyType) == 0 {
if t, err := extractTypeFromBase64Key(keyContent); err == nil {
keyType = t
} else {
return "", err
}
} else {
if t, err := extractTypeFromBase64Key(keyContent); err != nil || keyType != t {
return "", err
}
}
} else {
// Parse SSH2 file format.
continuationLine := false
for _, line := range lines {
// Skip lines that:
// 1) are a continuation of the previous line,
// 2) contain ":" as that are comment lines
// 3) contain "-" as that are begin and end tags
if continuationLine || strings.ContainsAny(line, ":-") {
continuationLine = strings.HasSuffix(line, "\\")
} else {
keyContent = keyContent + line
}
}
if t, err := extractTypeFromBase64Key(keyContent); err == nil {
keyType = t
} else {
return "", err
}
}
return keyType + " " + keyContent + " " + keyComment, nil
}
// extract key type and length using ssh-keygen
func SSHKeyGenParsePublicKey(key string) (string, int, error) {
// The ssh-keygen in Windows does not print key type, so no need go further.
if setting.IsWindows {
return "", 0, nil
}
tmpFile, err := ioutil.TempFile(setting.SSHWorkPath, "gogs_keytest")
if err != nil {
return "", 0, err
}
tmpName := tmpFile.Name()
defer os.Remove(tmpName)
if ln, err := tmpFile.WriteString(key); err != nil {
tmpFile.Close()
return "", 0, err
} else if ln != len(key) {
tmpFile.Close()
return "", 0, fmt.Errorf("could not write complete public key (written: %d, should be: %d): %s", ln, len(key), key)
}
tmpFile.Close()
stdout, stderr, err := process.Exec("CheckPublicKeyString", setting.SSHKeyGenPath, "-lf", tmpName)
if err != nil {
return "", 0, fmt.Errorf("public key check failed with error '%s': %s", err, stderr)
}
if strings.HasSuffix(stdout, "is not a public key file.") {
return "", 0, SSH_UNKNOWN_KEY_TYPE
}
fields := strings.Split(stdout, " ")
if len(fields) < 4 {
return "", 0, fmt.Errorf("invalid public key line: %s", stdout)
}
length, err := strconv.Atoi(fields[0])
if err != nil {
return "", 0, err
}
keyType := strings.Trim(fields[len(fields)-1], "()\r\n")
return strings.ToLower(keyType), length, nil
}
// extract the key type and length using the golang ssh library
func SSHNativeParsePublicKey(keyLine string) (string, int, error) {
fields := strings.Fields(keyLine)
if len(fields) < 2 {
return "", 0, fmt.Errorf("not enough fields in public key line: %s", string(keyLine))
}
raw, err := base64.StdEncoding.DecodeString(fields[1])
if err != nil {
return "", 0, err
}
pkey, err := ssh.ParsePublicKey(raw)
if err != nil {
if strings.HasPrefix(err.Error(), "ssh: unknown key algorithm") {
return "", 0, SSH_UNKNOWN_KEY_TYPE
}
return "", 0, err
}
// The ssh library can parse the key, so next we find out what key exactly we
// have.
switch pkey.Type() {
case ssh.KeyAlgoDSA:
rawPub := struct {
Name string
P, Q, G, Y *big.Int
}{}
if err := ssh.Unmarshal(pkey.Marshal(), &rawPub); err != nil {
return "", 0, err
}
// as per https://bugzilla.mindrot.org/show_bug.cgi?id=1647 we should never
// see dsa keys != 1024 bit, but as it seems to work, we will not check here
return "dsa", rawPub.P.BitLen(), nil // use P as per crypto/dsa/dsa.go (is L)
case ssh.KeyAlgoRSA:
rawPub := struct {
Name string
E *big.Int
N *big.Int
}{}
if err := ssh.Unmarshal(pkey.Marshal(), &rawPub); err != nil {
return "", 0, err
}
return "rsa", rawPub.N.BitLen(), nil // use N as per crypto/rsa/rsa.go (is bits)
case ssh.KeyAlgoECDSA256:
return "ecdsa", 256, nil
case ssh.KeyAlgoECDSA384:
return "ecdsa", 384, nil
case ssh.KeyAlgoECDSA521:
return "ecdsa", 521, nil
case "ssh-ed25519": // TODO replace with ssh constant when available
return "ed25519", 256, nil
default:
return "", 0, fmt.Errorf("no support for key length detection for type %s", pkey.Type())
}
return "", 0, fmt.Errorf("SSHNativeParsePublicKey failed horribly, please investigate why")
}
// CheckPublicKeyString checks if the given public key string is recognized by SSH.
//
// The function returns the actual public key line on success.
func CheckPublicKeyString(content string) (_ string, err error) {
content, err = parseKeyString(content)
if err != nil {
return "", err
}
content = strings.TrimRight(content, "\n\r")
if strings.ContainsAny(content, "\n\r") {
return "", errors.New("only a single line with a single key please")
}
// remove any unnecessary whitespace now
content = strings.TrimSpace(content)
var (
keyType string
length int
)
if setting.SSHPublicKeyCheck == setting.SSH_PUBLICKEY_CHECK_NATIVE {
keyType, length, err = SSHNativeParsePublicKey(content)
} else if setting.SSHPublicKeyCheck == setting.SSH_PUBLICKEY_CHECK_KEYGEN {
keyType, length, err = SSHKeyGenParsePublicKey(content)
} else {
log.Error(4, "invalid public key check type: %s", setting.SSHPublicKeyCheck)
return "", fmt.Errorf("invalid public key check type")
}
if err != nil {
log.Trace("invalid public key of type '%s' with length %d: %s", keyType, length, err)
return "", fmt.Errorf("ParsePublicKey: %v", err)
}
log.Trace("Key type: %s", keyType)
if !setting.Service.EnableMinimumKeySizeCheck {
return content, nil
}
if minLen, found := setting.Service.MinimumKeySizes[keyType]; found && length >= minLen {
return content, nil
} else if found && length < minLen {
return "", fmt.Errorf("key not large enough - got %d, needs %d", length, minLen)
}
return "", fmt.Errorf("key type '%s' is not allowed", keyType)
}
// saveAuthorizedKeyFile writes SSH key content to authorized_keys file.
func saveAuthorizedKeyFile(keys ...*PublicKey) error {
sshOpLocker.Lock()
defer sshOpLocker.Unlock()
fpath := filepath.Join(setting.SSHRootPath, "authorized_keys")
f, err := os.OpenFile(fpath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return err
}
// FIXME: following command does not support in Windows.
if !setting.IsWindows {
// .ssh directory should have mode 700, and authorized_keys file should have mode 600.
if fi.Mode().Perm() > 0600 {
log.Error(4, "authorized_keys file has unusual permission flags: %s - setting to -rw-------", fi.Mode().Perm().String())
if err = f.Chmod(0600); err != nil {
return err
}
}
}
for _, key := range keys {
if _, err = f.WriteString(key.GetAuthorizedString()); err != nil {
return err
}
}
return nil
}
// checkKeyContent onlys checks if key content has been used as public key,
// it is OK to use same key as deploy key for multiple repositories/users.
func checkKeyContent(content string) error {
has, err := x.Get(&PublicKey{
Content: content,
Type: KEY_TYPE_USER,
})
if err != nil {
return err
} else if has {
return ErrKeyAlreadyExist{0, content}
}
return nil
}
func addKey(e Engine, key *PublicKey) (err error) {
// Calculate fingerprint.
tmpPath := strings.Replace(path.Join(os.TempDir(), fmt.Sprintf("%d", time.Now().Nanosecond()),
"id_rsa.pub"), "\\", "/", -1)
os.MkdirAll(path.Dir(tmpPath), os.ModePerm)
if err = ioutil.WriteFile(tmpPath, []byte(key.Content), 0644); err != nil {
return err
}
stdout, stderr, err := process.Exec("AddPublicKey", "ssh-keygen", "-lf", tmpPath)
if err != nil {
return fmt.Errorf("'ssh-keygen -lf %s' failed with error '%s': %s", tmpPath, err, stderr)
} else if len(stdout) < 2 {
return errors.New("not enough output for calculating fingerprint: " + stdout)
}
key.Fingerprint = strings.Split(stdout, " ")[1]
// Save SSH key.
if _, err = e.Insert(key); err != nil {
return err
}
// Don't need to rewrite this file if builtin SSH server is enabled.
if setting.StartSSHServer {
return nil
}
return saveAuthorizedKeyFile(key)
}
// AddPublicKey adds new public key to database and authorized_keys file.
func AddPublicKey(ownerID int64, name, content string) (*PublicKey, error) {
log.Trace(content)
if err := checkKeyContent(content); err != nil {
return nil, err
}
// Key name of same user cannot be duplicated.
has, err := x.Where("owner_id=? AND name=?", ownerID, name).Get(new(PublicKey))
if err != nil {
return nil, err
} else if has {
return nil, ErrKeyNameAlreadyUsed{ownerID, name}
}
sess := x.NewSession()
defer sessionRelease(sess)
if err = sess.Begin(); err != nil {
return nil, err
}
key := &PublicKey{
OwnerID: ownerID,
Name: name,
Content: content,
Mode: ACCESS_MODE_WRITE,
Type: KEY_TYPE_USER,
}
if err = addKey(sess, key); err != nil {
return nil, fmt.Errorf("addKey: %v", err)
}
return key, sess.Commit()
}
// GetPublicKeyByID returns public key by given ID.
func GetPublicKeyByID(keyID int64) (*PublicKey, error) {
key := new(PublicKey)
has, err := x.Id(keyID).Get(key)
if err != nil {
return nil, err
} else if !has {
return nil, ErrKeyNotExist{keyID}
}
return key, nil
}
// SearchPublicKeyByContent searches content as prefix (leak e-mail part)
// and returns public key found.
func SearchPublicKeyByContent(content string) (*PublicKey, error) {
key := new(PublicKey)
has, err := x.Where("content like ?", content+"%").Get(key)
if err != nil {
return nil, err
} else if !has {
return nil, ErrKeyNotExist{}
}
return key, nil
}
// ListPublicKeys returns a list of public keys belongs to given user.
func ListPublicKeys(uid int64) ([]*PublicKey, error) {
keys := make([]*PublicKey, 0, 5)
return keys, x.Where("owner_id=?", uid).Find(&keys)
}
// rewriteAuthorizedKeys finds and deletes corresponding line in authorized_keys file.
func rewriteAuthorizedKeys(key *PublicKey, p, tmpP string) error {
fr, err := os.Open(p)
if err != nil {
return err
}
defer fr.Close()
fw, err := os.OpenFile(tmpP, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return err
}
defer fw.Close()
isFound := false
keyword := fmt.Sprintf("key-%d", key.ID)
buf := bufio.NewReader(fr)
for {
line, errRead := buf.ReadString('\n')
line = strings.TrimSpace(line)
if errRead != nil {
if errRead != io.EOF {
return errRead
}
// Reached end of file, if nothing to read then break,
// otherwise handle the last line.
if len(line) == 0 {
break
}
}
// Found the line and copy rest of file.
if !isFound && strings.Contains(line, keyword) && strings.Contains(line, key.Content) {
isFound = true
continue
}
// Still finding the line, copy the line that currently read.
if _, err = fw.WriteString(line + "\n"); err != nil {
return err
}
if errRead == io.EOF {
break
}
}
if !isFound {
log.Warn("SSH key %d not found in authorized_keys file for deletion", key.ID)
}
return nil
}
// UpdatePublicKey updates given public key.
func UpdatePublicKey(key *PublicKey) error {
_, err := x.Id(key.ID).AllCols().Update(key)
return err
}
func deletePublicKey(e *xorm.Session, keyID int64) error {
sshOpLocker.Lock()
defer sshOpLocker.Unlock()
key := &PublicKey{ID: keyID}
has, err := e.Get(key)
if err != nil {
return err
} else if !has {
return nil
}
if _, err = e.Id(key.ID).Delete(new(PublicKey)); err != nil {
return err
}
// Don't need to rewrite this file if builtin SSH server is enabled.
if setting.StartSSHServer {
return nil
}
fpath := filepath.Join(setting.SSHRootPath, "authorized_keys")
tmpPath := filepath.Join(setting.SSHRootPath, "authorized_keys.tmp")
if err = rewriteAuthorizedKeys(key, fpath, tmpPath); err != nil {
return err
} else if err = os.Remove(fpath); err != nil {
return err
}
return os.Rename(tmpPath, fpath)
}
// DeletePublicKey deletes SSH key information both in database and authorized_keys file.
func DeletePublicKey(doer *User, id int64) (err error) {
key, err := GetPublicKeyByID(id)
if err != nil {
if IsErrKeyNotExist(err) {
return nil
}
return fmt.Errorf("GetPublicKeyByID: %v", err)
}
// Check if user has access to delete this key.
if !doer.IsAdmin && doer.Id != key.OwnerID {
return ErrKeyAccessDenied{doer.Id, key.ID, "public"}
}
sess := x.NewSession()
defer sessionRelease(sess)
if err = sess.Begin(); err != nil {
return err
}
if err = deletePublicKey(sess, id); err != nil {
return err
}
return sess.Commit()
}
// RewriteAllPublicKeys removes any authorized key and rewrite all keys from database again.
func RewriteAllPublicKeys() error {
sshOpLocker.Lock()
defer sshOpLocker.Unlock()
tmpPath := filepath.Join(setting.SSHRootPath, "authorized_keys.tmp")
f, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer os.Remove(tmpPath)
err = x.Iterate(new(PublicKey), func(idx int, bean interface{}) (err error) {
_, err = f.WriteString((bean.(*PublicKey)).GetAuthorizedString())
return err
})
f.Close()
if err != nil {
return err
}
fpath := filepath.Join(setting.SSHRootPath, "authorized_keys")
if com.IsExist(fpath) {
if err = os.Remove(fpath); err != nil {
return err
}
}
if err = os.Rename(tmpPath, fpath); err != nil {
return err
}
return nil
}
// ________ .__ ____ __.
// \______ \ ____ ______ | | ____ ___.__.| |/ _|____ ___.__.
// | | \_/ __ \\____ \| | / _ < | || <_/ __ < | |
// | ` \ ___/| |_> > |_( <_> )___ || | \ ___/\___ |
// /_______ /\___ > __/|____/\____// ____||____|__ \___ > ____|
// \/ \/|__| \/ \/ \/\/
// DeployKey represents deploy key information and its relation with repository.
type DeployKey struct {
ID int64 `xorm:"pk autoincr"`
KeyID int64 `xorm:"UNIQUE(s) INDEX"`
RepoID int64 `xorm:"UNIQUE(s) INDEX"`
Name string
Fingerprint string
Content string `xorm:"-"`
Created time.Time `xorm:"CREATED"`
Updated time.Time // Note: Updated must below Created for AfterSet.
HasRecentActivity bool `xorm:"-"`
HasUsed bool `xorm:"-"`
}
func (k *DeployKey) AfterSet(colName string, _ xorm.Cell) {
switch colName {
case "created":
k.HasUsed = k.Updated.After(k.Created)
k.HasRecentActivity = k.Updated.Add(7 * 24 * time.Hour).After(time.Now())
}
}
// GetContent gets associated public key content.
func (k *DeployKey) GetContent() error {
pkey, err := GetPublicKeyByID(k.KeyID)
if err != nil {
return err
}
k.Content = pkey.Content
return nil
}
func checkDeployKey(e Engine, keyID, repoID int64, name string) error {
// Note: We want error detail, not just true or false here.
has, err := e.Where("key_id=? AND repo_id=?", keyID, repoID).Get(new(DeployKey))
if err != nil {
return err
} else if has {
return ErrDeployKeyAlreadyExist{keyID, repoID}
}
has, err = e.Where("repo_id=? AND name=?", repoID, name).Get(new(DeployKey))
if err != nil {
return err
} else if has {
return ErrDeployKeyNameAlreadyUsed{repoID, name}
}
return nil
}
// addDeployKey adds new key-repo relation.
func addDeployKey(e *xorm.Session, keyID, repoID int64, name, fingerprint string) (*DeployKey, error) {
if err := checkDeployKey(e, keyID, repoID, name); err != nil {
return nil, err
}
key := &DeployKey{
KeyID: keyID,
RepoID: repoID,
Name: name,
Fingerprint: fingerprint,
}
_, err := e.Insert(key)
return key, err
}
// HasDeployKey returns true if public key is a deploy key of given repository.
func HasDeployKey(keyID, repoID int64) bool {
has, _ := x.Where("key_id=? AND repo_id=?", keyID, repoID).Get(new(DeployKey))
return has
}
// AddDeployKey add new deploy key to database and authorized_keys file.
func AddDeployKey(repoID int64, name, content string) (*DeployKey, error) {
if err := checkKeyContent(content); err != nil {
return nil, err
}
pkey := &PublicKey{
Content: content,
Mode: ACCESS_MODE_READ,
Type: KEY_TYPE_DEPLOY,
}
has, err := x.Get(pkey)
if err != nil {
return nil, err
}
sess := x.NewSession()
defer sessionRelease(sess)
if err = sess.Begin(); err != nil {
return nil, err
}
// First time use this deploy key.
if !has {
if err = addKey(sess, pkey); err != nil {
return nil, fmt.Errorf("addKey: %v", err)
}
}
key, err := addDeployKey(sess, pkey.ID, repoID, name, pkey.Fingerprint)
if err != nil {
return nil, fmt.Errorf("addDeployKey: %v", err)
}
return key, sess.Commit()
}
// GetDeployKeyByID returns deploy key by given ID.
func GetDeployKeyByID(id int64) (*DeployKey, error) {
key := new(DeployKey)
has, err := x.Id(id).Get(key)
if err != nil {
return nil, err
} else if !has {
return nil, ErrDeployKeyNotExist{id, 0, 0}
}
return key, nil
}
// GetDeployKeyByRepo returns deploy key by given public key ID and repository ID.
func GetDeployKeyByRepo(keyID, repoID int64) (*DeployKey, error) {
key := &DeployKey{
KeyID: keyID,
RepoID: repoID,
}
has, err := x.Get(key)
if err != nil {
return nil, err
} else if !has {
return nil, ErrDeployKeyNotExist{0, keyID, repoID}
}
return key, nil
}
// UpdateDeployKey updates deploy key information.
func UpdateDeployKey(key *DeployKey) error {
_, err := x.Id(key.ID).AllCols().Update(key)
return err
}
// DeleteDeployKey deletes deploy key from its repository authorized_keys file if needed.
func DeleteDeployKey(doer *User, id int64) error {
key, err := GetDeployKeyByID(id)
if err != nil {
if IsErrDeployKeyNotExist(err) {
return nil
}
return fmt.Errorf("GetDeployKeyByID: %v", err)
}
// Check if user has access to delete this key.
if !doer.IsAdmin {
repo, err := GetRepositoryByID(key.RepoID)
if err != nil {
return fmt.Errorf("GetRepositoryByID: %v", err)
}
yes, err := HasAccess(doer, repo, ACCESS_MODE_ADMIN)
if err != nil {
return fmt.Errorf("HasAccess: %v", err)
} else if !yes {
return ErrKeyAccessDenied{doer.Id, key.ID, "deploy"}
}
}
sess := x.NewSession()
defer sessionRelease(sess)
if err = sess.Begin(); err != nil {
return err
}
if _, err = sess.Id(key.ID).Delete(new(DeployKey)); err != nil {
return fmt.Errorf("delete deploy key[%d]: %v", key.ID, err)
}
// Check if this is the last reference to same key content.
has, err := sess.Where("key_id=?", key.KeyID).Get(new(DeployKey))
if err != nil {
return err
} else if !has {
if err = deletePublicKey(sess, key.KeyID); err != nil {
return err
}
}
return sess.Commit()
}
// ListDeployKeys returns all deploy keys by given repository ID.
func ListDeployKeys(repoID int64) ([]*DeployKey, error) {
keys := make([]*DeployKey, 0, 5)
return keys, x.Where("repo_id=?", repoID).Find(&keys)
}