summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--cmd/serv.go7
-rw-r--r--custom/conf/app.example.ini24
-rw-r--r--docker/root/etc/templates/sshd_config3
-rw-r--r--docs/content/doc/advanced/config-cheat-sheet.en-us.md5
-rw-r--r--models/ssh_key.go238
-rw-r--r--models/user.go4
-rw-r--r--modules/cron/tasks_extended.go11
-rw-r--r--modules/setting/setting.go111
-rw-r--r--modules/ssh/ssh.go47
-rw-r--r--options/locale/locale_en-US.ini16
-rw-r--r--routers/private/serv.go2
-rw-r--r--routers/user/setting/keys.go44
-rw-r--r--templates/admin/dashboard.tmpl5
-rw-r--r--templates/user/settings/keys.tmpl1
-rw-r--r--templates/user/settings/keys_principal.tmpl67
15 files changed, 557 insertions, 28 deletions
diff --git a/cmd/serv.go b/cmd/serv.go
index 1938388001..1b41a5a078 100644
--- a/cmd/serv.go
+++ b/cmd/serv.go
@@ -113,9 +113,12 @@ func runServ(c *cli.Context) error {
if err != nil {
fail("Internal error", "Failed to check provided key: %v", err)
}
- if key.Type == models.KeyTypeDeploy {
+ switch key.Type {
+ case models.KeyTypeDeploy:
println("Hi there! You've successfully authenticated with the deploy key named " + key.Name + ", but Gitea does not provide shell access.")
- } else {
+ case models.KeyTypePrincipal:
+ println("Hi there! You've successfully authenticated with the principal " + key.Content + ", but Gitea does not provide shell access.")
+ default:
println("Hi there, " + user.Name + "! You've successfully authenticated with the key named " + key.Name + ", but Gitea does not provide shell access.")
}
println("If this is unexpected, please log in with password and setup Gitea under another user.")
diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index bc678c1934..dc273ced80 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -297,6 +297,9 @@ SSH_ROOT_PATH =
; Gitea will create a authorized_keys file by default when it is not using the internal ssh server
; If you intend to use the AuthorizedKeysCommand functionality then you should turn this off.
SSH_CREATE_AUTHORIZED_KEYS_FILE = true
+; Gitea will create a authorized_principals file by default when it is not using the internal ssh server
+; If you intend to use the AuthorizedPrincipalsCommand functionality then you should turn this off.
+SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE = true
; For the built-in SSH server, choose the ciphers to support for SSH connections,
; for system SSH this setting has no effect
SSH_SERVER_CIPHERS = aes128-ctr, aes192-ctr, aes256-ctr, aes128-gcm@openssh.com, arcfour256, arcfour128
@@ -312,7 +315,26 @@ SSH_KEY_TEST_PATH =
; Path to ssh-keygen, default is 'ssh-keygen' which means the shell is responsible for finding out which one to call.
SSH_KEYGEN_PATH = ssh-keygen
; Enable SSH Authorized Key Backup when rewriting all keys, default is true
-SSH_BACKUP_AUTHORIZED_KEYS = true
+SSH_AUTHORIZED_KEYS_BACKUP = true
+; Determines which principals to allow
+; - empty: if SSH_TRUSTED_USER_CA_KEYS is empty this will default to off, otherwise will default to email, username.
+; - off: Do not allow authorized principals
+; - email: the principal must match the user's email
+; - username: the principal must match the user's username
+; - anything: there will be no checking on the content of the principal
+SSH_AUTHORIZED_PRINCIPALS_ALLOW = email, username
+; Enable SSH Authorized Principals Backup when rewriting all keys, default is true
+SSH_AUTHORIZED_PRINCIPALS_BACKUP = true
+; Specifies the public keys of certificate authorities that are trusted to sign user certificates for authentication.
+; Multiple keys should be comma separated.
+; E.g."ssh-<algorithm> <key>". or "ssh-<algorithm> <key1>, ssh-<algorithm> <key2>".
+; For more information see "TrustedUserCAKeys" in the sshd config manpages.
+SSH_TRUSTED_USER_CA_KEYS =
+; Absolute path of the `TrustedUserCaKeys` file gitea will manage.
+; Default this `RUN_USER`/.ssh/gitea-trusted-user-ca-keys.pem
+; If you're running your own ssh server and you want to use the gitea managed file you'll also need to modify your
+; sshd_config to point to this file. The official docker image will automatically work without further configuration.
+SSH_TRUSTED_USER_CA_KEYS_FILENAME =
; Enable exposure of SSH clone URL to anonymous visitors, default is false
SSH_EXPOSE_ANONYMOUS = false
; Indicate whether to check minimum key size with corresponding type
diff --git a/docker/root/etc/templates/sshd_config b/docker/root/etc/templates/sshd_config
index 2c688ef4e0..82a9c0221e 100644
--- a/docker/root/etc/templates/sshd_config
+++ b/docker/root/etc/templates/sshd_config
@@ -13,6 +13,9 @@ HostKey /data/ssh/ssh_host_ecdsa_key
HostKey /data/ssh/ssh_host_dsa_key
AuthorizedKeysFile .ssh/authorized_keys
+AuthorizedPrincipalsFile .ssh/authorized_principals
+TrustedUserCAKeys /data/git/.ssh/gitea-trusted-user-ca-keys.pem
+CASignatureAlgorithms ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ecdsa-sha2-nistp256@openssh.com,ssh-ed25519,sk-ssh-ed25519@openssh.com,rsa-sha2-512,rsa-sha2-256,ssh-rsa
UseDNS no
AllowAgentForwarding no
diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md
index 36d5af1aef..3bd667be69 100644
--- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md
+++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md
@@ -251,6 +251,11 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`.
- `SSH_LISTEN_PORT`: **%(SSH\_PORT)s**: Port for the built-in SSH server.
- `SSH_ROOT_PATH`: **~/.ssh**: Root path of SSH directory.
- `SSH_CREATE_AUTHORIZED_KEYS_FILE`: **true**: Gitea will create a authorized_keys file by default when it is not using the internal ssh server. If you intend to use the AuthorizedKeysCommand functionality then you should turn this off.
+- `SSH_TRUSTED_USER_CA_KEYS`: **\<empty\>**: Specifies the public keys of certificate authorities that are trusted to sign user certificates for authentication. Multiple keys should be comma separated. E.g.`ssh-<algorithm> <key>` or `ssh-<algorithm> <key1>, ssh-<algorithm> <key2>`. For more information see `TrustedUserCAKeys` in the sshd config man pages. When empty no file will be created and `SSH_AUTHORIZED_PRINCIPALS_ALLOW` will default to `off`.
+- `SSH_TRUSTED_USER_CA_KEYS_FILENAME`: **`RUN_USER`/.ssh/gitea-trusted-user-ca-keys.pem**: Absolute path of the `TrustedUserCaKeys` file gitea will manage. If you're running your own ssh server and you want to use the gitea managed file you'll also need to modify your sshd_config to point to this file. The official docker image will automatically work without further configuration.
+- `SSH_AUTHORIZED_PRINCIPALS_ALLOW`: **off** or **username, email**: \[off, username, email, anything\]: Specify the principals values that users are allowed to use as principal. When set to `anything` no checks are done on the principal string. When set to `off` authorized principal are not allowed to be set.
+- `SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE`: **false/true**: Gitea will create a authorized_principals file by default when it is not using the internal ssh server and `SSH_AUTHORIZED_PRINCIPALS_ALLOW` is not `off`.
+- `SSH_AUTHORIZED_PRINCIPALS_BACKUP`: **false/true**: Enable SSH Authorized Principals Backup when rewriting all keys, default is true if `SSH_AUTHORIZED_PRINCIPALS_ALLOW` is not `off`.
- `SSH_SERVER_CIPHERS`: **aes128-ctr, aes192-ctr, aes256-ctr, aes128-gcm@openssh.com, arcfour256, arcfour128**: For the built-in SSH server, choose the ciphers to support for SSH connections, for system SSH this setting has no effect.
- `SSH_SERVER_KEY_EXCHANGES`: **diffie-hellman-group1-sha1, diffie-hellman-group14-sha1, ecdh-sha2-nistp256, ecdh-sha2-nistp384, ecdh-sha2-nistp521, curve25519-sha256@libssh.org**: For the built-in SSH server, choose the key exchange algorithms to support for SSH connections, for system SSH this setting has no effect.
- `SSH_SERVER_MACS`: **hmac-sha2-256-etm@openssh.com, hmac-sha2-256, hmac-sha1, hmac-sha1-96**: For the built-in SSH server, choose the MACs to support for SSH connections, for system SSH this setting has no effect
diff --git a/models/ssh_key.go b/models/ssh_key.go
index b46ff76b94..d67981398b 100644
--- a/models/ssh_key.go
+++ b/models/ssh_key.go
@@ -40,6 +40,8 @@ const (
tplCommentPrefix = `# gitea public key`
tplCommand = "%s --config=%s serv key-%d"
tplPublicKey = tplCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s` + "\n"
+
+ authorizedPrincipalsFile = "authorized_principals"
)
var sshOpLocker sync.Mutex
@@ -52,6 +54,8 @@ const (
KeyTypeUser = iota + 1
// KeyTypeDeploy specifies the deploy key
KeyTypeDeploy
+ // KeyTypePrincipal specifies the authorized principal key
+ KeyTypePrincipal
)
// PublicKey represents a user or deploy SSH public key.
@@ -401,6 +405,9 @@ func appendAuthorizedKeysToFile(keys ...*PublicKey) error {
}
for _, key := range keys {
+ if key.Type == KeyTypePrincipal {
+ continue
+ }
if _, err = f.WriteString(key.AuthorizedString()); err != nil {
return err
}
@@ -571,6 +578,25 @@ func SearchPublicKeyByContent(content string) (*PublicKey, error) {
return searchPublicKeyByContentWithEngine(x, content)
}
+func searchPublicKeyByContentExactWithEngine(e Engine, content string) (*PublicKey, error) {
+ key := new(PublicKey)
+ has, err := e.
+ Where("content = ?", content).
+ Get(key)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrKeyNotExist{}
+ }
+ return key, nil
+}
+
+// SearchPublicKeyByContentExact searches content
+// and returns public key found.
+func SearchPublicKeyByContentExact(content string) (*PublicKey, error) {
+ return searchPublicKeyByContentExactWithEngine(x, content)
+}
+
// SearchPublicKey returns a list of public keys matching the provided arguments.
func SearchPublicKey(uid int64, fingerprint string) ([]*PublicKey, error) {
keys := make([]*PublicKey, 0, 5)
@@ -586,7 +612,7 @@ func SearchPublicKey(uid int64, fingerprint string) ([]*PublicKey, error) {
// ListPublicKeys returns a list of public keys belongs to given user.
func ListPublicKeys(uid int64, listOptions ListOptions) ([]*PublicKey, error) {
- sess := x.Where("owner_id = ?", uid)
+ sess := x.Where("owner_id = ? AND type != ?", uid, KeyTypePrincipal)
if listOptions.Page != 0 {
sess = listOptions.setSessionPagination(sess)
@@ -662,6 +688,10 @@ func DeletePublicKey(doer *User, id int64) (err error) {
}
sess.Close()
+ if key.Type == KeyTypePrincipal {
+ return RewriteAllPrincipalKeys()
+ }
+
return RewriteAllPublicKeys()
}
@@ -727,11 +757,10 @@ func RegeneratePublicKeys(t io.StringWriter) error {
}
func regeneratePublicKeys(e Engine, t io.StringWriter) error {
- err := e.Iterate(new(PublicKey), func(idx int, bean interface{}) (err error) {
+ if err := e.Where("type != ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean interface{}) (err error) {
_, err = t.WriteString((bean.(*PublicKey)).AuthorizedString())
return err
- })
- if err != nil {
+ }); err != nil {
return err
}
@@ -1041,3 +1070,204 @@ func SearchDeployKeys(repoID int64, keyID int64, fingerprint string) ([]*DeployK
}
return keys, x.Where(cond).Find(&keys)
}
+
+// __________ .__ .__ .__
+// \______ _______|__| ____ ____ |_____________ | | ______
+// | ___\_ __ | |/ \_/ ___\| \____ \__ \ | | / ___/
+// | | | | \| | | \ \___| | |_> / __ \| |__\___ \
+// |____| |__| |__|___| /\___ |__| __(____ |____/____ >
+// \/ \/ |__| \/ \/
+
+// AddPrincipalKey adds new principal to database and authorized_principals file.
+func AddPrincipalKey(ownerID int64, content string, loginSourceID int64) (*PublicKey, error) {
+ sess := x.NewSession()
+ defer sess.Close()
+ if err := sess.Begin(); err != nil {
+ return nil, err
+ }
+
+ // Principals cannot be duplicated.
+ has, err := sess.
+ Where("content = ? AND type = ?", content, KeyTypePrincipal).
+ Get(new(PublicKey))
+ if err != nil {
+ return nil, err
+ } else if has {
+ return nil, ErrKeyAlreadyExist{0, "", content}
+ }
+
+ key := &PublicKey{
+ OwnerID: ownerID,
+ Name: content,
+ Content: content,
+ Mode: AccessModeWrite,
+ Type: KeyTypePrincipal,
+ LoginSourceID: loginSourceID,
+ }
+ if err = addPrincipalKey(sess, key); err != nil {
+ return nil, fmt.Errorf("addKey: %v", err)
+ }
+
+ if err = sess.Commit(); err != nil {
+ return nil, err
+ }
+
+ sess.Close()
+
+ return key, RewriteAllPrincipalKeys()
+}
+
+func addPrincipalKey(e Engine, key *PublicKey) (err error) {
+ // Save Key representing a principal.
+ if _, err = e.Insert(key); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// CheckPrincipalKeyString strips spaces and returns an error if the given principal contains newlines
+func CheckPrincipalKeyString(user *User, content string) (_ string, err error) {
+ if setting.SSH.Disabled {
+ return "", ErrSSHDisabled{}
+ }
+
+ content = strings.TrimSpace(content)
+ if strings.ContainsAny(content, "\r\n") {
+ return "", errors.New("only a single line with a single principal please")
+ }
+
+ // check all the allowed principals, email, username or anything
+ // if any matches, return ok
+ for _, v := range setting.SSH.AuthorizedPrincipalsAllow {
+ switch v {
+ case "anything":
+ return content, nil
+ case "email":
+ emails, err := GetEmailAddresses(user.ID)
+ if err != nil {
+ return "", err
+ }
+ for _, email := range emails {
+ if !email.IsActivated {
+ continue
+ }
+ if content == email.Email {
+ return content, nil
+ }
+ }
+
+ case "username":
+ if content == user.Name {
+ return content, nil
+ }
+ }
+ }
+
+ return "", fmt.Errorf("didn't match allowed principals: %s", setting.SSH.AuthorizedPrincipalsAllow)
+}
+
+// RewriteAllPrincipalKeys removes any authorized principal and rewrite all keys from database again.
+// Note: x.Iterate does not get latest data after insert/delete, so we have to call this function
+// outside any session scope independently.
+func RewriteAllPrincipalKeys() error {
+ return rewriteAllPrincipalKeys(x)
+}
+
+func rewriteAllPrincipalKeys(e Engine) error {
+ // Don't rewrite key if internal server
+ if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedPrincipalsFile {
+ return nil
+ }
+
+ sshOpLocker.Lock()
+ defer sshOpLocker.Unlock()
+
+ if setting.SSH.RootPath != "" {
+ // First of ensure that the RootPath is present, and if not make it with 0700 permissions
+ // This of course doesn't guarantee that this is the right directory for authorized_keys
+ // but at least if it's supposed to be this directory and it doesn't exist and we're the
+ // right user it will at least be created properly.
+ err := os.MkdirAll(setting.SSH.RootPath, 0700)
+ if err != nil {
+ log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err)
+ return err
+ }
+ }
+
+ fPath := filepath.Join(setting.SSH.RootPath, authorizedPrincipalsFile)
+ tmpPath := fPath + ".tmp"
+ t, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ t.Close()
+ os.Remove(tmpPath)
+ }()
+
+ if setting.SSH.AuthorizedPrincipalsBackup && com.IsExist(fPath) {
+ bakPath := fmt.Sprintf("%s_%d.gitea_bak", fPath, time.Now().Unix())
+ if err = com.Copy(fPath, bakPath); err != nil {
+ return err
+ }
+ }
+
+ if err := regeneratePrincipalKeys(e, t); err != nil {
+ return err
+ }
+
+ t.Close()
+ return os.Rename(tmpPath, fPath)
+}
+
+// ListPrincipalKeys returns a list of principals belongs to given user.
+func ListPrincipalKeys(uid int64, listOptions ListOptions) ([]*PublicKey, error) {
+ sess := x.Where("owner_id = ? AND type = ?", uid, KeyTypePrincipal)
+ if listOptions.Page != 0 {
+ sess = listOptions.setSessionPagination(sess)
+
+ keys := make([]*PublicKey, 0, listOptions.PageSize)
+ return keys, sess.Find(&keys)
+ }
+
+ keys := make([]*PublicKey, 0, 5)
+ return keys, sess.Find(&keys)
+}
+
+// RegeneratePrincipalKeys regenerates the authorized_principals file
+func RegeneratePrincipalKeys(t io.StringWriter) error {
+ return regeneratePrincipalKeys(x, t)
+}
+
+func regeneratePrincipalKeys(e Engine, t io.StringWriter) error {
+ if err := e.Where("type = ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean interface{}) (err error) {
+ _, err = t.WriteString((bean.(*PublicKey)).AuthorizedString())
+ return err
+ }); err != nil {
+ return err
+ }
+
+ fPath := filepath.Join(setting.SSH.RootPath, authorizedPrincipalsFile)
+ if com.IsExist(fPath) {
+ f, err := os.Open(fPath)
+ if err != nil {
+ return err
+ }
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if strings.HasPrefix(line, tplCommentPrefix) {
+ scanner.Scan()
+ continue
+ }
+ _, err = t.WriteString(line + "\n")
+ if err != nil {
+ f.Close()
+ return err
+ }
+ }
+ f.Close()
+ }
+ return nil
+}
diff --git a/models/user.go b/models/user.go
index 63ce6ffdfc..6c57dd473a 100644
--- a/models/user.go
+++ b/models/user.go
@@ -1254,6 +1254,10 @@ func deleteUser(e *xorm.Session, u *User) error {
if err != nil {
return err
}
+ err = rewriteAllPrincipalKeys(e)
+ if err != nil {
+ return err
+ }
// ***** END: PublicKey *****
// ***** START: GPGPublicKey *****
diff --git a/modules/cron/tasks_extended.go b/modules/cron/tasks_extended.go
index fa2d6e0c38..f0742eb471 100644
--- a/modules/cron/tasks_extended.go
+++ b/modules/cron/tasks_extended.go
@@ -67,6 +67,16 @@ func registerRewriteAllPublicKeys() {
})
}
+func registerRewriteAllPrincipalKeys() {
+ RegisterTaskFatal("resync_all_sshprincipals", &BaseConfig{
+ Enabled: false,
+ RunAtStart: false,
+ Schedule: "@every 72h",
+ }, func(_ context.Context, _ *models.User, _ Config) error {
+ return models.RewriteAllPrincipalKeys()
+ })
+}
+
func registerRepositoryUpdateHook() {
RegisterTaskFatal("resync_all_hooks", &BaseConfig{
Enabled: false,
@@ -112,6 +122,7 @@ func initExtendedTasks() {
registerDeleteRepositoryArchives()
registerGarbageCollectRepositories()
registerRewriteAllPublicKeys()
+ registerRewriteAllPrincipalKeys()
registerRepositoryUpdateHook()
registerReinitMissingRepositories()
registerDeleteMissingRepositories()
diff --git a/modules/setting/setting.go b/modules/setting/setting.go
index 52a14e0d28..8088cffcdf 100644
--- a/modules/setting/setting.go
+++ b/modules/setting/setting.go
@@ -28,6 +28,7 @@ import (
shellquote "github.com/kballard/go-shellquote"
"github.com/unknwon/com"
+ gossh "golang.org/x/crypto/ssh"
ini "gopkg.in/ini.v1"
"strk.kbt.io/projects/go/libravatar"
)
@@ -103,24 +104,31 @@ var (
StaticURLPrefix string
SSH = struct {
- Disabled bool `ini:"DISABLE_SSH"`
- StartBuiltinServer bool `ini:"START_SSH_SERVER"`
- BuiltinServerUser string `ini:"BUILTIN_SSH_SERVER_USER"`
- Domain string `ini:"SSH_DOMAIN"`
- Port int `ini:"SSH_PORT"`
- ListenHost string `ini:"SSH_LISTEN_HOST"`
- ListenPort int `ini:"SSH_LISTEN_PORT"`
- RootPath string `ini:"SSH_ROOT_PATH"`
- ServerCiphers []string `ini:"SSH_SERVER_CIPHERS"`
- ServerKeyExchanges []string `ini:"SSH_SERVER_KEY_EXCHANGES"`
- ServerMACs []string `ini:"SSH_SERVER_MACS"`
- KeyTestPath string `ini:"SSH_KEY_TEST_PATH"`
- KeygenPath string `ini:"SSH_KEYGEN_PATH"`
- AuthorizedKeysBackup bool `ini:"SSH_AUTHORIZED_KEYS_BACKUP"`
- MinimumKeySizeCheck bool `ini:"-"`
- MinimumKeySizes map[string]int `ini:"-"`
- CreateAuthorizedKeysFile bool `ini:"SSH_CREATE_AUTHORIZED_KEYS_FILE"`
- ExposeAnonymous bool `ini:"SSH_EXPOSE_ANONYMOUS"`
+ Disabled bool `ini:"DISABLE_SSH"`
+ StartBuiltinServer bool `ini:"START_SSH_SERVER"`
+ BuiltinServerUser string `ini:"BUILTIN_SSH_SERVER_USER"`
+ Domain string `ini:"SSH_DOMAIN"`
+ Port int `ini:"SSH_PORT"`
+ ListenHost string `ini:"SSH_LISTEN_HOST"`
+ ListenPort int `ini:"SSH_LISTEN_PORT"`
+ RootPath string `ini:"SSH_ROOT_PATH"`
+ ServerCiphers []string `ini:"SSH_SERVER_CIPHERS"`
+ ServerKeyExchanges []string `ini:"SSH_SERVER_KEY_EXCHANGES"`
+ ServerMACs []string `ini:"SSH_SERVER_MACS"`
+ KeyTestPath string `ini:"SSH_KEY_TEST_PATH"`
+ KeygenPath string `ini:"SSH_KEYGEN_PATH"`
+ AuthorizedKeysBackup bool `ini:"SSH_AUTHORIZED_KEYS_BACKUP"`
+ AuthorizedPrincipalsBackup bool `ini:"SSH_AUTHORIZED_PRINCIPALS_BACKUP"`
+ MinimumKeySizeCheck bool `ini:"-"`
+ MinimumKeySizes map[string]int `ini:"-"`
+ CreateAuthorizedKeysFile bool `ini:"SSH_CREATE_AUTHORIZED_KEYS_FILE"`
+ CreateAuthorizedPrincipalsFile bool `ini:"SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE"`
+ ExposeAnonymous bool `ini:"SSH_EXPOSE_ANONYMOUS"`
+ AuthorizedPrincipalsAllow []string `ini:"SSH_AUTHORIZED_PRINCIPALS_ALLOW"`
+ AuthorizedPrincipalsEnabled bool `ini:"-"`
+ TrustedUserCAKeys []string `ini:"SSH_TRUSTED_USER_CA_KEYS"`
+ TrustedUserCAKeysFile string `ini:"SSH_TRUSTED_USER_CA_KEYS_FILENAME"`
+ TrustedUserCAKeysParsed []gossh.PublicKey `ini:"-"`
}{
Disabled: false,
StartBuiltinServer: false,
@@ -672,12 +680,38 @@ func NewContext() {
SSH.StartBuiltinServer = false
}
+ trustedUserCaKeys := sec.Key("SSH_TRUSTED_USER_CA_KEYS").Strings(",")
+ for _, caKey := range trustedUserCaKeys {
+ pubKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(caKey))
+ if err != nil {
+ log.Fatal("Failed to parse TrustedUserCaKeys: %s %v", caKey, err)
+ }
+
+ SSH.TrustedUserCAKeysParsed = append(SSH.TrustedUserCAKeysParsed, pubKey)
+ }
+ if len(trustedUserCaKeys) > 0 {
+ // Set the default as email,username otherwise we can leave it empty
+ sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").MustString("username,email")
+ } else {
+ sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").MustString("off")
+ }
+
+ SSH.AuthorizedPrincipalsAllow, SSH.AuthorizedPrincipalsEnabled = parseAuthorizedPrincipalsAllow(sec.Key("SSH_AUTHORIZED_PRINCIPALS_ALLOW").Strings(","))
+
if !SSH.Disabled && !SSH.StartBuiltinServer {
if err := os.MkdirAll(SSH.RootPath, 0700); err != nil {
log.Fatal("Failed to create '%s': %v", SSH.RootPath, err)
} else if err = os.MkdirAll(SSH.KeyTestPath, 0644); err != nil {
log.Fatal("Failed to create '%s': %v", SSH.KeyTestPath, err)
}
+
+ if len(trustedUserCaKeys) > 0 && SSH.AuthorizedPrincipalsEnabled {
+ fname := sec.Key("SSH_TRUSTED_USER_CA_KEYS_FILENAME").MustString(filepath.Join(SSH.RootPath, "gitea-trusted-user-ca-keys.pem"))
+ if err := ioutil.WriteFile(fname,
+ []byte(strings.Join(trustedUserCaKeys, "\n")), 0600); err != nil {
+ log.Fatal("Failed to create '%s': %v", fname, err)
+ }
+ }
}
SSH.MinimumKeySizeCheck = sec.Key("MINIMUM_KEY_SIZE_CHECK").MustBool(SSH.MinimumKeySizeCheck)
@@ -689,8 +723,17 @@ func NewContext() {
delete(SSH.MinimumKeySizes, strings.ToLower(key.Name()))
}
}
+
SSH.AuthorizedKeysBackup = sec.Key("SSH_AUTHORIZED_KEYS_BACKUP").MustBool(true)
SSH.CreateAuthorizedKeysFile = sec.Key("SSH_CREATE_AUTHORIZED_KEYS_FILE").MustBool(true)
+
+ SSH.AuthorizedPrincipalsBackup = false
+ SSH.CreateAuthorizedPrincipalsFile = false
+ if SSH.AuthorizedPrincipalsEnabled {
+ SSH.AuthorizedPrincipalsBackup = sec.Key("SSH_AUTHORIZED_PRINCIPALS_BACKUP").MustBool(true)
+ SSH.CreateAuthorizedPrincipalsFile = sec.Key("SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE").MustBool(true)
+ }
+
SSH.ExposeAnonymous = sec.Key("SSH_EXPOSE_ANONYMOUS").MustBool(false)
if err = Cfg.Section("oauth2").MapTo(&OAuth2); err != nil {
@@ -944,6 +987,38 @@ func NewContext() {
}
}
+func parseAuthorizedPrincipalsAllow(values []string) ([]string, bool) {
+ anything := false
+ email := false
+ username := false
+ for _, value := range values {
+ v := strings.ToLower(strings.TrimSpace(value))
+ switch v {
+ case "off":
+ return []string{"off"}, false
+ case "email":
+ email = true
+ case "username":
+ username = true
+ case "anything":
+ anything = true
+ }
+ }
+ if anything {
+ return []string{"anything"}, true
+ }
+
+ authorizedPrincipalsAllow := []string{}
+ if username {
+ authorizedPrincipalsAllow = append(authorizedPrincipalsAllow, "username")
+ }
+ if email {
+ authorizedPrincipalsAllow = append(authorizedPrincipalsAllow, "email")
+ }
+
+ return authorizedPrincipalsAllow, true
+}
+
func loadInternalToken(sec *ini.Section) string {
uri := sec.Key("INTERNAL_TOKEN_URI").String()
if len(uri) == 0 {
diff --git a/modules/ssh/ssh.go b/modules/ssh/ssh.go
index e7a694683a..7a449dd41b 100644
--- a/modules/ssh/ssh.go
+++ b/modules/ssh/ssh.go
@@ -5,6 +5,7 @@
package ssh
import (
+ "bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
@@ -136,6 +137,52 @@ func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool {
return false
}
+ // check if we have a certificate
+ if cert, ok := key.(*gossh.Certificate); ok {
+ if len(setting.SSH.TrustedUserCAKeys) == 0 {
+ return false
+ }
+
+ // look for the exact principal
+ for _, principal := range cert.ValidPrincipals {
+ pkey, err := models.SearchPublicKeyByContentExact(principal)
+ if err != nil {
+ log.Error("SearchPublicKeyByContentExact: %v", err)
+ return false
+ }
+
+ if models.IsErrKeyNotExist(err) {
+ continue
+ }
+
+ c := &gossh.CertChecker{
+ IsUserAuthority: func(auth gossh.PublicKey) bool {
+ for _, k := range setting.SSH.TrustedUserCAKeysParsed {
+ if bytes.Equal(auth.Marshal(), k.Marshal()) {
+ return true
+ }
+ }
+
+ return false
+ },
+ }
+
+ // check the CA of the cert
+ if !c.IsUserAuthority(cert.SignatureKey) {
+ return false
+ }
+
+ // validate the cert for this principal
+ if err := c.CheckCert(principal, cert); err != nil {
+ return false
+ }
+
+ ctx.SetValue(giteaKeyID, pkey.ID)
+
+ return true
+ }
+ }
+
pkey, err := models.SearchPublicKeyByContent(strings.TrimSpace(string(gossh.MarshalAuthorizedKey(key))))
if err != nil {
log.Error("SearchPublicKeyByContent: %v", err)
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 9acc9b8bf6..45feaf8c04 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -383,6 +383,7 @@ cannot_add_org_to_team = An organization cannot be added as a team member.
invalid_ssh_key = Can not verify your SSH key: %s
invalid_gpg_key = Can not verify your GPG key: %s
+invalid_ssh_principal = Invalid principal: %s
unable_verify_ssh_key = "Can not verify the SSH key; double-check it for mistakes."
auth_failed = Authentication failed: %v
@@ -501,9 +502,11 @@ keep_email_private_popup = Your email address will be hidden from other users.
openid_desc = OpenID lets you delegate authentication to an external provider.
manage_ssh_keys = Manage SSH Keys
+manage_ssh_principals = Manage SSH Certificate Principals
manage_gpg_keys = Manage GPG Keys
add_key = Add Key
ssh_desc = These public SSH keys are associated with your account. The corresponding private keys allow full access to your repositories.
+principal_desc = These SSH certificate principals are associated with your account and allow full access to your repositories.
gpg_desc = These public GPG keys are associated with your account. Keep your private keys safe as they allow commits to be verified.
ssh_helper = <strong>Need help?</strong> Have a look at GitHub's guide to <a href="%s">create your own SSH keys</a> or solve <a href="%s">common problems</a> you may encounter using SSH.
gpg_helper = <strong>Need help?</strong> Have a look at GitHub's guide <a href="%s">about GPG</a>.
@@ -511,23 +514,30 @@ add_new_key = Add SSH Key
add_new_gpg_key = Add GPG Key
key_content_ssh_placeholder = Begins with 'ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', or 'ecdsa-sha2-nistp521'
key_content_gpg_placeholder = Begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'
+add_new_principal = Add Principal
ssh_key_been_used = This SSH key has already been added to the server.
-ssh_key_name_used = An SSH key with same name is already added to your account.
+ssh_key_name_used = An SSH key with same name already exists on your account.
+ssh_principal_been_used = This principal has already been added to the server.
gpg_key_id_used = A public GPG key with same ID already exists.
gpg_no_key_email_found = This GPG key is not usable with any email address associated with your account.
subkeys = Subkeys
key_id = Key ID
key_name = Key Name
key_content = Content
+principal_content = Content
add_key_success = The SSH key '%s' has been added.
add_gpg_key_success = The GPG key '%s' has been added.
+add_principal_success = The SSH certificate principal '%s' has been added.
delete_key = Remove
ssh_key_deletion = Remove SSH Key
gpg_key_deletion = Remove GPG Key
+ssh_principal_deletion = Remove SSH Certificate Principal
ssh_key_deletion_desc = Removing an SSH key revokes its access to your account. Continue?
gpg_key_deletion_desc = Removing a GPG key un-verifies commits signed by it. Continue?
+ssh_principal_deletion_desc = Removing a SSH Certificate Principal revokes its access to your account. Continue?
ssh_key_deletion_success = The SSH key has been removed.
gpg_key_deletion_success = The GPG key has been removed.
+ssh_principal_deletion_success = The principal has been removed.
add_on = Added on
valid_until = Valid until
valid_forever = Valid forever
@@ -537,10 +547,10 @@ can_read_info = Read
can_write_info = Write
key_state_desc = This key has been used in the last 7 days
token_state_desc = This token has been used in the last 7 days
+principal_state_desc = This principal has been used in the last 7 days
show_openid = Show on profile
hide_openid = Hide from profile
ssh_disabled = SSH Disabled
-
manage_social = Manage Associated Social Accounts
social_desc = These social accounts are linked to your Gitea account. Make sure you recognize all of them as they can be used to sign in to your Gitea account.
unbind = Unlink
@@ -1994,6 +2004,8 @@ dashboard.update_migration_poster_id = Update migration poster IDs
dashboard.git_gc_repos = Garbage collect all repositories
dashboard.resync_all_sshkeys = Update the '.ssh/authorized_keys' file with Gitea SSH keys.
dashboard.resync_all_sshkeys.desc = (Not needed for the built-in SSH server.)
+dashboard.resync_all_sshprincipals = Update the '.ssh/authorized_principals' file with Gitea SSH principals.
+dashboard.resync_all_sshprincipals.desc = (Not needed for the built-in SSH server.)
dashboard.resync_all_hooks = Resynchronize pre-receive, update and post-receive hooks of all repositories.
dashboard.reinit_missing_repos = Reinitialize all missing Git repositories for which records exist
dashboard.sync_external_users = Synchronize external user data
diff --git a/routers/private/serv.go b/routers/private/serv.go
index f463ff6828..79683c2826 100644
--- a/routers/private/serv.go
+++ b/routers/private/serv.go
@@ -46,7 +46,7 @@ func ServNoCommand(ctx *macaron.Context) {
}
results.Key = key
- if key.Type == models.KeyTypeUser {
+ if key.Type == models.KeyTypeUser || key.Type == models.KeyTypePrincipal {
user, err := models.GetUserByID(key.OwnerID)
if err != nil {
if models.IsErrUserNotExist(err) {
diff --git a/routers/user/setting/keys.go b/routers/user/setting/keys.go
index a7978fe14e..6a39666e94 100644
--- a/routers/user/setting/keys.go
+++ b/routers/user/setting/keys.go
@@ -22,6 +22,8 @@ func Keys(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsKeys"] = true
ctx.Data["DisableSSH"] = setting.SSH.Disabled
+ ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer
+ ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled
loadKeysData(ctx)
@@ -32,6 +34,9 @@ func Keys(ctx *context.Context) {
func KeysPost(ctx *context.Context, form auth.AddKeyForm) {
ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsKeys"] = true
+ ctx.Data["DisableSSH"] = setting.SSH.Disabled
+ ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer
+ ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled
if ctx.HasError() {
loadKeysData(ctx)
@@ -40,6 +45,32 @@ func KeysPost(ctx *context.Context, form auth.AddKeyForm) {
return
}
switch form.Type {
+ case "principal":
+ content, err := models.CheckPrincipalKeyString(ctx.User, form.Content)
+ if err != nil {
+ if models.IsErrSSHDisabled(err) {
+ ctx.Flash.Info(ctx.Tr("settings.ssh_disabled"))
+ } else {
+ ctx.Flash.Error(ctx.Tr("form.invalid_ssh_principal", err.Error()))
+ }
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
+ return
+ }
+ if _, err = models.AddPrincipalKey(ctx.User.ID, content, 0); err != nil {
+ ctx.Data["HasPrincipalError"] = true
+ switch {
+ case models.IsErrKeyAlreadyExist(err), models.IsErrKeyNameAlreadyUsed(err):
+ loadKeysData(ctx)
+
+ ctx.Data["Err_Content"] = true
+ ctx.RenderWithErr(ctx.Tr("settings.ssh_principal_been_used"), tplSettingsKeys, &form)
+ default:
+ ctx.ServerError("AddPrincipalKey", err)
+ }
+ return
+ }
+ ctx.Flash.Success(ctx.Tr("settings.add_principal_success", form.Content))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
case "gpg":
keys, err := models.AddGPGKey(ctx.User.ID, form.Content)
if err != nil {
@@ -134,6 +165,12 @@ func DeleteKey(ctx *context.Context) {
} else {
ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success"))
}
+ case "principal":
+ if err := models.DeletePublicKey(ctx.User, ctx.QueryInt64("id")); err != nil {
+ ctx.Flash.Error("DeletePublicKey: " + err.Error())
+ } else {
+ ctx.Flash.Success(ctx.Tr("settings.ssh_principal_deletion_success"))
+ }
default:
ctx.Flash.Warning("Function not implemented")
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
@@ -157,4 +194,11 @@ func loadKeysData(ctx *context.Context) {
return
}
ctx.Data["GPGKeys"] = gpgkeys
+
+ principals, err := models.ListPrincipalKeys(ctx.User.ID, models.ListOptions{})
+ if err != nil {
+ ctx.ServerError("ListPrincipalKeys", err)
+ return
+ }
+ ctx.Data["Principals"] = principals
}
diff --git a/templates/admin/dashboard.tmpl b/templates/admin/dashboard.tmpl
index d52f82fb63..911cdb9fef 100644
--- a/templates/admin/dashboard.tmpl
+++ b/templates/admin/dashboard.tmpl
@@ -43,6 +43,11 @@
</tr>
{{end}}
<tr>
+ <td>{{.i18n.Tr "admin.dashboard.resync_all_sshprincipals"}}<br/>
+ {{.i18n.Tr "admin.dashboard.resync_all_sshprincipals.desc"}}</td>
+ <td><button type="submit" class="ui green button" name="op" value="resync_all_sshprincipals">{{svg "octicon-play" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
+ </tr>
+ <tr>
<td>{{.i18n.Tr "admin.dashboard.resync_all_hooks"}}</td>
<td><button type="submit" class="ui green button" name="op" value="resync_all_hooks">{{svg "octicon-play"}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
</tr>
diff --git a/templates/user/settings/keys.tmpl b/templates/user/settings/keys.tmpl
index 0a1d380f6c..3653761ac5 100644
--- a/templates/user/settings/keys.tmpl
+++ b/templates/user/settings/keys.tmpl
@@ -4,6 +4,7 @@
<div class="ui container">
{{template "base/alert" .}}
{{template "user/settings/keys_ssh" .}}
+ {{template "user/settings/keys_principal" .}}
{{template "user/settings/keys_gpg" .}}
</div>
</div>
diff --git a/templates/user/settings/keys_principal.tmpl b/templates/user/settings/keys_principal.tmpl
new file mode 100644
index 0000000000..c163263ea9
--- /dev/null
+++ b/templates/user/settings/keys_principal.tmpl
@@ -0,0 +1,67 @@
+{{if .AllowPrincipals}}
+ <h4 class="ui top attached header">
+ {{.i18n.Tr "settings.manage_ssh_principals"}}
+ <div class="ui right">
+ {{if not .DisableSSH}}
+ <div class="ui blue tiny show-panel button" data-panel="#add-ssh-principal-panel">{{.i18n.Tr "settings.add_new_principal"}}</div>
+ {{else}}
+ <div class="ui blue tiny button disabled">{{.i18n.Tr "settings.ssh_disabled"}}</div>
+ {{end}}
+ </div>
+ </h4>
+ <div class="ui attached segment">
+ <div class="ui key list">
+ <div class="item">
+ {{.i18n.Tr "settings.principal_desc"}}
+ </div>
+ {{range .Principals}}
+ <div class="item">
+ <div class="right floated content">
+ <button class="ui red tiny button delete-button" id="delete-principal" data-url="{{$.Link}}/delete?type=principal" data-id="{{.ID}}">
+ {{$.i18n.Tr "settings.delete_key"}}
+ </button>
+ </div>
+ <i class="big send icon {{if .HasRecentActivity}}green{{end}}" {{if .HasRecentActivity}}data-content="{{$.i18n.Tr "settings.principal_state_desc"}}" data-variation="inverted tiny"{{end}}></i>
+ <div class="content">
+ <strong>{{.Name}}</strong>
+ <div class="activity meta">
+ <i>{{$.i18n.Tr "settings.add_on"}} <span>{{.CreatedUnix.FormatShort}}</span> — {{svg "octicon-info" 16}} {{if .HasUsed}}{{$.i18n.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="green"{{end}}>{{.UpdatedUnix.FormatShort}}</span>{{else}}{{$.i18n.Tr "settings.no_activity"}}{{end}}</i>
+ </div>
+ </div>
+ </div>
+ {{end}}
+ </div>
+ </div>
+ <br>
+
+ <div {{if not .HasPrincipalError}}class="hide"{{end}} id="add-ssh-principal-panel">
+ <h4 class="ui top attached header">
+ {{.i18n.Tr "settings.add_new_principal"}}
+ </h4>
+ <div class="ui attached segment">
+ <form class="ui form" action="{{.Link}}" method="post">
+ {{.CsrfTokenHtml}}
+ <div class="field {{if .Err_Content}}error{{end}}">
+ <label for="content">{{.i18n.Tr "settings.principal_content"}}</label>
+ <input id="ssh-principal-content" name="content" value="{{.content}}" autofocus required>
+ </div>
+ <input name="title" type="hidden" value="principal">
+ <input name="type" type="hidden" value="principal">
+ <button class="ui green button">
+ {{.i18n.Tr "settings.add_new_principal"}}
+ </button>
+ </form>
+ </div>
+ </div>
+
+ <div class="ui small basic delete modal" id="delete-principal">
+ <div class="ui icon header">
+ <i class="trash icon"></i>
+ {{.i18n.Tr "settings.ssh_principal_deletion"}}
+ </div>
+ <div class="content">
+ <p>{{.i18n.Tr "settings.ssh_principal_deletion_desc"}}</p>
+ </div>
+ {{template "base/delete_modal_actions" .}}
+ </div>
+{{end}}