diff --git a/cmd/serv.go b/cmd/serv.go index 193838800..1b41a5a07 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 bc678c193..dc273ced8 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- ". or "ssh- , ssh- ". +; 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 2c688ef4e..82a9c0221 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 36d5af1ae..3bd667be6 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`: **\**: 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- ` or `ssh- , ssh- `. 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 b46ff76b9..d67981398 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 63ce6ffdf..6c57dd473 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 fa2d6e0c3..f0742eb47 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 52a14e0d2..8088cffcd 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 e7a694683..7a449dd41 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 9acc9b8bf..45feaf8c0 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 = Need help? Have a look at GitHub's guide to create your own SSH keys or solve common problems you may encounter using SSH. gpg_helper = Need help? Have a look at GitHub's guide about GPG. @@ -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 f463ff682..79683c282 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 a7978fe14..6a39666e9 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 d52f82fb6..911cdb9fe 100644 --- a/templates/admin/dashboard.tmpl +++ b/templates/admin/dashboard.tmpl @@ -42,6 +42,11 @@ {{end}} + + {{.i18n.Tr "admin.dashboard.resync_all_sshprincipals"}}
+ {{.i18n.Tr "admin.dashboard.resync_all_sshprincipals.desc"}} + + {{.i18n.Tr "admin.dashboard.resync_all_hooks"}} diff --git a/templates/user/settings/keys.tmpl b/templates/user/settings/keys.tmpl index 0a1d380f6..3653761ac 100644 --- a/templates/user/settings/keys.tmpl +++ b/templates/user/settings/keys.tmpl @@ -4,6 +4,7 @@
{{template "base/alert" .}} {{template "user/settings/keys_ssh" .}} + {{template "user/settings/keys_principal" .}} {{template "user/settings/keys_gpg" .}}
diff --git a/templates/user/settings/keys_principal.tmpl b/templates/user/settings/keys_principal.tmpl new file mode 100644 index 000000000..c163263ea --- /dev/null +++ b/templates/user/settings/keys_principal.tmpl @@ -0,0 +1,67 @@ +{{if .AllowPrincipals}} +

+ {{.i18n.Tr "settings.manage_ssh_principals"}} +
+ {{if not .DisableSSH}} +
{{.i18n.Tr "settings.add_new_principal"}}
+ {{else}} +
{{.i18n.Tr "settings.ssh_disabled"}}
+ {{end}} +
+

+
+
+
+ {{.i18n.Tr "settings.principal_desc"}} +
+ {{range .Principals}} +
+
+ +
+ +
+ {{.Name}} +
+ {{$.i18n.Tr "settings.add_on"}} {{.CreatedUnix.FormatShort}} — {{svg "octicon-info" 16}} {{if .HasUsed}}{{$.i18n.Tr "settings.last_used"}} {{.UpdatedUnix.FormatShort}}{{else}}{{$.i18n.Tr "settings.no_activity"}}{{end}} +
+
+
+ {{end}} +
+
+
+ +
+

+ {{.i18n.Tr "settings.add_new_principal"}} +

+
+
+ {{.CsrfTokenHtml}} +
+ + +
+ + + +
+
+
+ + +{{end}}