summaryrefslogtreecommitdiffstats
path: root/models/asymkey
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
commitdd136858f1ea40ad3c94191d647487fa4f31926c (patch)
tree58fec94a7b2a12510c9664b21793f1ed560c6518 /models/asymkey
parentInitial commit. (diff)
downloadforgejo-debian.tar.xz
forgejo-debian.zip
Adding upstream version 9.0.0.upstream/9.0.0upstreamdebian
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'models/asymkey')
-rw-r--r--models/asymkey/error.go318
-rw-r--r--models/asymkey/gpg_key.go273
-rw-r--r--models/asymkey/gpg_key_add.go167
-rw-r--r--models/asymkey/gpg_key_commit_verification.go63
-rw-r--r--models/asymkey/gpg_key_common.go146
-rw-r--r--models/asymkey/gpg_key_import.go47
-rw-r--r--models/asymkey/gpg_key_list.go38
-rw-r--r--models/asymkey/gpg_key_object_verification.go520
-rw-r--r--models/asymkey/gpg_key_tag_verification.go15
-rw-r--r--models/asymkey/gpg_key_test.go466
-rw-r--r--models/asymkey/gpg_key_verify.go119
-rw-r--r--models/asymkey/main_test.go24
-rw-r--r--models/asymkey/ssh_key.go427
-rw-r--r--models/asymkey/ssh_key_authorized_keys.go220
-rw-r--r--models/asymkey/ssh_key_authorized_principals.go142
-rw-r--r--models/asymkey/ssh_key_deploy.go218
-rw-r--r--models/asymkey/ssh_key_fingerprint.go89
-rw-r--r--models/asymkey/ssh_key_object_verification.go85
-rw-r--r--models/asymkey/ssh_key_object_verification_test.go153
-rw-r--r--models/asymkey/ssh_key_parse.go312
-rw-r--r--models/asymkey/ssh_key_principals.go96
-rw-r--r--models/asymkey/ssh_key_test.go513
-rw-r--r--models/asymkey/ssh_key_verify.go55
23 files changed, 4506 insertions, 0 deletions
diff --git a/models/asymkey/error.go b/models/asymkey/error.go
new file mode 100644
index 0000000..03bc823
--- /dev/null
+++ b/models/asymkey/error.go
@@ -0,0 +1,318 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "fmt"
+
+ "code.gitea.io/gitea/modules/util"
+)
+
+// ErrKeyUnableVerify represents a "KeyUnableVerify" kind of error.
+type ErrKeyUnableVerify struct {
+ Result string
+}
+
+// IsErrKeyUnableVerify checks if an error is a ErrKeyUnableVerify.
+func IsErrKeyUnableVerify(err error) bool {
+ _, ok := err.(ErrKeyUnableVerify)
+ return ok
+}
+
+func (err ErrKeyUnableVerify) Error() string {
+ return fmt.Sprintf("Unable to verify key content [result: %s]", err.Result)
+}
+
+// ErrKeyIsPrivate is returned when the provided key is a private key not a public key
+var ErrKeyIsPrivate = util.NewSilentWrapErrorf(util.ErrInvalidArgument, "the provided key is a private key")
+
+// ErrKeyNotExist represents a "KeyNotExist" kind of error.
+type ErrKeyNotExist struct {
+ ID int64
+}
+
+// IsErrKeyNotExist checks if an error is a ErrKeyNotExist.
+func IsErrKeyNotExist(err error) bool {
+ _, ok := err.(ErrKeyNotExist)
+ return ok
+}
+
+func (err ErrKeyNotExist) Error() string {
+ return fmt.Sprintf("public key does not exist [id: %d]", err.ID)
+}
+
+func (err ErrKeyNotExist) Unwrap() error {
+ return util.ErrNotExist
+}
+
+// ErrKeyAlreadyExist represents a "KeyAlreadyExist" kind of error.
+type ErrKeyAlreadyExist struct {
+ OwnerID int64
+ Fingerprint string
+ Content string
+}
+
+// IsErrKeyAlreadyExist checks if an error is a ErrKeyAlreadyExist.
+func IsErrKeyAlreadyExist(err error) bool {
+ _, ok := err.(ErrKeyAlreadyExist)
+ return ok
+}
+
+func (err ErrKeyAlreadyExist) Error() string {
+ return fmt.Sprintf("public key already exists [owner_id: %d, finger_print: %s, content: %s]",
+ err.OwnerID, err.Fingerprint, err.Content)
+}
+
+func (err ErrKeyAlreadyExist) Unwrap() error {
+ return util.ErrAlreadyExist
+}
+
+// ErrKeyNameAlreadyUsed represents a "KeyNameAlreadyUsed" kind of error.
+type ErrKeyNameAlreadyUsed struct {
+ OwnerID int64
+ Name string
+}
+
+// IsErrKeyNameAlreadyUsed checks if an error is a ErrKeyNameAlreadyUsed.
+func IsErrKeyNameAlreadyUsed(err error) bool {
+ _, ok := err.(ErrKeyNameAlreadyUsed)
+ return ok
+}
+
+func (err ErrKeyNameAlreadyUsed) Error() string {
+ return fmt.Sprintf("public key already exists [owner_id: %d, name: %s]", err.OwnerID, err.Name)
+}
+
+func (err ErrKeyNameAlreadyUsed) Unwrap() error {
+ return util.ErrAlreadyExist
+}
+
+// ErrGPGNoEmailFound represents a "ErrGPGNoEmailFound" kind of error.
+type ErrGPGNoEmailFound struct {
+ FailedEmails []string
+ ID string
+}
+
+// IsErrGPGNoEmailFound checks if an error is a ErrGPGNoEmailFound.
+func IsErrGPGNoEmailFound(err error) bool {
+ _, ok := err.(ErrGPGNoEmailFound)
+ return ok
+}
+
+func (err ErrGPGNoEmailFound) Error() string {
+ return fmt.Sprintf("none of the emails attached to the GPG key could be found: %v", err.FailedEmails)
+}
+
+// ErrGPGInvalidTokenSignature represents a "ErrGPGInvalidTokenSignature" kind of error.
+type ErrGPGInvalidTokenSignature struct {
+ Wrapped error
+ ID string
+}
+
+// IsErrGPGInvalidTokenSignature checks if an error is a ErrGPGInvalidTokenSignature.
+func IsErrGPGInvalidTokenSignature(err error) bool {
+ _, ok := err.(ErrGPGInvalidTokenSignature)
+ return ok
+}
+
+func (err ErrGPGInvalidTokenSignature) Error() string {
+ return "the provided signature does not sign the token with the provided key"
+}
+
+// ErrGPGKeyParsing represents a "ErrGPGKeyParsing" kind of error.
+type ErrGPGKeyParsing struct {
+ ParseError error
+}
+
+// IsErrGPGKeyParsing checks if an error is a ErrGPGKeyParsing.
+func IsErrGPGKeyParsing(err error) bool {
+ _, ok := err.(ErrGPGKeyParsing)
+ return ok
+}
+
+func (err ErrGPGKeyParsing) Error() string {
+ return fmt.Sprintf("failed to parse gpg key %s", err.ParseError.Error())
+}
+
+// ErrGPGKeyNotExist represents a "GPGKeyNotExist" kind of error.
+type ErrGPGKeyNotExist struct {
+ ID int64
+}
+
+// IsErrGPGKeyNotExist checks if an error is a ErrGPGKeyNotExist.
+func IsErrGPGKeyNotExist(err error) bool {
+ _, ok := err.(ErrGPGKeyNotExist)
+ return ok
+}
+
+func (err ErrGPGKeyNotExist) Error() string {
+ return fmt.Sprintf("public gpg key does not exist [id: %d]", err.ID)
+}
+
+func (err ErrGPGKeyNotExist) Unwrap() error {
+ return util.ErrNotExist
+}
+
+// ErrGPGKeyImportNotExist represents a "GPGKeyImportNotExist" kind of error.
+type ErrGPGKeyImportNotExist struct {
+ ID string
+}
+
+// IsErrGPGKeyImportNotExist checks if an error is a ErrGPGKeyImportNotExist.
+func IsErrGPGKeyImportNotExist(err error) bool {
+ _, ok := err.(ErrGPGKeyImportNotExist)
+ return ok
+}
+
+func (err ErrGPGKeyImportNotExist) Error() string {
+ return fmt.Sprintf("public gpg key import does not exist [id: %s]", err.ID)
+}
+
+func (err ErrGPGKeyImportNotExist) Unwrap() error {
+ return util.ErrNotExist
+}
+
+// ErrGPGKeyIDAlreadyUsed represents a "GPGKeyIDAlreadyUsed" kind of error.
+type ErrGPGKeyIDAlreadyUsed struct {
+ KeyID string
+}
+
+// IsErrGPGKeyIDAlreadyUsed checks if an error is a ErrKeyNameAlreadyUsed.
+func IsErrGPGKeyIDAlreadyUsed(err error) bool {
+ _, ok := err.(ErrGPGKeyIDAlreadyUsed)
+ return ok
+}
+
+func (err ErrGPGKeyIDAlreadyUsed) Error() string {
+ return fmt.Sprintf("public key already exists [key_id: %s]", err.KeyID)
+}
+
+func (err ErrGPGKeyIDAlreadyUsed) Unwrap() error {
+ return util.ErrAlreadyExist
+}
+
+// ErrGPGKeyAccessDenied represents a "GPGKeyAccessDenied" kind of Error.
+type ErrGPGKeyAccessDenied struct {
+ UserID int64
+ KeyID int64
+}
+
+// IsErrGPGKeyAccessDenied checks if an error is a ErrGPGKeyAccessDenied.
+func IsErrGPGKeyAccessDenied(err error) bool {
+ _, ok := err.(ErrGPGKeyAccessDenied)
+ return ok
+}
+
+// Error pretty-prints an error of type ErrGPGKeyAccessDenied.
+func (err ErrGPGKeyAccessDenied) Error() string {
+ return fmt.Sprintf("user does not have access to the key [user_id: %d, key_id: %d]",
+ err.UserID, err.KeyID)
+}
+
+func (err ErrGPGKeyAccessDenied) Unwrap() error {
+ return util.ErrPermissionDenied
+}
+
+// ErrKeyAccessDenied represents a "KeyAccessDenied" kind of error.
+type ErrKeyAccessDenied struct {
+ UserID int64
+ KeyID int64
+ Note string
+}
+
+// IsErrKeyAccessDenied checks if an error is a ErrKeyAccessDenied.
+func IsErrKeyAccessDenied(err error) bool {
+ _, ok := err.(ErrKeyAccessDenied)
+ return ok
+}
+
+func (err ErrKeyAccessDenied) Error() string {
+ return fmt.Sprintf("user does not have access to the key [user_id: %d, key_id: %d, note: %s]",
+ err.UserID, err.KeyID, err.Note)
+}
+
+func (err ErrKeyAccessDenied) Unwrap() error {
+ return util.ErrPermissionDenied
+}
+
+// ErrDeployKeyNotExist represents a "DeployKeyNotExist" kind of error.
+type ErrDeployKeyNotExist struct {
+ ID int64
+ KeyID int64
+ RepoID int64
+}
+
+// IsErrDeployKeyNotExist checks if an error is a ErrDeployKeyNotExist.
+func IsErrDeployKeyNotExist(err error) bool {
+ _, ok := err.(ErrDeployKeyNotExist)
+ return ok
+}
+
+func (err ErrDeployKeyNotExist) Error() string {
+ return fmt.Sprintf("Deploy key does not exist [id: %d, key_id: %d, repo_id: %d]", err.ID, err.KeyID, err.RepoID)
+}
+
+func (err ErrDeployKeyNotExist) Unwrap() error {
+ return util.ErrNotExist
+}
+
+// ErrDeployKeyAlreadyExist represents a "DeployKeyAlreadyExist" kind of error.
+type ErrDeployKeyAlreadyExist struct {
+ KeyID int64
+ RepoID int64
+}
+
+// IsErrDeployKeyAlreadyExist checks if an error is a ErrDeployKeyAlreadyExist.
+func IsErrDeployKeyAlreadyExist(err error) bool {
+ _, ok := err.(ErrDeployKeyAlreadyExist)
+ return ok
+}
+
+func (err ErrDeployKeyAlreadyExist) Error() string {
+ return fmt.Sprintf("public key already exists [key_id: %d, repo_id: %d]", err.KeyID, err.RepoID)
+}
+
+func (err ErrDeployKeyAlreadyExist) Unwrap() error {
+ return util.ErrAlreadyExist
+}
+
+// ErrDeployKeyNameAlreadyUsed represents a "DeployKeyNameAlreadyUsed" kind of error.
+type ErrDeployKeyNameAlreadyUsed struct {
+ RepoID int64
+ Name string
+}
+
+// IsErrDeployKeyNameAlreadyUsed checks if an error is a ErrDeployKeyNameAlreadyUsed.
+func IsErrDeployKeyNameAlreadyUsed(err error) bool {
+ _, ok := err.(ErrDeployKeyNameAlreadyUsed)
+ return ok
+}
+
+func (err ErrDeployKeyNameAlreadyUsed) Error() string {
+ return fmt.Sprintf("public key with name already exists [repo_id: %d, name: %s]", err.RepoID, err.Name)
+}
+
+func (err ErrDeployKeyNameAlreadyUsed) Unwrap() error {
+ return util.ErrNotExist
+}
+
+// ErrSSHInvalidTokenSignature represents a "ErrSSHInvalidTokenSignature" kind of error.
+type ErrSSHInvalidTokenSignature struct {
+ Wrapped error
+ Fingerprint string
+}
+
+// IsErrSSHInvalidTokenSignature checks if an error is a ErrSSHInvalidTokenSignature.
+func IsErrSSHInvalidTokenSignature(err error) bool {
+ _, ok := err.(ErrSSHInvalidTokenSignature)
+ return ok
+}
+
+func (err ErrSSHInvalidTokenSignature) Error() string {
+ return "the provided signature does not sign the token with the provided key"
+}
+
+func (err ErrSSHInvalidTokenSignature) Unwrap() error {
+ return util.ErrInvalidArgument
+}
diff --git a/models/asymkey/gpg_key.go b/models/asymkey/gpg_key.go
new file mode 100644
index 0000000..6e2914e
--- /dev/null
+++ b/models/asymkey/gpg_key.go
@@ -0,0 +1,273 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "github.com/ProtonMail/go-crypto/openpgp"
+ "github.com/ProtonMail/go-crypto/openpgp/packet"
+ "xorm.io/builder"
+)
+
+// GPGKey represents a GPG key.
+type GPGKey struct {
+ ID int64 `xorm:"pk autoincr"`
+ OwnerID int64 `xorm:"INDEX NOT NULL"`
+ KeyID string `xorm:"INDEX CHAR(16) NOT NULL"`
+ PrimaryKeyID string `xorm:"CHAR(16)"`
+ Content string `xorm:"MEDIUMTEXT NOT NULL"`
+ CreatedUnix timeutil.TimeStamp `xorm:"created"`
+ ExpiredUnix timeutil.TimeStamp
+ AddedUnix timeutil.TimeStamp
+ SubsKey []*GPGKey `xorm:"-"`
+ Emails []*user_model.EmailAddress
+ Verified bool `xorm:"NOT NULL DEFAULT false"`
+ CanSign bool
+ CanEncryptComms bool
+ CanEncryptStorage bool
+ CanCertify bool
+}
+
+func init() {
+ db.RegisterModel(new(GPGKey))
+}
+
+// BeforeInsert will be invoked by XORM before inserting a record
+func (key *GPGKey) BeforeInsert() {
+ key.AddedUnix = timeutil.TimeStampNow()
+}
+
+func (key *GPGKey) LoadSubKeys(ctx context.Context) error {
+ if err := db.GetEngine(ctx).Where("primary_key_id=?", key.KeyID).Find(&key.SubsKey); err != nil {
+ return fmt.Errorf("find Sub GPGkeys[%s]: %v", key.KeyID, err)
+ }
+ return nil
+}
+
+// PaddedKeyID show KeyID padded to 16 characters
+func (key *GPGKey) PaddedKeyID() string {
+ return PaddedKeyID(key.KeyID)
+}
+
+// PaddedKeyID show KeyID padded to 16 characters
+func PaddedKeyID(keyID string) string {
+ if len(keyID) > 15 {
+ return keyID
+ }
+ zeros := "0000000000000000"
+ return zeros[0:16-len(keyID)] + keyID
+}
+
+type FindGPGKeyOptions struct {
+ db.ListOptions
+ OwnerID int64
+ KeyID string
+ IncludeSubKeys bool
+}
+
+func (opts FindGPGKeyOptions) ToConds() builder.Cond {
+ cond := builder.NewCond()
+ if !opts.IncludeSubKeys {
+ cond = cond.And(builder.Eq{"primary_key_id": ""})
+ }
+
+ if opts.OwnerID > 0 {
+ cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
+ }
+ if opts.KeyID != "" {
+ cond = cond.And(builder.Eq{"key_id": opts.KeyID})
+ }
+ return cond
+}
+
+func GetGPGKeyForUserByID(ctx context.Context, ownerID, keyID int64) (*GPGKey, error) {
+ key := new(GPGKey)
+ has, err := db.GetEngine(ctx).Where("id=? AND owner_id=?", keyID, ownerID).Get(key)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrGPGKeyNotExist{keyID}
+ }
+ return key, nil
+}
+
+// GPGKeyToEntity retrieve the imported key and the traducted entity
+func GPGKeyToEntity(ctx context.Context, k *GPGKey) (*openpgp.Entity, error) {
+ impKey, err := GetGPGImportByKeyID(ctx, k.KeyID)
+ if err != nil {
+ return nil, err
+ }
+ keys, err := checkArmoredGPGKeyString(impKey.Content)
+ if err != nil {
+ return nil, err
+ }
+ return keys[0], err
+}
+
+// parseSubGPGKey parse a sub Key
+func parseSubGPGKey(ownerID int64, primaryID string, pubkey *packet.PublicKey, expiry time.Time) (*GPGKey, error) {
+ content, err := base64EncPubKey(pubkey)
+ if err != nil {
+ return nil, err
+ }
+ return &GPGKey{
+ OwnerID: ownerID,
+ KeyID: pubkey.KeyIdString(),
+ PrimaryKeyID: primaryID,
+ Content: content,
+ CreatedUnix: timeutil.TimeStamp(pubkey.CreationTime.Unix()),
+ ExpiredUnix: timeutil.TimeStamp(expiry.Unix()),
+ CanSign: pubkey.CanSign(),
+ CanEncryptComms: pubkey.PubKeyAlgo.CanEncrypt(),
+ CanEncryptStorage: pubkey.PubKeyAlgo.CanEncrypt(),
+ CanCertify: pubkey.PubKeyAlgo.CanSign(),
+ }, nil
+}
+
+// parseGPGKey parse a PrimaryKey entity (primary key + subs keys + self-signature)
+func parseGPGKey(ctx context.Context, ownerID int64, e *openpgp.Entity, verified bool) (*GPGKey, error) {
+ pubkey := e.PrimaryKey
+ expiry := getExpiryTime(e)
+
+ // Parse Subkeys
+ subkeys := make([]*GPGKey, len(e.Subkeys))
+ for i, k := range e.Subkeys {
+ subKeyExpiry := expiry
+ if k.Sig.KeyLifetimeSecs != nil {
+ subKeyExpiry = k.PublicKey.CreationTime.Add(time.Duration(*k.Sig.KeyLifetimeSecs) * time.Second)
+ }
+
+ subs, err := parseSubGPGKey(ownerID, pubkey.KeyIdString(), k.PublicKey, subKeyExpiry)
+ if err != nil {
+ return nil, ErrGPGKeyParsing{ParseError: err}
+ }
+ subkeys[i] = subs
+ }
+
+ // Check emails
+ userEmails, err := user_model.GetEmailAddresses(ctx, ownerID)
+ if err != nil {
+ return nil, err
+ }
+
+ emails := make([]*user_model.EmailAddress, 0, len(e.Identities))
+ for _, ident := range e.Identities {
+ // Check if the identity is revoked.
+ if ident.Revoked(time.Now()) {
+ continue
+ }
+ email := strings.ToLower(strings.TrimSpace(ident.UserId.Email))
+ for _, e := range userEmails {
+ if e.IsActivated && e.LowerEmail == email {
+ emails = append(emails, e)
+ break
+ }
+ }
+ }
+
+ if !verified {
+ // In the case no email as been found
+ if len(emails) == 0 {
+ failedEmails := make([]string, 0, len(e.Identities))
+ for _, ident := range e.Identities {
+ failedEmails = append(failedEmails, ident.UserId.Email)
+ }
+ return nil, ErrGPGNoEmailFound{failedEmails, e.PrimaryKey.KeyIdString()}
+ }
+ }
+
+ content, err := base64EncPubKey(pubkey)
+ if err != nil {
+ return nil, err
+ }
+ return &GPGKey{
+ OwnerID: ownerID,
+ KeyID: pubkey.KeyIdString(),
+ PrimaryKeyID: "",
+ Content: content,
+ CreatedUnix: timeutil.TimeStamp(pubkey.CreationTime.Unix()),
+ ExpiredUnix: timeutil.TimeStamp(expiry.Unix()),
+ Emails: emails,
+ SubsKey: subkeys,
+ Verified: verified,
+ CanSign: pubkey.CanSign(),
+ CanEncryptComms: pubkey.PubKeyAlgo.CanEncrypt(),
+ CanEncryptStorage: pubkey.PubKeyAlgo.CanEncrypt(),
+ CanCertify: pubkey.PubKeyAlgo.CanSign(),
+ }, nil
+}
+
+// deleteGPGKey does the actual key deletion
+func deleteGPGKey(ctx context.Context, keyID string) (int64, error) {
+ if keyID == "" {
+ return 0, fmt.Errorf("empty KeyId forbidden") // Should never happen but just to be sure
+ }
+ // Delete imported key
+ n, err := db.GetEngine(ctx).Where("key_id=?", keyID).Delete(new(GPGKeyImport))
+ if err != nil {
+ return n, err
+ }
+ return db.GetEngine(ctx).Where("key_id=?", keyID).Or("primary_key_id=?", keyID).Delete(new(GPGKey))
+}
+
+// DeleteGPGKey deletes GPG key information in database.
+func DeleteGPGKey(ctx context.Context, doer *user_model.User, id int64) (err error) {
+ key, err := GetGPGKeyForUserByID(ctx, doer.ID, id)
+ if err != nil {
+ if IsErrGPGKeyNotExist(err) {
+ return nil
+ }
+ return fmt.Errorf("GetPublicKeyByID: %w", err)
+ }
+
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ if _, err = deleteGPGKey(ctx, key.KeyID); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+func checkKeyEmails(ctx context.Context, email string, keys ...*GPGKey) (bool, string) {
+ uid := int64(0)
+ var userEmails []*user_model.EmailAddress
+ var user *user_model.User
+ for _, key := range keys {
+ for _, e := range key.Emails {
+ if e.IsActivated && (email == "" || strings.EqualFold(e.Email, email)) {
+ return true, e.Email
+ }
+ }
+ if key.Verified && key.OwnerID != 0 {
+ if uid != key.OwnerID {
+ userEmails, _ = user_model.GetEmailAddresses(ctx, key.OwnerID)
+ uid = key.OwnerID
+ user = &user_model.User{ID: uid}
+ _, _ = user_model.GetUser(ctx, user)
+ }
+ for _, e := range userEmails {
+ if e.IsActivated && (email == "" || strings.EqualFold(e.Email, email)) {
+ return true, e.Email
+ }
+ }
+ if user.KeepEmailPrivate && strings.EqualFold(email, user.GetEmail()) {
+ return true, user.GetEmail()
+ }
+ }
+ }
+ return false, email
+}
diff --git a/models/asymkey/gpg_key_add.go b/models/asymkey/gpg_key_add.go
new file mode 100644
index 0000000..6c0f6e0
--- /dev/null
+++ b/models/asymkey/gpg_key_add.go
@@ -0,0 +1,167 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "context"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/ProtonMail/go-crypto/openpgp"
+)
+
+// __________________ ________ ____ __.
+// / _____/\______ \/ _____/ | |/ _|____ ___.__.
+// / \ ___ | ___/ \ ___ | <_/ __ < | |
+// \ \_\ \| | \ \_\ \ | | \ ___/\___ |
+// \______ /|____| \______ / |____|__ \___ > ____|
+// \/ \/ \/ \/\/
+// _____ .___ .___
+// / _ \ __| _/__| _/
+// / /_\ \ / __ |/ __ |
+// / | \/ /_/ / /_/ |
+// \____|__ /\____ \____ |
+// \/ \/ \/
+
+// This file contains functions relating to adding GPG Keys
+
+// addGPGKey add key, import and subkeys to database
+func addGPGKey(ctx context.Context, key *GPGKey, content string) (err error) {
+ // Add GPGKeyImport
+ if err = db.Insert(ctx, &GPGKeyImport{
+ KeyID: key.KeyID,
+ Content: content,
+ }); err != nil {
+ return err
+ }
+ // Save GPG primary key.
+ if err = db.Insert(ctx, key); err != nil {
+ return err
+ }
+ // Save GPG subs key.
+ for _, subkey := range key.SubsKey {
+ if err := addGPGSubKey(ctx, subkey); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// addGPGSubKey add subkeys to database
+func addGPGSubKey(ctx context.Context, key *GPGKey) (err error) {
+ // Save GPG primary key.
+ if err = db.Insert(ctx, key); err != nil {
+ return err
+ }
+ // Save GPG subs key.
+ for _, subkey := range key.SubsKey {
+ if err := addGPGSubKey(ctx, subkey); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// AddGPGKey adds new public key to database.
+func AddGPGKey(ctx context.Context, ownerID int64, content, token, signature string) ([]*GPGKey, error) {
+ ekeys, err := checkArmoredGPGKeyString(content)
+ if err != nil {
+ return nil, err
+ }
+
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer committer.Close()
+
+ keys := make([]*GPGKey, 0, len(ekeys))
+
+ verified := false
+ // Handle provided signature
+ if signature != "" {
+ signer, err := openpgp.CheckArmoredDetachedSignature(ekeys, strings.NewReader(token), strings.NewReader(signature), nil)
+ if err != nil {
+ signer, err = openpgp.CheckArmoredDetachedSignature(ekeys, strings.NewReader(token+"\n"), strings.NewReader(signature), nil)
+ }
+ if err != nil {
+ signer, err = openpgp.CheckArmoredDetachedSignature(ekeys, strings.NewReader(token+"\r\n"), strings.NewReader(signature), nil)
+ }
+ if err != nil {
+ log.Error("Unable to validate token signature. Error: %v", err)
+ return nil, ErrGPGInvalidTokenSignature{
+ ID: ekeys[0].PrimaryKey.KeyIdString(),
+ Wrapped: err,
+ }
+ }
+ ekeys = []*openpgp.Entity{signer}
+ verified = true
+ }
+
+ if len(ekeys) > 1 {
+ id2key := map[string]*openpgp.Entity{}
+ newEKeys := make([]*openpgp.Entity, 0, len(ekeys))
+ for _, ekey := range ekeys {
+ id := ekey.PrimaryKey.KeyIdString()
+ if original, has := id2key[id]; has {
+ // Coalesce this with the other one
+ for _, subkey := range ekey.Subkeys {
+ if subkey.PublicKey == nil {
+ continue
+ }
+ found := false
+
+ for _, originalSubkey := range original.Subkeys {
+ if originalSubkey.PublicKey == nil {
+ continue
+ }
+ if originalSubkey.PublicKey.KeyId == subkey.PublicKey.KeyId {
+ found = true
+ break
+ }
+ }
+ if !found {
+ original.Subkeys = append(original.Subkeys, subkey)
+ }
+ }
+ for name, identity := range ekey.Identities {
+ if _, has := original.Identities[name]; has {
+ continue
+ }
+ original.Identities[name] = identity
+ }
+ continue
+ }
+ id2key[id] = ekey
+ newEKeys = append(newEKeys, ekey)
+ }
+ ekeys = newEKeys
+ }
+
+ for _, ekey := range ekeys {
+ // Key ID cannot be duplicated.
+ has, err := db.GetEngine(ctx).Where("key_id=?", ekey.PrimaryKey.KeyIdString()).
+ Get(new(GPGKey))
+ if err != nil {
+ return nil, err
+ } else if has {
+ return nil, ErrGPGKeyIDAlreadyUsed{ekey.PrimaryKey.KeyIdString()}
+ }
+
+ // Get DB session
+
+ key, err := parseGPGKey(ctx, ownerID, ekey, verified)
+ if err != nil {
+ return nil, err
+ }
+
+ if err = addGPGKey(ctx, key, content); err != nil {
+ return nil, err
+ }
+ keys = append(keys, key)
+ }
+ return keys, committer.Commit()
+}
diff --git a/models/asymkey/gpg_key_commit_verification.go b/models/asymkey/gpg_key_commit_verification.go
new file mode 100644
index 0000000..9aa6064
--- /dev/null
+++ b/models/asymkey/gpg_key_commit_verification.go
@@ -0,0 +1,63 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "context"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+)
+
+// __________________ ________ ____ __.
+// / _____/\______ \/ _____/ | |/ _|____ ___.__.
+// / \ ___ | ___/ \ ___ | <_/ __ < | |
+// \ \_\ \| | \ \_\ \ | | \ ___/\___ |
+// \______ /|____| \______ / |____|__ \___ > ____|
+// \/ \/ \/ \/\/
+// _________ .__ __
+// \_ ___ \ ____ _____ _____ |__|/ |_
+// / \ \/ / _ \ / \ / \| \ __\
+// \ \___( <_> ) Y Y \ Y Y \ || |
+// \______ /\____/|__|_| /__|_| /__||__|
+// \/ \/ \/
+// ____ ____ .__ _____.__ __ .__
+// \ \ / /___________|__|/ ____\__| ____ _____ _/ |_|__| ____ ____
+// \ Y // __ \_ __ \ \ __\| |/ ___\\__ \\ __\ |/ _ \ / \
+// \ /\ ___/| | \/ || | | \ \___ / __ \| | | ( <_> ) | \
+// \___/ \___ >__| |__||__| |__|\___ >____ /__| |__|\____/|___| /
+// \/ \/ \/ \/
+
+// This file provides functions relating commit verification
+
+// SignCommit represents a commit with validation of signature.
+type SignCommit struct {
+ Verification *ObjectVerification
+ *user_model.UserCommit
+}
+
+// ParseCommitsWithSignature checks if signaute of commits are corresponding to users gpg keys.
+func ParseCommitsWithSignature(ctx context.Context, oldCommits []*user_model.UserCommit, repoTrustModel repo_model.TrustModelType, isOwnerMemberCollaborator func(*user_model.User) (bool, error)) []*SignCommit {
+ newCommits := make([]*SignCommit, 0, len(oldCommits))
+ keyMap := map[string]bool{}
+
+ for _, c := range oldCommits {
+ o := commitToGitObject(c.Commit)
+ signCommit := &SignCommit{
+ UserCommit: c,
+ Verification: ParseObjectWithSignature(ctx, &o),
+ }
+
+ _ = CalculateTrustStatus(signCommit.Verification, repoTrustModel, isOwnerMemberCollaborator, &keyMap)
+
+ newCommits = append(newCommits, signCommit)
+ }
+ return newCommits
+}
+
+func ParseCommitWithSignature(ctx context.Context, c *git.Commit) *ObjectVerification {
+ o := commitToGitObject(c)
+ return ParseObjectWithSignature(ctx, &o)
+}
diff --git a/models/asymkey/gpg_key_common.go b/models/asymkey/gpg_key_common.go
new file mode 100644
index 0000000..db1912c
--- /dev/null
+++ b/models/asymkey/gpg_key_common.go
@@ -0,0 +1,146 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "bytes"
+ "crypto"
+ "encoding/base64"
+ "fmt"
+ "hash"
+ "io"
+ "strings"
+ "time"
+
+ "github.com/ProtonMail/go-crypto/openpgp"
+ "github.com/ProtonMail/go-crypto/openpgp/armor"
+ "github.com/ProtonMail/go-crypto/openpgp/packet"
+)
+
+// __________________ ________ ____ __.
+// / _____/\______ \/ _____/ | |/ _|____ ___.__.
+// / \ ___ | ___/ \ ___ | <_/ __ < | |
+// \ \_\ \| | \ \_\ \ | | \ ___/\___ |
+// \______ /|____| \______ / |____|__ \___ > ____|
+// \/ \/ \/ \/\/
+// _________
+// \_ ___ \ ____ _____ _____ ____ ____
+// / \ \/ / _ \ / \ / \ / _ \ / \
+// \ \___( <_> ) Y Y \ Y Y ( <_> ) | \
+// \______ /\____/|__|_| /__|_| /\____/|___| /
+// \/ \/ \/ \/
+
+// This file provides common functions relating to GPG Keys
+
+// checkArmoredGPGKeyString checks if the given key string is a valid GPG armored key.
+// The function returns the actual public key on success
+func checkArmoredGPGKeyString(content string) (openpgp.EntityList, error) {
+ list, err := openpgp.ReadArmoredKeyRing(strings.NewReader(content))
+ if err != nil {
+ return nil, ErrGPGKeyParsing{err}
+ }
+ return list, nil
+}
+
+// base64EncPubKey encode public key content to base 64
+func base64EncPubKey(pubkey *packet.PublicKey) (string, error) {
+ var w bytes.Buffer
+ err := pubkey.Serialize(&w)
+ if err != nil {
+ return "", err
+ }
+ return base64.StdEncoding.EncodeToString(w.Bytes()), nil
+}
+
+func readerFromBase64(s string) (io.Reader, error) {
+ bs, err := base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ return nil, err
+ }
+ return bytes.NewBuffer(bs), nil
+}
+
+// base64DecPubKey decode public key content from base 64
+func base64DecPubKey(content string) (*packet.PublicKey, error) {
+ b, err := readerFromBase64(content)
+ if err != nil {
+ return nil, err
+ }
+ // Read key
+ p, err := packet.Read(b)
+ if err != nil {
+ return nil, err
+ }
+ // Check type
+ pkey, ok := p.(*packet.PublicKey)
+ if !ok {
+ return nil, fmt.Errorf("key is not a public key")
+ }
+ return pkey, nil
+}
+
+// getExpiryTime extract the expire time of primary key based on sig
+func getExpiryTime(e *openpgp.Entity) time.Time {
+ expiry := time.Time{}
+ // Extract self-sign for expire date based on : https://github.com/golang/crypto/blob/master/openpgp/keys.go#L165
+ var selfSig *packet.Signature
+ for _, ident := range e.Identities {
+ if selfSig == nil {
+ selfSig = ident.SelfSignature
+ } else if ident.SelfSignature != nil && ident.SelfSignature.IsPrimaryId != nil && *ident.SelfSignature.IsPrimaryId {
+ selfSig = ident.SelfSignature
+ break
+ }
+ }
+ if selfSig.KeyLifetimeSecs != nil {
+ expiry = e.PrimaryKey.CreationTime.Add(time.Duration(*selfSig.KeyLifetimeSecs) * time.Second)
+ }
+ return expiry
+}
+
+func populateHash(hashFunc crypto.Hash, msg []byte) (hash.Hash, error) {
+ h := hashFunc.New()
+ if _, err := h.Write(msg); err != nil {
+ return nil, err
+ }
+ return h, nil
+}
+
+// readArmoredSign read an armored signature block with the given type. https://sourcegraph.com/github.com/golang/crypto/-/blob/openpgp/read.go#L24:6-24:17
+func readArmoredSign(r io.Reader) (body io.Reader, err error) {
+ block, err := armor.Decode(r)
+ if err != nil {
+ return nil, err
+ }
+ if block.Type != openpgp.SignatureType {
+ return nil, fmt.Errorf("expected %q, got: %s", openpgp.SignatureType, block.Type)
+ }
+ return block.Body, nil
+}
+
+func extractSignature(s string) (*packet.Signature, error) {
+ r, err := readArmoredSign(strings.NewReader(s))
+ if err != nil {
+ return nil, fmt.Errorf("Failed to read signature armor")
+ }
+ p, err := packet.Read(r)
+ if err != nil {
+ return nil, fmt.Errorf("Failed to read signature packet")
+ }
+ sig, ok := p.(*packet.Signature)
+ if !ok {
+ return nil, fmt.Errorf("Packet is not a signature")
+ }
+ return sig, nil
+}
+
+func tryGetKeyIDFromSignature(sig *packet.Signature) string {
+ if sig.IssuerKeyId != nil && (*sig.IssuerKeyId) != 0 {
+ return fmt.Sprintf("%016X", *sig.IssuerKeyId)
+ }
+ if len(sig.IssuerFingerprint) > 0 {
+ return fmt.Sprintf("%016X", sig.IssuerFingerprint[12:20])
+ }
+ return ""
+}
diff --git a/models/asymkey/gpg_key_import.go b/models/asymkey/gpg_key_import.go
new file mode 100644
index 0000000..c9d46d2
--- /dev/null
+++ b/models/asymkey/gpg_key_import.go
@@ -0,0 +1,47 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/db"
+)
+
+// __________________ ________ ____ __.
+// / _____/\______ \/ _____/ | |/ _|____ ___.__.
+// / \ ___ | ___/ \ ___ | <_/ __ < | |
+// \ \_\ \| | \ \_\ \ | | \ ___/\___ |
+// \______ /|____| \______ / |____|__ \___ > ____|
+// \/ \/ \/ \/\/
+// .___ __
+// | | _____ ______ ____________/ |_
+// | |/ \\____ \ / _ \_ __ \ __\
+// | | Y Y \ |_> > <_> ) | \/| |
+// |___|__|_| / __/ \____/|__| |__|
+// \/|__|
+
+// This file contains functions related to the original import of a key
+
+// GPGKeyImport the original import of key
+type GPGKeyImport struct {
+ KeyID string `xorm:"pk CHAR(16) NOT NULL"`
+ Content string `xorm:"MEDIUMTEXT NOT NULL"`
+}
+
+func init() {
+ db.RegisterModel(new(GPGKeyImport))
+}
+
+// GetGPGImportByKeyID returns the import public armored key by given KeyID.
+func GetGPGImportByKeyID(ctx context.Context, keyID string) (*GPGKeyImport, error) {
+ key := new(GPGKeyImport)
+ has, err := db.GetEngine(ctx).ID(keyID).Get(key)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrGPGKeyImportNotExist{keyID}
+ }
+ return key, nil
+}
diff --git a/models/asymkey/gpg_key_list.go b/models/asymkey/gpg_key_list.go
new file mode 100644
index 0000000..89548e4
--- /dev/null
+++ b/models/asymkey/gpg_key_list.go
@@ -0,0 +1,38 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/db"
+)
+
+type GPGKeyList []*GPGKey
+
+func (keys GPGKeyList) keyIDs() []string {
+ ids := make([]string, len(keys))
+ for i, key := range keys {
+ ids[i] = key.KeyID
+ }
+ return ids
+}
+
+func (keys GPGKeyList) LoadSubKeys(ctx context.Context) error {
+ subKeys := make([]*GPGKey, 0, len(keys))
+ if err := db.GetEngine(ctx).In("primary_key_id", keys.keyIDs()).Find(&subKeys); err != nil {
+ return err
+ }
+ subKeysMap := make(map[string][]*GPGKey, len(subKeys))
+ for _, key := range subKeys {
+ subKeysMap[key.PrimaryKeyID] = append(subKeysMap[key.PrimaryKeyID], key)
+ }
+
+ for _, key := range keys {
+ if subKeys, ok := subKeysMap[key.KeyID]; ok {
+ key.SubsKey = subKeys
+ }
+ }
+ return nil
+}
diff --git a/models/asymkey/gpg_key_object_verification.go b/models/asymkey/gpg_key_object_verification.go
new file mode 100644
index 0000000..24d72a5
--- /dev/null
+++ b/models/asymkey/gpg_key_object_verification.go
@@ -0,0 +1,520 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "context"
+ "fmt"
+ "hash"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/ProtonMail/go-crypto/openpgp/packet"
+)
+
+// This file provides functions related to object (commit, tag) verification
+
+// ObjectVerification represents a commit validation of signature
+type ObjectVerification struct {
+ Verified bool
+ Warning bool
+ Reason string
+ SigningUser *user_model.User
+ CommittingUser *user_model.User
+ SigningEmail string
+ SigningKey *GPGKey
+ SigningSSHKey *PublicKey
+ TrustStatus string
+}
+
+const (
+ // BadSignature is used as the reason when the signature has a KeyID that is in the db
+ // but no key that has that ID verifies the signature. This is a suspicious failure.
+ BadSignature = "gpg.error.probable_bad_signature"
+ // BadDefaultSignature is used as the reason when the signature has a KeyID that matches the
+ // default Key but is not verified by the default key. This is a suspicious failure.
+ BadDefaultSignature = "gpg.error.probable_bad_default_signature"
+ // NoKeyFound is used as the reason when no key can be found to verify the signature.
+ NoKeyFound = "gpg.error.no_gpg_keys_found"
+)
+
+type GitObject struct {
+ ID git.ObjectID
+ Committer *git.Signature
+ Signature *git.ObjectSignature
+ Commit *git.Commit
+}
+
+func commitToGitObject(c *git.Commit) GitObject {
+ return GitObject{
+ ID: c.ID,
+ Committer: c.Committer,
+ Signature: c.Signature,
+ Commit: c,
+ }
+}
+
+func tagToGitObject(t *git.Tag, gitRepo *git.Repository) GitObject {
+ commit, _ := t.Commit(gitRepo)
+ return GitObject{
+ ID: t.ID,
+ Committer: t.Tagger,
+ Signature: t.Signature,
+ Commit: commit,
+ }
+}
+
+// ParseObjectWithSignature check if signature is good against keystore.
+func ParseObjectWithSignature(ctx context.Context, c *GitObject) *ObjectVerification {
+ var committer *user_model.User
+ if c.Committer != nil {
+ var err error
+ // Find Committer account
+ committer, err = user_model.GetUserByEmail(ctx, c.Committer.Email) // This finds the user by primary email or activated email so commit will not be valid if email is not
+ if err != nil { // Skipping not user for committer
+ committer = &user_model.User{
+ Name: c.Committer.Name,
+ Email: c.Committer.Email,
+ }
+ // We can expect this to often be an ErrUserNotExist. in the case
+ // it is not, however, it is important to log it.
+ if !user_model.IsErrUserNotExist(err) {
+ log.Error("GetUserByEmail: %v", err)
+ return &ObjectVerification{
+ CommittingUser: committer,
+ Verified: false,
+ Reason: "gpg.error.no_committer_account",
+ }
+ }
+ }
+ }
+
+ // If no signature just report the committer
+ if c.Signature == nil {
+ return &ObjectVerification{
+ CommittingUser: committer,
+ Verified: false, // Default value
+ Reason: "gpg.error.not_signed_commit", // Default value
+ }
+ }
+
+ // If this a SSH signature handle it differently
+ if strings.HasPrefix(c.Signature.Signature, "-----BEGIN SSH SIGNATURE-----") {
+ return ParseObjectWithSSHSignature(ctx, c, committer)
+ }
+
+ // Parsing signature
+ sig, err := extractSignature(c.Signature.Signature)
+ if err != nil { // Skipping failed to extract sign
+ log.Error("SignatureRead err: %v", err)
+ return &ObjectVerification{
+ CommittingUser: committer,
+ Verified: false,
+ Reason: "gpg.error.extract_sign",
+ }
+ }
+
+ keyID := tryGetKeyIDFromSignature(sig)
+ defaultReason := NoKeyFound
+
+ // First check if the sig has a keyID and if so just look at that
+ if commitVerification := hashAndVerifyForKeyID(
+ ctx,
+ sig,
+ c.Signature.Payload,
+ committer,
+ keyID,
+ setting.AppName,
+ ""); commitVerification != nil {
+ if commitVerification.Reason == BadSignature {
+ defaultReason = BadSignature
+ } else {
+ return commitVerification
+ }
+ }
+
+ // Now try to associate the signature with the committer, if present
+ if committer.ID != 0 {
+ keys, err := db.Find[GPGKey](ctx, FindGPGKeyOptions{
+ OwnerID: committer.ID,
+ })
+ if err != nil { // Skipping failed to get gpg keys of user
+ log.Error("ListGPGKeys: %v", err)
+ return &ObjectVerification{
+ CommittingUser: committer,
+ Verified: false,
+ Reason: "gpg.error.failed_retrieval_gpg_keys",
+ }
+ }
+
+ if err := GPGKeyList(keys).LoadSubKeys(ctx); err != nil {
+ log.Error("LoadSubKeys: %v", err)
+ return &ObjectVerification{
+ CommittingUser: committer,
+ Verified: false,
+ Reason: "gpg.error.failed_retrieval_gpg_keys",
+ }
+ }
+
+ committerEmailAddresses, _ := user_model.GetEmailAddresses(ctx, committer.ID)
+ activated := false
+ for _, e := range committerEmailAddresses {
+ if e.IsActivated && strings.EqualFold(e.Email, c.Committer.Email) {
+ activated = true
+ break
+ }
+ }
+
+ for _, k := range keys {
+ // Pre-check (& optimization) that emails attached to key can be attached to the committer email and can validate
+ canValidate := false
+ email := ""
+ if k.Verified && activated {
+ canValidate = true
+ email = c.Committer.Email
+ }
+ if !canValidate {
+ for _, e := range k.Emails {
+ if e.IsActivated && strings.EqualFold(e.Email, c.Committer.Email) {
+ canValidate = true
+ email = e.Email
+ break
+ }
+ }
+ }
+ if !canValidate {
+ continue // Skip this key
+ }
+
+ commitVerification := hashAndVerifyWithSubKeysObjectVerification(sig, c.Signature.Payload, k, committer, committer, email)
+ if commitVerification != nil {
+ return commitVerification
+ }
+ }
+ }
+
+ if setting.Repository.Signing.SigningKey != "" && setting.Repository.Signing.SigningKey != "default" && setting.Repository.Signing.SigningKey != "none" {
+ // OK we should try the default key
+ gpgSettings := git.GPGSettings{
+ Sign: true,
+ KeyID: setting.Repository.Signing.SigningKey,
+ Name: setting.Repository.Signing.SigningName,
+ Email: setting.Repository.Signing.SigningEmail,
+ }
+ if err := gpgSettings.LoadPublicKeyContent(); err != nil {
+ log.Error("Error getting default signing key: %s %v", gpgSettings.KeyID, err)
+ } else if commitVerification := verifyWithGPGSettings(ctx, &gpgSettings, sig, c.Signature.Payload, committer, keyID); commitVerification != nil {
+ if commitVerification.Reason == BadSignature {
+ defaultReason = BadSignature
+ } else {
+ return commitVerification
+ }
+ }
+ }
+
+ defaultGPGSettings, err := c.Commit.GetRepositoryDefaultPublicGPGKey(false)
+ if err != nil {
+ log.Error("Error getting default public gpg key: %v", err)
+ } else if defaultGPGSettings == nil {
+ log.Warn("Unable to get defaultGPGSettings for unattached commit: %s", c.Commit.ID.String())
+ } else if defaultGPGSettings.Sign {
+ if commitVerification := verifyWithGPGSettings(ctx, defaultGPGSettings, sig, c.Signature.Payload, committer, keyID); commitVerification != nil {
+ if commitVerification.Reason == BadSignature {
+ defaultReason = BadSignature
+ } else {
+ return commitVerification
+ }
+ }
+ }
+
+ return &ObjectVerification{ // Default at this stage
+ CommittingUser: committer,
+ Verified: false,
+ Warning: defaultReason != NoKeyFound,
+ Reason: defaultReason,
+ SigningKey: &GPGKey{
+ KeyID: keyID,
+ },
+ }
+}
+
+func verifyWithGPGSettings(ctx context.Context, gpgSettings *git.GPGSettings, sig *packet.Signature, payload string, committer *user_model.User, keyID string) *ObjectVerification {
+ // First try to find the key in the db
+ if commitVerification := hashAndVerifyForKeyID(ctx, sig, payload, committer, gpgSettings.KeyID, gpgSettings.Name, gpgSettings.Email); commitVerification != nil {
+ return commitVerification
+ }
+
+ // Otherwise we have to parse the key
+ ekeys, err := checkArmoredGPGKeyString(gpgSettings.PublicKeyContent)
+ if err != nil {
+ log.Error("Unable to get default signing key: %v", err)
+ return &ObjectVerification{
+ CommittingUser: committer,
+ Verified: false,
+ Reason: "gpg.error.generate_hash",
+ }
+ }
+ for _, ekey := range ekeys {
+ pubkey := ekey.PrimaryKey
+ content, err := base64EncPubKey(pubkey)
+ if err != nil {
+ return &ObjectVerification{
+ CommittingUser: committer,
+ Verified: false,
+ Reason: "gpg.error.generate_hash",
+ }
+ }
+ k := &GPGKey{
+ Content: content,
+ CanSign: pubkey.CanSign(),
+ KeyID: pubkey.KeyIdString(),
+ }
+ for _, subKey := range ekey.Subkeys {
+ content, err := base64EncPubKey(subKey.PublicKey)
+ if err != nil {
+ return &ObjectVerification{
+ CommittingUser: committer,
+ Verified: false,
+ Reason: "gpg.error.generate_hash",
+ }
+ }
+ k.SubsKey = append(k.SubsKey, &GPGKey{
+ Content: content,
+ CanSign: subKey.PublicKey.CanSign(),
+ KeyID: subKey.PublicKey.KeyIdString(),
+ })
+ }
+ if commitVerification := hashAndVerifyWithSubKeysObjectVerification(sig, payload, k, committer, &user_model.User{
+ Name: gpgSettings.Name,
+ Email: gpgSettings.Email,
+ }, gpgSettings.Email); commitVerification != nil {
+ return commitVerification
+ }
+ if keyID == k.KeyID {
+ // This is a bad situation ... We have a key id that matches our default key but the signature doesn't match.
+ return &ObjectVerification{
+ CommittingUser: committer,
+ Verified: false,
+ Warning: true,
+ Reason: BadSignature,
+ }
+ }
+ }
+ return nil
+}
+
+func verifySign(s *packet.Signature, h hash.Hash, k *GPGKey) error {
+ // Check if key can sign
+ if !k.CanSign {
+ return fmt.Errorf("key can not sign")
+ }
+ // Decode key
+ pkey, err := base64DecPubKey(k.Content)
+ if err != nil {
+ return err
+ }
+ return pkey.VerifySignature(h, s)
+}
+
+func hashAndVerify(sig *packet.Signature, payload string, k *GPGKey) (*GPGKey, error) {
+ // Generating hash of commit
+ hash, err := populateHash(sig.Hash, []byte(payload))
+ if err != nil { // Skipping as failed to generate hash
+ log.Error("PopulateHash: %v", err)
+ return nil, err
+ }
+ // We will ignore errors in verification as they don't need to be propagated up
+ err = verifySign(sig, hash, k)
+ if err != nil {
+ return nil, nil
+ }
+ return k, nil
+}
+
+func hashAndVerifyWithSubKeys(sig *packet.Signature, payload string, k *GPGKey) (*GPGKey, error) {
+ verified, err := hashAndVerify(sig, payload, k)
+ if err != nil || verified != nil {
+ return verified, err
+ }
+ for _, sk := range k.SubsKey {
+ verified, err := hashAndVerify(sig, payload, sk)
+ if err != nil || verified != nil {
+ return verified, err
+ }
+ }
+ return nil, nil
+}
+
+func hashAndVerifyWithSubKeysObjectVerification(sig *packet.Signature, payload string, k *GPGKey, committer, signer *user_model.User, email string) *ObjectVerification {
+ key, err := hashAndVerifyWithSubKeys(sig, payload, k)
+ if err != nil { // Skipping failed to generate hash
+ return &ObjectVerification{
+ CommittingUser: committer,
+ Verified: false,
+ Reason: "gpg.error.generate_hash",
+ }
+ }
+
+ if key != nil {
+ return &ObjectVerification{ // Everything is ok
+ CommittingUser: committer,
+ Verified: true,
+ Reason: fmt.Sprintf("%s / %s", signer.Name, key.KeyID),
+ SigningUser: signer,
+ SigningKey: key,
+ SigningEmail: email,
+ }
+ }
+ return nil
+}
+
+func hashAndVerifyForKeyID(ctx context.Context, sig *packet.Signature, payload string, committer *user_model.User, keyID, name, email string) *ObjectVerification {
+ if keyID == "" {
+ return nil
+ }
+ keys, err := db.Find[GPGKey](ctx, FindGPGKeyOptions{
+ KeyID: keyID,
+ IncludeSubKeys: true,
+ })
+ if err != nil {
+ log.Error("GetGPGKeysByKeyID: %v", err)
+ return &ObjectVerification{
+ CommittingUser: committer,
+ Verified: false,
+ Reason: "gpg.error.failed_retrieval_gpg_keys",
+ }
+ }
+ if len(keys) == 0 {
+ return nil
+ }
+ for _, key := range keys {
+ var primaryKeys []*GPGKey
+ if key.PrimaryKeyID != "" {
+ primaryKeys, err = db.Find[GPGKey](ctx, FindGPGKeyOptions{
+ KeyID: key.PrimaryKeyID,
+ IncludeSubKeys: true,
+ })
+ if err != nil {
+ log.Error("GetGPGKeysByKeyID: %v", err)
+ return &ObjectVerification{
+ CommittingUser: committer,
+ Verified: false,
+ Reason: "gpg.error.failed_retrieval_gpg_keys",
+ }
+ }
+ }
+
+ activated, email := checkKeyEmails(ctx, email, append([]*GPGKey{key}, primaryKeys...)...)
+ if !activated {
+ continue
+ }
+
+ signer := &user_model.User{
+ Name: name,
+ Email: email,
+ }
+ if key.OwnerID != 0 {
+ owner, err := user_model.GetUserByID(ctx, key.OwnerID)
+ if err == nil {
+ signer = owner
+ } else if !user_model.IsErrUserNotExist(err) {
+ log.Error("Failed to user_model.GetUserByID: %d for key ID: %d (%s) %v", key.OwnerID, key.ID, key.KeyID, err)
+ return &ObjectVerification{
+ CommittingUser: committer,
+ Verified: false,
+ Reason: "gpg.error.no_committer_account",
+ }
+ }
+ }
+ commitVerification := hashAndVerifyWithSubKeysObjectVerification(sig, payload, key, committer, signer, email)
+ if commitVerification != nil {
+ return commitVerification
+ }
+ }
+ // This is a bad situation ... We have a key id that is in our database but the signature doesn't match.
+ return &ObjectVerification{
+ CommittingUser: committer,
+ Verified: false,
+ Warning: true,
+ Reason: BadSignature,
+ }
+}
+
+// CalculateTrustStatus will calculate the TrustStatus for a commit verification within a repository
+// There are several trust models in Gitea
+func CalculateTrustStatus(verification *ObjectVerification, repoTrustModel repo_model.TrustModelType, isOwnerMemberCollaborator func(*user_model.User) (bool, error), keyMap *map[string]bool) error {
+ if !verification.Verified {
+ return nil
+ }
+
+ // In the Committer trust model a signature is trusted if it matches the committer
+ // - it doesn't matter if they're a collaborator, the owner, Gitea or Github
+ // NB: This model is commit verification only
+ if repoTrustModel == repo_model.CommitterTrustModel {
+ // default to "unmatched"
+ verification.TrustStatus = "unmatched"
+
+ // We can only verify against users in our database but the default key will match
+ // against by email if it is not in the db.
+ if (verification.SigningUser.ID != 0 &&
+ verification.CommittingUser.ID == verification.SigningUser.ID) ||
+ (verification.SigningUser.ID == 0 && verification.CommittingUser.ID == 0 &&
+ verification.SigningUser.Email == verification.CommittingUser.Email) {
+ verification.TrustStatus = "trusted"
+ }
+ return nil
+ }
+
+ // Now we drop to the more nuanced trust models...
+ verification.TrustStatus = "trusted"
+
+ if verification.SigningUser.ID == 0 {
+ // This commit is signed by the default key - but this key is not assigned to a user in the DB.
+
+ // However in the repo_model.CollaboratorCommitterTrustModel we cannot mark this as trusted
+ // unless the default key matches the email of a non-user.
+ if repoTrustModel == repo_model.CollaboratorCommitterTrustModel && (verification.CommittingUser.ID != 0 ||
+ verification.SigningUser.Email != verification.CommittingUser.Email) {
+ verification.TrustStatus = "untrusted"
+ }
+ return nil
+ }
+
+ // Check we actually have a GPG SigningKey
+ var err error
+ if verification.SigningKey != nil {
+ var isMember bool
+ if keyMap != nil {
+ var has bool
+ isMember, has = (*keyMap)[verification.SigningKey.KeyID]
+ if !has {
+ isMember, err = isOwnerMemberCollaborator(verification.SigningUser)
+ (*keyMap)[verification.SigningKey.KeyID] = isMember
+ }
+ } else {
+ isMember, err = isOwnerMemberCollaborator(verification.SigningUser)
+ }
+
+ if !isMember {
+ verification.TrustStatus = "untrusted"
+ if verification.CommittingUser.ID != verification.SigningUser.ID {
+ // The committing user and the signing user are not the same
+ // This should be marked as questionable unless the signing user is a collaborator/team member etc.
+ verification.TrustStatus = "unmatched"
+ }
+ } else if repoTrustModel == repo_model.CollaboratorCommitterTrustModel && verification.CommittingUser.ID != verification.SigningUser.ID {
+ // The committing user and the signing user are not the same and our trustmodel states that they must match
+ verification.TrustStatus = "unmatched"
+ }
+ }
+
+ return err
+}
diff --git a/models/asymkey/gpg_key_tag_verification.go b/models/asymkey/gpg_key_tag_verification.go
new file mode 100644
index 0000000..5fd3983
--- /dev/null
+++ b/models/asymkey/gpg_key_tag_verification.go
@@ -0,0 +1,15 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/modules/git"
+)
+
+func ParseTagWithSignature(ctx context.Context, gitRepo *git.Repository, t *git.Tag) *ObjectVerification {
+ o := tagToGitObject(t, gitRepo)
+ return ParseObjectWithSignature(ctx, &o)
+}
diff --git a/models/asymkey/gpg_key_test.go b/models/asymkey/gpg_key_test.go
new file mode 100644
index 0000000..e9aa9cf
--- /dev/null
+++ b/models/asymkey/gpg_key_test.go
@@ -0,0 +1,466 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/ProtonMail/go-crypto/openpgp/packet"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCheckArmoredGPGKeyString(t *testing.T) {
+ testGPGArmor := `-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQENBFh91QoBCADciaDd7aqegYkn4ZIG7J0p1CRwpqMGjxFroJEMg6M1ZiuEVTRv
+z49P4kcr1+98NvFmcNc+x5uJgvPCwr/N8ZW5nqBUs2yrklbFF4MeQomyZJJegP8m
+/dsRT3BwIT8YMUtJuCj0iqD9vuKYfjrztcMgC1sYwcE9E9OlA0pWBvUdU2i0TIB1
+vOq6slWGvHHa5l5gPfm09idlVxfH5+I+L1uIMx5ovbiVVU5x2f1AR1T18f0t2TVN
+0agFTyuoYE1ATmvJHmMcsfgM1Gpd9hIlr9vlupT2kKTPoNzVzsJsOU6Ku/Lf/bac
+mF+TfSbRCtmG7dkYZ4metLj7zG/WkW8IvJARABEBAAG0HUFudG9pbmUgR0lSQVJE
+IDxzYXBrQHNhcGsuZnI+iQFUBBMBCAA+FiEEEIOwJg/1vpF1itJ4roJVuKDYKOQF
+Alh91QoCGwMFCQPCZwAFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AACgkQroJVuKDY
+KORreggAlIkC2QjHP5tb7b0+LksB2JMXdY+UzZBcJxtNmvA7gNQaGvWRrhrbePpa
+MKDP+3A4BPDBsWFbbB7N56vQ5tROpmWbNKuFOVER4S1bj0JZV0E+xkDLqt9QwQtQ
+ojd7oIZJwDUwdud1PvCza2mjgBqqiFE+twbc3i9xjciCGspMniUul1eQYLxRJ0w+
+sbvSOUnujnq5ByMSz9ij00O6aiPfNQS5oB5AALfpjYZDvWAAljLVrtmlQJWZ6dZo
+T/YNwsW2dECPuti8+Nmu5FxPGDTXxdbnRaeJTQ3T6q1oUVAv7yTXBx5NXfXkMa5i
+iEayQIH8Joq5Ev5ja/lRGQQhArMQ2bkBDQRYfdUKAQgAv7B3coLSrOQbuTZSlgWE
+QeT+7DWbmqE1LAQA1pQPcUPXLBUVd60amZJxF9nzUYcY83ylDi0gUNJS+DJGOXpT
+pzX2IOuOMGbtUSeKwg5s9O4SUO7f2yCc3RGaegER5zgESxelmOXG+b/hoNt7JbdU
+JtxcnLr91Jw2PBO/Xf0ZKJ01CQG2Yzdrrj6jnrHyx94seHy0i6xH1o0OuvfVMLfN
+/Vbb/ZHh6ym2wHNqRX62b0VAbchcJXX/MEehXGknKTkO6dDUd+mhRgWMf9ZGRFWx
+ag4qALimkf1FXtAyD0vxFYeyoWUQzrOvUsm2BxIN/986R08fhkBQnp5nz07mrU02
+cQARAQABiQE8BBgBCAAmFiEEEIOwJg/1vpF1itJ4roJVuKDYKOQFAlh91QoCGwwF
+CQPCZwAACgkQroJVuKDYKOT32wf/UZqMdPn5OhyhffFzjQx7wolrf92WkF2JkxtH
+6c3Htjlt/p5RhtKEeErSrNAxB4pqB7dznHaJXiOdWEZtRVXXjlNHjrokGTesqtKk
+lHWtK62/MuyLdr+FdCl68F3ewuT2iu/MDv+D4HPqA47zma9xVgZ9ZNwJOpv3fCOo
+RfY66UjGEnfgYifgtI5S84/mp2jaSc9UNvlZB6RSf8cfbJUL74kS2lq+xzSlf0yP
+Av844q/BfRuVsJsK1NDNG09LC30B0l3LKBqlrRmRTUMHtgchdX2dY+p7GPOoSzlR
+MkM/fdpyc2hY7Dl/+qFmN5MG5yGmMpQcX+RNNR222ibNC1D3wg==
+=i9b7
+-----END PGP PUBLIC KEY BLOCK-----`
+
+ key, err := checkArmoredGPGKeyString(testGPGArmor)
+ require.NoError(t, err, "Could not parse a valid GPG public armored rsa key", key)
+ // TODO verify value of key
+}
+
+func TestCheckArmoredbrainpoolP256r1GPGKeyString(t *testing.T) {
+ testGPGArmor := `-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: GnuPG v2
+
+mFMEV6HwkhMJKyQDAwIIAQEHAgMEUsvJO/j5dFMRRj67qeZC9fSKBsGZdOHRj2+6
+8wssmbUuLTfT/ZjIbExETyY8hFnURRGpD2Ifyz0cKjXcbXfJtrQTRm9vYmFyIDxm
+b29AYmFyLmRlPoh/BBMTCAAnBQJZOsDIAhsDBQkJZgGABQsJCAcCBhUICQoLAgQW
+AgMBAh4BAheAAAoJEGuJTd/DBMzmNVQA/2beUrv1yU4gyvCiPDEm3pK42cSfaL5D
+muCtPCUg9hlWAP4yq6M78NW8STfsXgn6oeziMYiHSTmV14nOamLuwwDWM7hXBFeh
+8JISCSskAwMCCAEBBwIDBG3A+XfINAZp1CTse2mRNgeUE5DbUtEpO8ALXKA1UQsQ
+DLKq27b7zTgawgXIGUGP6mWsJ5oH7MNAJ/uKTsYmX40DAQgHiGcEGBMIAA8FAleh
+8JICGwwFCQlmAYAACgkQa4lN38MEzOZwKAD/QKyerAgcvzzLaqvtap3XvpYcw9tc
+OyjLLnFQiVmq7kEA/0z0CQe3ZQiQIq5zrs7Nh1XRkFAo8GlU/SGC9XFFi722
+=ZiSe
+-----END PGP PUBLIC KEY BLOCK-----`
+
+ key, err := checkArmoredGPGKeyString(testGPGArmor)
+ require.NoError(t, err, "Could not parse a valid GPG public armored brainpoolP256r1 key", key)
+ // TODO verify value of key
+}
+
+func TestExtractSignature(t *testing.T) {
+ testGPGArmor := `-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQENBFh91QoBCADciaDd7aqegYkn4ZIG7J0p1CRwpqMGjxFroJEMg6M1ZiuEVTRv
+z49P4kcr1+98NvFmcNc+x5uJgvPCwr/N8ZW5nqBUs2yrklbFF4MeQomyZJJegP8m
+/dsRT3BwIT8YMUtJuCj0iqD9vuKYfjrztcMgC1sYwcE9E9OlA0pWBvUdU2i0TIB1
+vOq6slWGvHHa5l5gPfm09idlVxfH5+I+L1uIMx5ovbiVVU5x2f1AR1T18f0t2TVN
+0agFTyuoYE1ATmvJHmMcsfgM1Gpd9hIlr9vlupT2kKTPoNzVzsJsOU6Ku/Lf/bac
+mF+TfSbRCtmG7dkYZ4metLj7zG/WkW8IvJARABEBAAG0HUFudG9pbmUgR0lSQVJE
+IDxzYXBrQHNhcGsuZnI+iQFUBBMBCAA+FiEEEIOwJg/1vpF1itJ4roJVuKDYKOQF
+Alh91QoCGwMFCQPCZwAFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AACgkQroJVuKDY
+KORreggAlIkC2QjHP5tb7b0+LksB2JMXdY+UzZBcJxtNmvA7gNQaGvWRrhrbePpa
+MKDP+3A4BPDBsWFbbB7N56vQ5tROpmWbNKuFOVER4S1bj0JZV0E+xkDLqt9QwQtQ
+ojd7oIZJwDUwdud1PvCza2mjgBqqiFE+twbc3i9xjciCGspMniUul1eQYLxRJ0w+
+sbvSOUnujnq5ByMSz9ij00O6aiPfNQS5oB5AALfpjYZDvWAAljLVrtmlQJWZ6dZo
+T/YNwsW2dECPuti8+Nmu5FxPGDTXxdbnRaeJTQ3T6q1oUVAv7yTXBx5NXfXkMa5i
+iEayQIH8Joq5Ev5ja/lRGQQhArMQ2bkBDQRYfdUKAQgAv7B3coLSrOQbuTZSlgWE
+QeT+7DWbmqE1LAQA1pQPcUPXLBUVd60amZJxF9nzUYcY83ylDi0gUNJS+DJGOXpT
+pzX2IOuOMGbtUSeKwg5s9O4SUO7f2yCc3RGaegER5zgESxelmOXG+b/hoNt7JbdU
+JtxcnLr91Jw2PBO/Xf0ZKJ01CQG2Yzdrrj6jnrHyx94seHy0i6xH1o0OuvfVMLfN
+/Vbb/ZHh6ym2wHNqRX62b0VAbchcJXX/MEehXGknKTkO6dDUd+mhRgWMf9ZGRFWx
+ag4qALimkf1FXtAyD0vxFYeyoWUQzrOvUsm2BxIN/986R08fhkBQnp5nz07mrU02
+cQARAQABiQE8BBgBCAAmFiEEEIOwJg/1vpF1itJ4roJVuKDYKOQFAlh91QoCGwwF
+CQPCZwAACgkQroJVuKDYKOT32wf/UZqMdPn5OhyhffFzjQx7wolrf92WkF2JkxtH
+6c3Htjlt/p5RhtKEeErSrNAxB4pqB7dznHaJXiOdWEZtRVXXjlNHjrokGTesqtKk
+lHWtK62/MuyLdr+FdCl68F3ewuT2iu/MDv+D4HPqA47zma9xVgZ9ZNwJOpv3fCOo
+RfY66UjGEnfgYifgtI5S84/mp2jaSc9UNvlZB6RSf8cfbJUL74kS2lq+xzSlf0yP
+Av844q/BfRuVsJsK1NDNG09LC30B0l3LKBqlrRmRTUMHtgchdX2dY+p7GPOoSzlR
+MkM/fdpyc2hY7Dl/+qFmN5MG5yGmMpQcX+RNNR222ibNC1D3wg==
+=i9b7
+-----END PGP PUBLIC KEY BLOCK-----`
+ keys, err := checkArmoredGPGKeyString(testGPGArmor)
+ if !assert.NotEmpty(t, keys) {
+ return
+ }
+ ekey := keys[0]
+ require.NoError(t, err, "Could not parse a valid GPG armored key", ekey)
+
+ pubkey := ekey.PrimaryKey
+ content, err := base64EncPubKey(pubkey)
+ require.NoError(t, err, "Could not base64 encode a valid PublicKey content", ekey)
+
+ key := &GPGKey{
+ KeyID: pubkey.KeyIdString(),
+ Content: content,
+ CreatedUnix: timeutil.TimeStamp(pubkey.CreationTime.Unix()),
+ CanSign: pubkey.CanSign(),
+ CanEncryptComms: pubkey.PubKeyAlgo.CanEncrypt(),
+ CanEncryptStorage: pubkey.PubKeyAlgo.CanEncrypt(),
+ CanCertify: pubkey.PubKeyAlgo.CanSign(),
+ }
+
+ cannotsignkey := &GPGKey{
+ KeyID: pubkey.KeyIdString(),
+ Content: content,
+ CreatedUnix: timeutil.TimeStamp(pubkey.CreationTime.Unix()),
+ CanSign: false,
+ CanEncryptComms: false,
+ CanEncryptStorage: false,
+ CanCertify: false,
+ }
+
+ testGoodSigArmor := `-----BEGIN PGP SIGNATURE-----
+
+iQEzBAABCAAdFiEEEIOwJg/1vpF1itJ4roJVuKDYKOQFAljAiQIACgkQroJVuKDY
+KORvCgf6A/Ehh0r7QbO2tFEghT+/Ab+bN7jRN3zP9ed6/q/ophYmkrU0NibtbJH9
+AwFVdHxCmj78SdiRjaTKyevklXw34nvMftmvnOI4lBNUdw6KWl25/n/7wN0l2oZW
+rW3UawYpZgodXiLTYarfEimkDQmT67ArScjRA6lLbkEYKO0VdwDu+Z6yBUH3GWtm
+45RkXpnsF6AXUfuD7YxnfyyDE1A7g7zj4vVYUAfWukJjqow/LsCUgETETJOqj9q3
+52/oQDs04fVkIEtCDulcY+K/fKlukBPJf9WceNDEqiENUzN/Z1y0E+tJ07cSy4bk
+yIJb+d0OAaG8bxloO7nJq4Res1Qa8Q==
+=puvG
+-----END PGP SIGNATURE-----`
+ testGoodPayload := `tree 56ae8d2799882b20381fc11659db06c16c68c61a
+parent c7870c39e4e6b247235ca005797703ec4254613f
+author Antoine GIRARD <sapk@sapk.fr> 1489012989 +0100
+committer Antoine GIRARD <sapk@sapk.fr> 1489012989 +0100
+
+Goog GPG
+`
+
+ testBadSigArmor := `-----BEGIN PGP SIGNATURE-----
+
+iQEzBAABCAAdFiEE5yr4rn9ulbdMxJFiPYI/ySNrtNkFAljAiYkACgkQPYI/ySNr
+tNmDdQf+NXhVRiOGt0GucpjJCGrOnK/qqVUmQyRUfrqzVUdb/1/Ws84V5/wE547I
+6z3oxeBKFsJa1CtIlxYaUyVhYnDzQtphJzub+Aw3UG0E2ywiE+N7RCa1Ufl7pPxJ
+U0SD6gvNaeTDQV/Wctu8v8DkCtEd3N8cMCDWhvy/FQEDztVtzm8hMe0Vdm0ozEH6
+P0W93sDNkLC5/qpWDN44sFlYDstW5VhMrnF0r/ohfaK2kpYHhkPk7WtOoHSUwQSg
+c4gfhjvXIQrWFnII1Kr5jFGlmgNSR02qpb31VGkMzSnBhWVf2OaHS/kI49QHJakq
+AhVDEnoYLCgoDGg9c3p1Ll2452/c6Q==
+=uoGV
+-----END PGP SIGNATURE-----`
+ testBadPayload := `tree 3074ff04951956a974e8b02d57733b0766f7cf6c
+parent fd3577542f7ad1554c7c7c0eb86bb57a1324ad91
+author Antoine GIRARD <sapk@sapk.fr> 1489013107 +0100
+committer Antoine GIRARD <sapk@sapk.fr> 1489013107 +0100
+
+Unknown GPG key with good email
+`
+ // Reading Sign
+ goodSig, err := extractSignature(testGoodSigArmor)
+ require.NoError(t, err, "Could not parse a valid GPG armored signature", testGoodSigArmor)
+ badSig, err := extractSignature(testBadSigArmor)
+ require.NoError(t, err, "Could not parse a valid GPG armored signature", testBadSigArmor)
+
+ // Generating hash of commit
+ goodHash, err := populateHash(goodSig.Hash, []byte(testGoodPayload))
+ require.NoError(t, err, "Could not generate a valid hash of payload", testGoodPayload)
+ badHash, err := populateHash(badSig.Hash, []byte(testBadPayload))
+ require.NoError(t, err, "Could not generate a valid hash of payload", testBadPayload)
+
+ // Verify
+ err = verifySign(goodSig, goodHash, key)
+ require.NoError(t, err, "Could not validate a good signature")
+ err = verifySign(badSig, badHash, key)
+ require.Error(t, err, "Validate a bad signature")
+ err = verifySign(goodSig, goodHash, cannotsignkey)
+ require.Error(t, err, "Validate a bad signature with a kay that can not sign")
+}
+
+func TestCheckGPGUserEmail(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ _ = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ testEmailWithUpperCaseLetters := `-----BEGIN PGP PUBLIC KEY BLOCK-----
+Version: GnuPG v1
+
+mQENBFlEBvMBCADe+EQcfv/aKbMFy7YB8e/DE+hY39sfjvdvSgeXtNhfmYvIOUjT
+ORMCvce2Oxzb3HTI0rjYsJpzo9jEQ53dB3vdr0ne5Juby6N7QPjof3NR+ko50Ki2
+0ilOjYuA0v6VHLIn70UBa9NEf+XDuE7P+Lbtl2L9B9OMXtcTAZoA3cJySgtNFNIG
+AVefPi8LeOcekL39wxJEA8OzdCyO5oENEwAG1tzjy9DDNJf74/dBBh2NiXeSeMxZ
+RYeYzqEa2UTDP1fkUl7d2/hV36cKZWZr+l4SQ5bM7HeLj2SsfabLfqKoVWgkfAzQ
+VwtkbRpzMiDLMte2ZAyTJUc+77YbFoyAmOcjABEBAAG0HFVzZXIgT25lIDxVc2Vy
+MUBFeGFtcGxlLmNvbT6JATgEEwECACIFAllEBvMCGwMGCwkIBwMCBhUIAgkKCwQW
+AgMBAh4BAheAAAoJEFMOzOY274DFw5EIAKc4jiYaMb1HDKrSv0tphgNxPFEY83/J
+9CZggO7BINxlb7z/lH1i0U2h2Ha9E3VJTJQF80zBCaIvtU2UNrgVmSKoc0BdE/2S
+rS9MAl29sXxf1BfvXHu12Suvo8O/ZFP45Vm/3kkHuasHyOV1GwUWnynt1qo0zUEn
+WMIcB8USlmMT1TnSb10YKBd/BpGF3crFDJLfAHRumZUk4knDDWUOWy5RCOG8cedc
+VTAhfdoKRRO3PchOfz6Rls/hew12mRNayqxuLQl2+BX+BWu+25dR3qyiS+twLbk6
+Rjpb0S+RQTkYIUoI0SEZpxcTZso11xF5KNpKZ9aAoiLJqkNF5h4oPSe5AQ0EWUQG
+8wEIALiMMqh3NF3ON/z7hQfeU24bCl/WdfJwCR9CWU/jx4X4gZq2C2aGtytGN5g/
+qoYQ3poTOPzh/4Dvs+r6CtHqi0CvPiEOfSxzmaK+F+vA0GMn2i3Sx5gq/VB0mr+j
+RIYMCjf68Tifo2RAT0VDzn6t304l5+VPr4OgbobMRH+wDe7Hhd2pZXl7ty8DooBn
+vqaqoKgdiccUXGBKe4Oihl/oZ4qrYH6K4ACP1Sco1rs4mNeKDAW8k/Y7zLjg6d59
+g0YQ1YI+CX/bKB7/cpMHLupyMLqvCcqIpjBXRJNMdjuMHgKckjr89DwnqXqgXz7W
+u0B39MZQn9nn6vq8BdkoDFgrTQ8AEQEAAYkBHwQYAQIACQUCWUQG8wIbDAAKCRBT
+DszmNu+Axf4IB/0S9NTc6kpwW+ZPZQNTWR5oKDEaXVCRLccOlkt33txMvk/z2jNM
+trEke99ss5L1bRyWB5fRA+XVsPmW9kIk8pmGFmxqp2nSxr9m9rlL5oTYH8u6dfSm
+zwGhqkfITjPI7hyNN52PLANwoS0o4dLzIE65ewigx6cnRlrT2IENObxG/tlxaYg1
+NHahJX0uFlVk0W0bLBrs3fTDw1lS/N8HpyQb+5ryQmiIb2a48aygCS/h2qeRlX1d
+Q0KHb+QcycSgbDx0ZAvdIacuKvBBcbxrsmFUI4LR+oIup0G9gUc0roPvr014jYQL
+7f8r/8fpcN8t+I/41QHCs6L/BEIdTHW3rTQ6
+=zHo9
+-----END PGP PUBLIC KEY BLOCK-----`
+
+ keys, err := AddGPGKey(db.DefaultContext, 1, testEmailWithUpperCaseLetters, "", "")
+ require.NoError(t, err)
+ if assert.NotEmpty(t, keys) {
+ key := keys[0]
+ if assert.Len(t, key.Emails, 1) {
+ assert.Equal(t, "user1@example.com", key.Emails[0].Email)
+ }
+ }
+}
+
+func TestCheckGPGRevokedIdentity(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ require.NoError(t, db.Insert(db.DefaultContext, &user_model.EmailAddress{UID: 1, Email: "no-reply@golang.com", IsActivated: true}))
+ require.NoError(t, db.Insert(db.DefaultContext, &user_model.EmailAddress{UID: 1, Email: "revoked@golang.com", IsActivated: true}))
+ _ = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ revokedUserKey := `-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+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
+7qTZOahrETw=
+=IKnw
+-----END PGP PUBLIC KEY BLOCK-----
+`
+
+ keys, err := AddGPGKey(db.DefaultContext, 1, revokedUserKey, "", "")
+ require.NoError(t, err)
+ assert.Len(t, keys, 1)
+ assert.Len(t, keys[0].Emails, 1)
+ assert.EqualValues(t, "no-reply@golang.com", keys[0].Emails[0].Email)
+
+ primaryKeyID := "D68172F48E9C5283"
+ // Assert primary key
+ unittest.AssertExistsAndLoadBean(t, &GPGKey{OwnerID: 1, KeyID: primaryKeyID, Content: "xsBNBFsgO5EBCADhREPmcjsPkXe1z7ctvyWL0S7oa9JaoGZ9oPDHFDlQxd0qlX2eDZJZDg0qYvVixmaULIulApq1puEsaJCn3lHUbHlb4PYKwLEywYXM28JN91KtLsz/uaEX2KC5WqeP40utmzkNLq+oRX/xnRMgwbO7yUNVG2UlEa6eI+xOXO3YtLdmJMBWClQ066ZnOIzEo1JxnIwha1CDBMWLLfOLrg6l8InUqaXbtEBbnaIYO6fXVXELUjkxnmk7t/QOk0tXCy8muH9UDqJkwDUESY2l79XwBAcx9riX8vY7vwC34pm22fAUVLCJx1SJx0J8bkeNp38jKM2Zd9SUQqSbfBopQ4pPABEBAAE="})
+ // Assert subkey
+ unittest.AssertExistsAndLoadBean(t, &GPGKey{OwnerID: 1, KeyID: "2C56900BE5486AF8", PrimaryKeyID: primaryKeyID, Content: "zsBNBFsgO5EBCAC5INOERA2aNSYHWFeMfByShUuMQGFmyL2tWT6rwzZmUVG0GUdvoKSRhMJ+81aHxr5zmIhluegEuY99UhX+ZK6NftW2UOYjjjQZ4NPDjqOfP5dYUbHiCFRgeUxkmjwnQoSih63iSOoUt5kocR+oXXxbYmbgeOa8KGgKzDLGHI2nsy8Cni3N/enKVMMHGbJy1DXdV7uRFhBdjnRZGdmtamHcQbwGHUH+PtTa/jUSMdbtTUvXPI6dz7jDpK0BImzbXNb+r9CcudpiixuMu5gv3qyJL5EAWCXcT2j+y2VWj2HN/8bJHMoo6yf+bn6A/Cu9f0obbGVF0kJ/Y5UWmEdBG6IzABEBAAE="})
+}
+
+func TestCheckGParseGPGExpire(t *testing.T) {
+ testIssue6599 := `-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+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
+epiDVQ==
+=VSKJ
+-----END PGP PUBLIC KEY BLOCK-----
+`
+ keys, err := checkArmoredGPGKeyString(testIssue6599)
+ require.NoError(t, err)
+ if assert.NotEmpty(t, keys) {
+ ekey := keys[0]
+ expire := getExpiryTime(ekey)
+ assert.Equal(t, time.Unix(1586105389, 0), expire)
+ }
+}
+
+func TestTryGetKeyIDFromSignature(t *testing.T) {
+ assert.Empty(t, tryGetKeyIDFromSignature(&packet.Signature{}))
+ assert.Equal(t, "038D1A3EADDBEA9C", tryGetKeyIDFromSignature(&packet.Signature{
+ IssuerKeyId: util.ToPointer(uint64(0x38D1A3EADDBEA9C)),
+ }))
+ assert.Equal(t, "038D1A3EADDBEA9C", tryGetKeyIDFromSignature(&packet.Signature{
+ IssuerFingerprint: []uint8{0xb, 0x23, 0x24, 0xc7, 0xe6, 0xfe, 0x4f, 0x3a, 0x6, 0x26, 0xc1, 0x21, 0x3, 0x8d, 0x1a, 0x3e, 0xad, 0xdb, 0xea, 0x9c},
+ }))
+}
diff --git a/models/asymkey/gpg_key_verify.go b/models/asymkey/gpg_key_verify.go
new file mode 100644
index 0000000..01812a2
--- /dev/null
+++ b/models/asymkey/gpg_key_verify.go
@@ -0,0 +1,119 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "context"
+ "strconv"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// __________________ ________ ____ __.
+// / _____/\______ \/ _____/ | |/ _|____ ___.__.
+// / \ ___ | ___/ \ ___ | <_/ __ < | |
+// \ \_\ \| | \ \_\ \ | | \ ___/\___ |
+// \______ /|____| \______ / |____|__ \___ > ____|
+// \/ \/ \/ \/\/
+// ____ ____ .__ _____
+// \ \ / /___________|__|/ ____\__.__.
+// \ Y // __ \_ __ \ \ __< | |
+// \ /\ ___/| | \/ || | \___ |
+// \___/ \___ >__| |__||__| / ____|
+// \/ \/
+
+// This file provides functions relating verifying gpg keys
+
+// VerifyGPGKey marks a GPG key as verified
+func VerifyGPGKey(ctx context.Context, ownerID int64, keyID, token, signature string) (string, error) {
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return "", err
+ }
+ defer committer.Close()
+
+ key := new(GPGKey)
+
+ has, err := db.GetEngine(ctx).Where("owner_id = ? AND key_id = ?", ownerID, keyID).Get(key)
+ if err != nil {
+ return "", err
+ } else if !has {
+ return "", ErrGPGKeyNotExist{}
+ }
+
+ if err := key.LoadSubKeys(ctx); err != nil {
+ return "", err
+ }
+
+ sig, err := extractSignature(signature)
+ if err != nil {
+ return "", ErrGPGInvalidTokenSignature{
+ ID: key.KeyID,
+ Wrapped: err,
+ }
+ }
+
+ signer, err := hashAndVerifyWithSubKeys(sig, token, key)
+ if err != nil {
+ return "", ErrGPGInvalidTokenSignature{
+ ID: key.KeyID,
+ Wrapped: err,
+ }
+ }
+ if signer == nil {
+ signer, err = hashAndVerifyWithSubKeys(sig, token+"\n", key)
+ if err != nil {
+ return "", ErrGPGInvalidTokenSignature{
+ ID: key.KeyID,
+ Wrapped: err,
+ }
+ }
+ }
+ if signer == nil {
+ signer, err = hashAndVerifyWithSubKeys(sig, token+"\n\n", key)
+ if err != nil {
+ return "", ErrGPGInvalidTokenSignature{
+ ID: key.KeyID,
+ Wrapped: err,
+ }
+ }
+ }
+
+ if signer == nil {
+ log.Error("Unable to validate token signature. Error: %v", err)
+ return "", ErrGPGInvalidTokenSignature{
+ ID: key.KeyID,
+ }
+ }
+
+ if signer.PrimaryKeyID != key.KeyID && signer.KeyID != key.KeyID {
+ return "", ErrGPGKeyNotExist{}
+ }
+
+ key.Verified = true
+ if _, err := db.GetEngine(ctx).ID(key.ID).SetExpr("verified", true).Update(new(GPGKey)); err != nil {
+ return "", err
+ }
+
+ if err := committer.Commit(); err != nil {
+ return "", err
+ }
+
+ return key.KeyID, nil
+}
+
+// VerificationToken returns token for the user that will be valid in minutes (time)
+func VerificationToken(user *user_model.User, minutes int) string {
+ return base.EncodeSha256(
+ time.Now().Truncate(1*time.Minute).Add(time.Duration(minutes)*time.Minute).Format(
+ time.RFC1123Z) + ":" +
+ user.CreatedUnix.Format(time.RFC1123Z) + ":" +
+ user.Name + ":" +
+ user.Email + ":" +
+ strconv.FormatInt(user.ID, 10))
+}
diff --git a/models/asymkey/main_test.go b/models/asymkey/main_test.go
new file mode 100644
index 0000000..87b5c22
--- /dev/null
+++ b/models/asymkey/main_test.go
@@ -0,0 +1,24 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m, &unittest.TestOptions{
+ FixtureFiles: []string{
+ "gpg_key.yml",
+ "public_key.yml",
+ "TestParseCommitWithSSHSignature/public_key.yml",
+ "deploy_key.yml",
+ "gpg_key_import.yml",
+ "user.yml",
+ "email_address.yml",
+ },
+ })
+}
diff --git a/models/asymkey/ssh_key.go b/models/asymkey/ssh_key.go
new file mode 100644
index 0000000..7a18732
--- /dev/null
+++ b/models/asymkey/ssh_key.go
@@ -0,0 +1,427 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/perm"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+
+ "golang.org/x/crypto/ssh"
+ "xorm.io/builder"
+)
+
+// KeyType specifies the key type
+type KeyType int
+
+const (
+ // KeyTypeUser specifies the user key
+ 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.
+type PublicKey struct {
+ ID int64 `xorm:"pk autoincr"`
+ OwnerID int64 `xorm:"INDEX NOT NULL"`
+ Name string `xorm:"NOT NULL"`
+ Fingerprint string `xorm:"INDEX NOT NULL"`
+ Content string `xorm:"MEDIUMTEXT NOT NULL"`
+ Mode perm.AccessMode `xorm:"NOT NULL DEFAULT 2"`
+ Type KeyType `xorm:"NOT NULL DEFAULT 1"`
+ LoginSourceID int64 `xorm:"NOT NULL DEFAULT 0"`
+
+ CreatedUnix timeutil.TimeStamp `xorm:"created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
+ HasRecentActivity bool `xorm:"-"`
+ HasUsed bool `xorm:"-"`
+ Verified bool `xorm:"NOT NULL DEFAULT false"`
+}
+
+func init() {
+ db.RegisterModel(new(PublicKey))
+}
+
+// AfterLoad is invoked from XORM after setting the values of all fields of this object.
+func (key *PublicKey) AfterLoad() {
+ key.HasUsed = key.UpdatedUnix > key.CreatedUnix
+ key.HasRecentActivity = key.UpdatedUnix.AddDuration(7*24*time.Hour) > timeutil.TimeStampNow()
+}
+
+// OmitEmail returns content of public key without email address.
+func (key *PublicKey) OmitEmail() string {
+ return strings.Join(strings.Split(key.Content, " ")[:2], " ")
+}
+
+// AuthorizedString returns formatted public key string for authorized_keys file.
+//
+// TODO: Consider dropping this function
+func (key *PublicKey) AuthorizedString() string {
+ return AuthorizedStringForKey(key)
+}
+
+func addKey(ctx context.Context, key *PublicKey) (err error) {
+ if len(key.Fingerprint) == 0 {
+ key.Fingerprint, err = CalcFingerprint(key.Content)
+ if err != nil {
+ return err
+ }
+ }
+
+ // Save SSH key.
+ if err = db.Insert(ctx, key); err != nil {
+ return err
+ }
+
+ return appendAuthorizedKeysToFile(key)
+}
+
+// AddPublicKey adds new public key to database and authorized_keys file.
+func AddPublicKey(ctx context.Context, ownerID int64, name, content string, authSourceID int64) (*PublicKey, error) {
+ log.Trace(content)
+
+ fingerprint, err := CalcFingerprint(content)
+ if err != nil {
+ return nil, err
+ }
+
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer committer.Close()
+
+ if err := checkKeyFingerprint(ctx, fingerprint); err != nil {
+ return nil, err
+ }
+
+ // Key name of same user cannot be duplicated.
+ has, err := db.GetEngine(ctx).
+ Where("owner_id = ? AND name = ?", ownerID, name).
+ Get(new(PublicKey))
+ if err != nil {
+ return nil, err
+ } else if has {
+ return nil, ErrKeyNameAlreadyUsed{ownerID, name}
+ }
+
+ key := &PublicKey{
+ OwnerID: ownerID,
+ Name: name,
+ Fingerprint: fingerprint,
+ Content: content,
+ Mode: perm.AccessModeWrite,
+ Type: KeyTypeUser,
+ LoginSourceID: authSourceID,
+ }
+ if err = addKey(ctx, key); err != nil {
+ return nil, fmt.Errorf("addKey: %w", err)
+ }
+
+ return key, committer.Commit()
+}
+
+// GetPublicKeyByID returns public key by given ID.
+func GetPublicKeyByID(ctx context.Context, keyID int64) (*PublicKey, error) {
+ key := new(PublicKey)
+ has, err := db.GetEngine(ctx).
+ 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(ctx context.Context, content string) (*PublicKey, error) {
+ key := new(PublicKey)
+ has, err := db.GetEngine(ctx).
+ Where("content like ?", 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(ctx context.Context, content string) (*PublicKey, error) {
+ key := new(PublicKey)
+ has, err := db.GetEngine(ctx).
+ Where("content = ?", content).
+ Get(key)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrKeyNotExist{}
+ }
+ return key, nil
+}
+
+type FindPublicKeyOptions struct {
+ db.ListOptions
+ OwnerID int64
+ Fingerprint string
+ KeyTypes []KeyType
+ NotKeytype KeyType
+ LoginSourceID int64
+}
+
+func (opts FindPublicKeyOptions) ToConds() builder.Cond {
+ cond := builder.NewCond()
+ if opts.OwnerID > 0 {
+ cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
+ }
+ if opts.Fingerprint != "" {
+ cond = cond.And(builder.Eq{"fingerprint": opts.Fingerprint})
+ }
+ if len(opts.KeyTypes) > 0 {
+ cond = cond.And(builder.In("`type`", opts.KeyTypes))
+ }
+ if opts.NotKeytype > 0 {
+ cond = cond.And(builder.Neq{"`type`": opts.NotKeytype})
+ }
+ if opts.LoginSourceID > 0 {
+ cond = cond.And(builder.Eq{"login_source_id": opts.LoginSourceID})
+ }
+ return cond
+}
+
+// UpdatePublicKeyUpdated updates public key use time.
+func UpdatePublicKeyUpdated(ctx context.Context, id int64) error {
+ // Check if key exists before update as affected rows count is unreliable
+ // and will return 0 affected rows if two updates are made at the same time
+ if cnt, err := db.GetEngine(ctx).ID(id).Count(&PublicKey{}); err != nil {
+ return err
+ } else if cnt != 1 {
+ return ErrKeyNotExist{id}
+ }
+
+ _, err := db.GetEngine(ctx).ID(id).Cols("updated_unix").Update(&PublicKey{
+ UpdatedUnix: timeutil.TimeStampNow(),
+ })
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+// PublicKeysAreExternallyManaged returns whether the provided KeyID represents an externally managed Key
+func PublicKeysAreExternallyManaged(ctx context.Context, keys []*PublicKey) ([]bool, error) {
+ sourceCache := make(map[int64]*auth.Source, len(keys))
+ externals := make([]bool, len(keys))
+
+ for i, key := range keys {
+ if key.LoginSourceID == 0 {
+ externals[i] = false
+ continue
+ }
+
+ source, ok := sourceCache[key.LoginSourceID]
+ if !ok {
+ var err error
+ source, err = auth.GetSourceByID(ctx, key.LoginSourceID)
+ if err != nil {
+ if auth.IsErrSourceNotExist(err) {
+ externals[i] = false
+ sourceCache[key.LoginSourceID] = &auth.Source{
+ ID: key.LoginSourceID,
+ }
+ continue
+ }
+ return nil, err
+ }
+ }
+
+ if sshKeyProvider, ok := source.Cfg.(auth.SSHKeyProvider); ok && sshKeyProvider.ProvidesSSHKeys() {
+ // Disable setting SSH keys for this user
+ externals[i] = true
+ }
+ }
+
+ return externals, nil
+}
+
+// PublicKeyIsExternallyManaged returns whether the provided KeyID represents an externally managed Key
+func PublicKeyIsExternallyManaged(ctx context.Context, id int64) (bool, error) {
+ key, err := GetPublicKeyByID(ctx, id)
+ if err != nil {
+ return false, err
+ }
+ if key.LoginSourceID == 0 {
+ return false, nil
+ }
+ source, err := auth.GetSourceByID(ctx, key.LoginSourceID)
+ if err != nil {
+ if auth.IsErrSourceNotExist(err) {
+ return false, nil
+ }
+ return false, err
+ }
+ if sshKeyProvider, ok := source.Cfg.(auth.SSHKeyProvider); ok && sshKeyProvider.ProvidesSSHKeys() {
+ // Disable setting SSH keys for this user
+ return true, nil
+ }
+ return false, nil
+}
+
+// deleteKeysMarkedForDeletion returns true if ssh keys needs update
+func deleteKeysMarkedForDeletion(ctx context.Context, keys []string) (bool, error) {
+ // Start session
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return false, err
+ }
+ defer committer.Close()
+
+ // Delete keys marked for deletion
+ var sshKeysNeedUpdate bool
+ for _, KeyToDelete := range keys {
+ key, err := SearchPublicKeyByContent(ctx, KeyToDelete)
+ if err != nil {
+ log.Error("SearchPublicKeyByContent: %v", err)
+ continue
+ }
+ if _, err = db.DeleteByID[PublicKey](ctx, key.ID); err != nil {
+ log.Error("DeleteByID[PublicKey]: %v", err)
+ continue
+ }
+ sshKeysNeedUpdate = true
+ }
+
+ if err := committer.Commit(); err != nil {
+ return false, err
+ }
+
+ return sshKeysNeedUpdate, nil
+}
+
+// AddPublicKeysBySource add a users public keys. Returns true if there are changes.
+func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) bool {
+ var sshKeysNeedUpdate bool
+ for _, sshKey := range sshPublicKeys {
+ var err error
+ found := false
+ keys := []byte(sshKey)
+ loop:
+ for len(keys) > 0 && err == nil {
+ var out ssh.PublicKey
+ // We ignore options as they are not relevant to Gitea
+ out, _, _, keys, err = ssh.ParseAuthorizedKey(keys)
+ if err != nil {
+ break loop
+ }
+ found = true
+ marshalled := string(ssh.MarshalAuthorizedKey(out))
+ marshalled = marshalled[:len(marshalled)-1]
+ sshKeyName := fmt.Sprintf("%s-%s", s.Name, ssh.FingerprintSHA256(out))
+
+ if _, err := AddPublicKey(ctx, usr.ID, sshKeyName, marshalled, s.ID); err != nil {
+ if IsErrKeyAlreadyExist(err) {
+ log.Trace("AddPublicKeysBySource[%s]: Public SSH Key %s already exists for user", sshKeyName, usr.Name)
+ } else {
+ log.Error("AddPublicKeysBySource[%s]: Error adding Public SSH Key for user %s: %v", sshKeyName, usr.Name, err)
+ }
+ } else {
+ log.Trace("AddPublicKeysBySource[%s]: Added Public SSH Key for user %s", sshKeyName, usr.Name)
+ sshKeysNeedUpdate = true
+ }
+ }
+ if !found && err != nil {
+ log.Warn("AddPublicKeysBySource[%s]: Skipping invalid Public SSH Key for user %s: %v", s.Name, usr.Name, sshKey)
+ }
+ }
+ return sshKeysNeedUpdate
+}
+
+// SynchronizePublicKeys updates a users public keys. Returns true if there are changes.
+func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) bool {
+ var sshKeysNeedUpdate bool
+
+ log.Trace("synchronizePublicKeys[%s]: Handling Public SSH Key synchronization for user %s", s.Name, usr.Name)
+
+ // Get Public Keys from DB with current LDAP source
+ var giteaKeys []string
+ keys, err := db.Find[PublicKey](ctx, FindPublicKeyOptions{
+ OwnerID: usr.ID,
+ LoginSourceID: s.ID,
+ })
+ if err != nil {
+ log.Error("synchronizePublicKeys[%s]: Error listing Public SSH Keys for user %s: %v", s.Name, usr.Name, err)
+ }
+
+ for _, v := range keys {
+ giteaKeys = append(giteaKeys, v.OmitEmail())
+ }
+
+ // Process the provided keys to remove duplicates and name part
+ var providedKeys []string
+ for _, v := range sshPublicKeys {
+ sshKeySplit := strings.Split(v, " ")
+ if len(sshKeySplit) > 1 {
+ key := strings.Join(sshKeySplit[:2], " ")
+ if !util.SliceContainsString(providedKeys, key) {
+ providedKeys = append(providedKeys, key)
+ }
+ }
+ }
+
+ // Check if Public Key sync is needed
+ if util.SliceSortedEqual(giteaKeys, providedKeys) {
+ log.Trace("synchronizePublicKeys[%s]: Public Keys are already in sync for %s (Source:%v/DB:%v)", s.Name, usr.Name, len(providedKeys), len(giteaKeys))
+ return false
+ }
+ log.Trace("synchronizePublicKeys[%s]: Public Key needs update for user %s (Source:%v/DB:%v)", s.Name, usr.Name, len(providedKeys), len(giteaKeys))
+
+ // Add new Public SSH Keys that doesn't already exist in DB
+ var newKeys []string
+ for _, key := range providedKeys {
+ if !util.SliceContainsString(giteaKeys, key) {
+ newKeys = append(newKeys, key)
+ }
+ }
+ if AddPublicKeysBySource(ctx, usr, s, newKeys) {
+ sshKeysNeedUpdate = true
+ }
+
+ // Mark keys from DB that no longer exist in the source for deletion
+ var giteaKeysToDelete []string
+ for _, giteaKey := range giteaKeys {
+ if !util.SliceContainsString(providedKeys, giteaKey) {
+ log.Trace("synchronizePublicKeys[%s]: Marking Public SSH Key for deletion for user %s: %v", s.Name, usr.Name, giteaKey)
+ giteaKeysToDelete = append(giteaKeysToDelete, giteaKey)
+ }
+ }
+
+ // Delete keys from DB that no longer exist in the source
+ needUpd, err := deleteKeysMarkedForDeletion(ctx, giteaKeysToDelete)
+ if err != nil {
+ log.Error("synchronizePublicKeys[%s]: Error deleting Public Keys marked for deletion for user %s: %v", s.Name, usr.Name, err)
+ }
+ if needUpd {
+ sshKeysNeedUpdate = true
+ }
+
+ return sshKeysNeedUpdate
+}
diff --git a/models/asymkey/ssh_key_authorized_keys.go b/models/asymkey/ssh_key_authorized_keys.go
new file mode 100644
index 0000000..d3f9f3f
--- /dev/null
+++ b/models/asymkey/ssh_key_authorized_keys.go
@@ -0,0 +1,220 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// _____ __ .__ .__ .___
+// / _ \ __ ___/ |_| |__ ___________|__|_______ ____ __| _/
+// / /_\ \| | \ __\ | \ / _ \_ __ \ \___ // __ \ / __ |
+// / | \ | /| | | Y ( <_> ) | \/ |/ /\ ___// /_/ |
+// \____|__ /____/ |__| |___| /\____/|__| |__/_____ \\___ >____ |
+// \/ \/ \/ \/ \/
+// ____ __.
+// | |/ _|____ ___.__. ______
+// | <_/ __ < | |/ ___/
+// | | \ ___/\___ |\___ \
+// |____|__ \___ > ____/____ >
+// \/ \/\/ \/
+//
+// This file contains functions for creating authorized_keys files
+//
+// There is a dependence on the database within RegeneratePublicKeys however most of these functions probably belong in a module
+
+const (
+ tplCommentPrefix = `# gitea public key`
+ tplPublicKey = tplCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict %s` + "\n"
+)
+
+var sshOpLocker sync.Mutex
+
+// AuthorizedStringForKey creates the authorized keys string appropriate for the provided key
+func AuthorizedStringForKey(key *PublicKey) string {
+ sb := &strings.Builder{}
+ _ = setting.SSH.AuthorizedKeysCommandTemplateTemplate.Execute(sb, map[string]any{
+ "AppPath": util.ShellEscape(setting.AppPath),
+ "AppWorkPath": util.ShellEscape(setting.AppWorkPath),
+ "CustomConf": util.ShellEscape(setting.CustomConf),
+ "CustomPath": util.ShellEscape(setting.CustomPath),
+ "Key": key,
+ })
+
+ return fmt.Sprintf(tplPublicKey, util.ShellEscape(sb.String()), key.Content)
+}
+
+// appendAuthorizedKeysToFile appends new SSH keys' content to authorized_keys file.
+func appendAuthorizedKeysToFile(keys ...*PublicKey) error {
+ // Don't need to rewrite this file if builtin SSH server is enabled.
+ if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedKeysFile {
+ 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, 0o700)
+ if err != nil {
+ log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err)
+ return err
+ }
+ }
+
+ fPath := filepath.Join(setting.SSH.RootPath, "authorized_keys")
+ f, err := os.OpenFile(fPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ // Note: chmod command does not support in Windows.
+ if !setting.IsWindows {
+ fi, err := f.Stat()
+ if err != nil {
+ return err
+ }
+
+ // .ssh directory should have mode 700, and authorized_keys file should have mode 600.
+ if fi.Mode().Perm() > 0o600 {
+ log.Error("authorized_keys file has unusual permission flags: %s - setting to -rw-------", fi.Mode().Perm().String())
+ if err = f.Chmod(0o600); err != nil {
+ return err
+ }
+ }
+ }
+
+ for _, key := range keys {
+ if key.Type == KeyTypePrincipal {
+ continue
+ }
+ if _, err = f.WriteString(key.AuthorizedString()); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// RewriteAllPublicKeys removes any authorized key and rewrite all keys from database again.
+// Note: db.GetEngine(ctx).Iterate does not get latest data after insert/delete, so we have to call this function
+// outside any session scope independently.
+func RewriteAllPublicKeys(ctx context.Context) error {
+ // Don't rewrite key if internal server
+ if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedKeysFile {
+ 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, 0o700)
+ if err != nil {
+ log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err)
+ return err
+ }
+ }
+
+ fPath := filepath.Join(setting.SSH.RootPath, "authorized_keys")
+ tmpPath := fPath + ".tmp"
+ t, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ t.Close()
+ if err := util.Remove(tmpPath); err != nil {
+ log.Warn("Unable to remove temporary authorized keys file: %s: Error: %v", tmpPath, err)
+ }
+ }()
+
+ if setting.SSH.AuthorizedKeysBackup {
+ isExist, err := util.IsExist(fPath)
+ if err != nil {
+ log.Error("Unable to check if %s exists. Error: %v", fPath, err)
+ return err
+ }
+ if isExist {
+ bakPath := fmt.Sprintf("%s_%d.gitea_bak", fPath, time.Now().Unix())
+ if err = util.CopyFile(fPath, bakPath); err != nil {
+ return err
+ }
+ }
+ }
+
+ if err := RegeneratePublicKeys(ctx, t); err != nil {
+ return err
+ }
+
+ if err := t.Sync(); err != nil {
+ return err
+ }
+ if err := t.Close(); err != nil {
+ return err
+ }
+ return util.Rename(tmpPath, fPath)
+}
+
+// RegeneratePublicKeys regenerates the authorized_keys file
+func RegeneratePublicKeys(ctx context.Context, t io.StringWriter) error {
+ if err := db.GetEngine(ctx).Where("type != ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean any) (err error) {
+ _, err = t.WriteString((bean.(*PublicKey)).AuthorizedString())
+ return err
+ }); err != nil {
+ return err
+ }
+
+ fPath := filepath.Join(setting.SSH.RootPath, "authorized_keys")
+ isExist, err := util.IsExist(fPath)
+ if err != nil {
+ log.Error("Unable to check if %s exists. Error: %v", fPath, err)
+ return err
+ }
+ if isExist {
+ f, err := os.Open(fPath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ 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 {
+ return err
+ }
+ }
+ if err = scanner.Err(); err != nil {
+ return fmt.Errorf("RegeneratePublicKeys scan: %w", err)
+ }
+ }
+ return nil
+}
diff --git a/models/asymkey/ssh_key_authorized_principals.go b/models/asymkey/ssh_key_authorized_principals.go
new file mode 100644
index 0000000..f85de12
--- /dev/null
+++ b/models/asymkey/ssh_key_authorized_principals.go
@@ -0,0 +1,142 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// _____ __ .__ .__ .___
+// / _ \ __ ___/ |_| |__ ___________|__|_______ ____ __| _/
+// / /_\ \| | \ __\ | \ / _ \_ __ \ \___ // __ \ / __ |
+// / | \ | /| | | Y ( <_> ) | \/ |/ /\ ___// /_/ |
+// \____|__ /____/ |__| |___| /\____/|__| |__/_____ \\___ >____ |
+// \/ \/ \/ \/ \/
+// __________ .__ .__ .__
+// \______ _______|__| ____ ____ |_____________ | | ______
+// | ___\_ __ | |/ \_/ ___\| \____ \__ \ | | / ___/
+// | | | | \| | | \ \___| | |_> / __ \| |__\___ \
+// |____| |__| |__|___| /\___ |__| __(____ |____/____ >
+// \/ \/ |__| \/ \/
+//
+// This file contains functions for creating authorized_principals files
+//
+// There is a dependence on the database within RewriteAllPrincipalKeys & RegeneratePrincipalKeys
+// The sshOpLocker is used from ssh_key_authorized_keys.go
+
+const authorizedPrincipalsFile = "authorized_principals"
+
+// RewriteAllPrincipalKeys removes any authorized principal and rewrite all keys from database again.
+// Note: db.GetEngine(ctx).Iterate does not get latest data after insert/delete, so we have to call this function
+// outside any session scope independently.
+func RewriteAllPrincipalKeys(ctx context.Context) 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, 0o700)
+ 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, 0o600)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ t.Close()
+ os.Remove(tmpPath)
+ }()
+
+ if setting.SSH.AuthorizedPrincipalsBackup {
+ isExist, err := util.IsExist(fPath)
+ if err != nil {
+ log.Error("Unable to check if %s exists. Error: %v", fPath, err)
+ return err
+ }
+ if isExist {
+ bakPath := fmt.Sprintf("%s_%d.gitea_bak", fPath, time.Now().Unix())
+ if err = util.CopyFile(fPath, bakPath); err != nil {
+ return err
+ }
+ }
+ }
+
+ if err := regeneratePrincipalKeys(ctx, t); err != nil {
+ return err
+ }
+
+ if err := t.Sync(); err != nil {
+ return err
+ }
+ if err := t.Close(); err != nil {
+ return err
+ }
+ return util.Rename(tmpPath, fPath)
+}
+
+func regeneratePrincipalKeys(ctx context.Context, t io.StringWriter) error {
+ if err := db.GetEngine(ctx).Where("type = ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean any) (err error) {
+ _, err = t.WriteString((bean.(*PublicKey)).AuthorizedString())
+ return err
+ }); err != nil {
+ return err
+ }
+
+ fPath := filepath.Join(setting.SSH.RootPath, authorizedPrincipalsFile)
+ isExist, err := util.IsExist(fPath)
+ if err != nil {
+ log.Error("Unable to check if %s exists. Error: %v", fPath, err)
+ return err
+ }
+ if isExist {
+ f, err := os.Open(fPath)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ 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 {
+ return err
+ }
+ }
+ if err = scanner.Err(); err != nil {
+ return fmt.Errorf("regeneratePrincipalKeys scan: %w", err)
+ }
+ }
+ return nil
+}
diff --git a/models/asymkey/ssh_key_deploy.go b/models/asymkey/ssh_key_deploy.go
new file mode 100644
index 0000000..923c502
--- /dev/null
+++ b/models/asymkey/ssh_key_deploy.go
@@ -0,0 +1,218 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/perm"
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "xorm.io/builder"
+)
+
+// ________ .__ ____ __.
+// \______ \ ____ ______ | | ____ ___.__.| |/ _|____ ___.__.
+// | | \_/ __ \\____ \| | / _ < | || <_/ __ < | |
+// | ` \ ___/| |_> > |_( <_> )___ || | \ ___/\___ |
+// /_______ /\___ > __/|____/\____// ____||____|__ \___ > ____|
+// \/ \/|__| \/ \/ \/\/
+//
+// This file contains functions specific to DeployKeys
+
+// 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:"-"`
+
+ Mode perm.AccessMode `xorm:"NOT NULL DEFAULT 1"`
+
+ CreatedUnix timeutil.TimeStamp `xorm:"created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
+ HasRecentActivity bool `xorm:"-"`
+ HasUsed bool `xorm:"-"`
+}
+
+// AfterLoad is invoked from XORM after setting the values of all fields of this object.
+func (key *DeployKey) AfterLoad() {
+ key.HasUsed = key.UpdatedUnix > key.CreatedUnix
+ key.HasRecentActivity = key.UpdatedUnix.AddDuration(7*24*time.Hour) > timeutil.TimeStampNow()
+}
+
+// GetContent gets associated public key content.
+func (key *DeployKey) GetContent(ctx context.Context) error {
+ pkey, err := GetPublicKeyByID(ctx, key.KeyID)
+ if err != nil {
+ return err
+ }
+ key.Content = pkey.Content
+ return nil
+}
+
+// IsReadOnly checks if the key can only be used for read operations, used by template
+func (key *DeployKey) IsReadOnly() bool {
+ return key.Mode == perm.AccessModeRead
+}
+
+func init() {
+ db.RegisterModel(new(DeployKey))
+}
+
+func checkDeployKey(ctx context.Context, keyID, repoID int64, name string) error {
+ // Note: We want error detail, not just true or false here.
+ has, err := db.GetEngine(ctx).
+ 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 = db.GetEngine(ctx).
+ 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(ctx context.Context, keyID, repoID int64, name, fingerprint string, mode perm.AccessMode) (*DeployKey, error) {
+ if err := checkDeployKey(ctx, keyID, repoID, name); err != nil {
+ return nil, err
+ }
+
+ key := &DeployKey{
+ KeyID: keyID,
+ RepoID: repoID,
+ Name: name,
+ Fingerprint: fingerprint,
+ Mode: mode,
+ }
+ return key, db.Insert(ctx, key)
+}
+
+// HasDeployKey returns true if public key is a deploy key of given repository.
+func HasDeployKey(ctx context.Context, keyID, repoID int64) bool {
+ has, _ := db.GetEngine(ctx).
+ 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(ctx context.Context, repoID int64, name, content string, readOnly bool) (*DeployKey, error) {
+ fingerprint, err := CalcFingerprint(content)
+ if err != nil {
+ return nil, err
+ }
+
+ accessMode := perm.AccessModeRead
+ if !readOnly {
+ accessMode = perm.AccessModeWrite
+ }
+
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer committer.Close()
+
+ pkey, exist, err := db.Get[PublicKey](ctx, builder.Eq{"fingerprint": fingerprint})
+ if err != nil {
+ return nil, err
+ } else if exist {
+ if pkey.Type != KeyTypeDeploy {
+ return nil, ErrKeyAlreadyExist{0, fingerprint, ""}
+ }
+ } else {
+ // First time use this deploy key.
+ pkey = &PublicKey{
+ Fingerprint: fingerprint,
+ Mode: accessMode,
+ Type: KeyTypeDeploy,
+ Content: content,
+ Name: name,
+ }
+ if err = addKey(ctx, pkey); err != nil {
+ return nil, fmt.Errorf("addKey: %w", err)
+ }
+ }
+
+ key, err := addDeployKey(ctx, pkey.ID, repoID, name, pkey.Fingerprint, accessMode)
+ if err != nil {
+ return nil, err
+ }
+
+ return key, committer.Commit()
+}
+
+// GetDeployKeyByID returns deploy key by given ID.
+func GetDeployKeyByID(ctx context.Context, id int64) (*DeployKey, error) {
+ key, exist, err := db.GetByID[DeployKey](ctx, id)
+ if err != nil {
+ return nil, err
+ } else if !exist {
+ return nil, ErrDeployKeyNotExist{id, 0, 0}
+ }
+ return key, nil
+}
+
+// GetDeployKeyByRepo returns deploy key by given public key ID and repository ID.
+func GetDeployKeyByRepo(ctx context.Context, keyID, repoID int64) (*DeployKey, error) {
+ key, exist, err := db.Get[DeployKey](ctx, builder.Eq{"key_id": keyID, "repo_id": repoID})
+ if err != nil {
+ return nil, err
+ } else if !exist {
+ return nil, ErrDeployKeyNotExist{0, keyID, repoID}
+ }
+ return key, nil
+}
+
+// IsDeployKeyExistByKeyID return true if there is at least one deploykey with the key id
+func IsDeployKeyExistByKeyID(ctx context.Context, keyID int64) (bool, error) {
+ return db.GetEngine(ctx).
+ Where("key_id = ?", keyID).
+ Get(new(DeployKey))
+}
+
+// UpdateDeployKeyCols updates deploy key information in the specified columns.
+func UpdateDeployKeyCols(ctx context.Context, key *DeployKey, cols ...string) error {
+ _, err := db.GetEngine(ctx).ID(key.ID).Cols(cols...).Update(key)
+ return err
+}
+
+// ListDeployKeysOptions are options for ListDeployKeys
+type ListDeployKeysOptions struct {
+ db.ListOptions
+ RepoID int64
+ KeyID int64
+ Fingerprint string
+}
+
+func (opt ListDeployKeysOptions) ToConds() builder.Cond {
+ cond := builder.NewCond()
+ if opt.RepoID != 0 {
+ cond = cond.And(builder.Eq{"repo_id": opt.RepoID})
+ }
+ if opt.KeyID != 0 {
+ cond = cond.And(builder.Eq{"key_id": opt.KeyID})
+ }
+ if opt.Fingerprint != "" {
+ cond = cond.And(builder.Eq{"fingerprint": opt.Fingerprint})
+ }
+ return cond
+}
diff --git a/models/asymkey/ssh_key_fingerprint.go b/models/asymkey/ssh_key_fingerprint.go
new file mode 100644
index 0000000..1ed3b5d
--- /dev/null
+++ b/models/asymkey/ssh_key_fingerprint.go
@@ -0,0 +1,89 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+
+ "golang.org/x/crypto/ssh"
+ "xorm.io/builder"
+)
+
+// ___________.__ .__ __
+// \_ _____/|__| ____ ____ ________________________|__| _____/ |_
+// | __) | |/ \ / ___\_/ __ \_ __ \____ \_ __ \ |/ \ __\
+// | \ | | | \/ /_/ > ___/| | \/ |_> > | \/ | | \ |
+// \___ / |__|___| /\___ / \___ >__| | __/|__| |__|___| /__|
+// \/ \//_____/ \/ |__| \/
+//
+// This file contains functions for fingerprinting SSH keys
+//
+// The database is used in checkKeyFingerprint however most of these functions probably belong in a module
+
+// checkKeyFingerprint only checks if key fingerprint has been used as public key,
+// it is OK to use same key as deploy key for multiple repositories/users.
+func checkKeyFingerprint(ctx context.Context, fingerprint string) error {
+ has, err := db.Exist[PublicKey](ctx, builder.Eq{"fingerprint": fingerprint})
+ if err != nil {
+ return err
+ } else if has {
+ return ErrKeyAlreadyExist{0, fingerprint, ""}
+ }
+ return nil
+}
+
+func calcFingerprintSSHKeygen(publicKeyContent string) (string, error) {
+ // Calculate fingerprint.
+ tmpPath, err := writeTmpKeyFile(publicKeyContent)
+ if err != nil {
+ return "", err
+ }
+ defer func() {
+ if err := util.Remove(tmpPath); err != nil {
+ log.Warn("Unable to remove temporary key file: %s: Error: %v", tmpPath, err)
+ }
+ }()
+ stdout, stderr, err := process.GetManager().Exec("AddPublicKey", "ssh-keygen", "-lf", tmpPath)
+ if err != nil {
+ if strings.Contains(stderr, "is not a public key file") {
+ return "", ErrKeyUnableVerify{stderr}
+ }
+ return "", util.NewInvalidArgumentErrorf("'ssh-keygen -lf %s' failed with error '%s': %s", tmpPath, err, stderr)
+ } else if len(stdout) < 2 {
+ return "", util.NewInvalidArgumentErrorf("not enough output for calculating fingerprint: %s", stdout)
+ }
+ return strings.Split(stdout, " ")[1], nil
+}
+
+func calcFingerprintNative(publicKeyContent string) (string, error) {
+ // Calculate fingerprint.
+ pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(publicKeyContent))
+ if err != nil {
+ return "", err
+ }
+ return ssh.FingerprintSHA256(pk), nil
+}
+
+// CalcFingerprint calculate public key's fingerprint
+func CalcFingerprint(publicKeyContent string) (string, error) {
+ // Call the method based on configuration
+ useNative := setting.SSH.KeygenPath == ""
+ calcFn := util.Iif(useNative, calcFingerprintNative, calcFingerprintSSHKeygen)
+ fp, err := calcFn(publicKeyContent)
+ if err != nil {
+ if IsErrKeyUnableVerify(err) {
+ return "", err
+ }
+ return "", fmt.Errorf("CalcFingerprint(%s): %w", util.Iif(useNative, "native", "ssh-keygen"), err)
+ }
+ return fp, nil
+}
diff --git a/models/asymkey/ssh_key_object_verification.go b/models/asymkey/ssh_key_object_verification.go
new file mode 100644
index 0000000..5ad6fdb
--- /dev/null
+++ b/models/asymkey/ssh_key_object_verification.go
@@ -0,0 +1,85 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/42wim/sshsig"
+)
+
+// ParseObjectWithSSHSignature check if signature is good against keystore.
+func ParseObjectWithSSHSignature(ctx context.Context, c *GitObject, committer *user_model.User) *ObjectVerification {
+ // Now try to associate the signature with the committer, if present
+ if committer.ID != 0 {
+ keys, err := db.Find[PublicKey](ctx, FindPublicKeyOptions{
+ OwnerID: committer.ID,
+ NotKeytype: KeyTypePrincipal,
+ })
+ if err != nil { // Skipping failed to get ssh keys of user
+ log.Error("ListPublicKeys: %v", err)
+ return &ObjectVerification{
+ CommittingUser: committer,
+ Verified: false,
+ Reason: "gpg.error.failed_retrieval_gpg_keys",
+ }
+ }
+
+ committerEmailAddresses, err := user_model.GetEmailAddresses(ctx, committer.ID)
+ if err != nil {
+ log.Error("GetEmailAddresses: %v", err)
+ }
+
+ // Add the noreply email address as verified address.
+ committerEmailAddresses = append(committerEmailAddresses, &user_model.EmailAddress{
+ IsActivated: true,
+ Email: committer.GetPlaceholderEmail(),
+ })
+
+ activated := false
+ for _, e := range committerEmailAddresses {
+ if e.IsActivated && strings.EqualFold(e.Email, c.Committer.Email) {
+ activated = true
+ break
+ }
+ }
+
+ for _, k := range keys {
+ if k.Verified && activated {
+ commitVerification := verifySSHObjectVerification(c.Signature.Signature, c.Signature.Payload, k, committer, committer, c.Committer.Email)
+ if commitVerification != nil {
+ return commitVerification
+ }
+ }
+ }
+ }
+
+ return &ObjectVerification{
+ CommittingUser: committer,
+ Verified: false,
+ Reason: NoKeyFound,
+ }
+}
+
+func verifySSHObjectVerification(sig, payload string, k *PublicKey, committer, signer *user_model.User, email string) *ObjectVerification {
+ if err := sshsig.Verify(bytes.NewBuffer([]byte(payload)), []byte(sig), []byte(k.Content), "git"); err != nil {
+ return nil
+ }
+
+ return &ObjectVerification{ // Everything is ok
+ CommittingUser: committer,
+ Verified: true,
+ Reason: fmt.Sprintf("%s / %s", signer.Name, k.Fingerprint),
+ SigningUser: signer,
+ SigningSSHKey: k,
+ SigningEmail: email,
+ }
+}
diff --git a/models/asymkey/ssh_key_object_verification_test.go b/models/asymkey/ssh_key_object_verification_test.go
new file mode 100644
index 0000000..0d5ebab
--- /dev/null
+++ b/models/asymkey/ssh_key_object_verification_test.go
@@ -0,0 +1,153 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseCommitWithSSHSignature(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ sshKey := unittest.AssertExistsAndLoadBean(t, &PublicKey{ID: 1000, OwnerID: 2})
+
+ t.Run("No commiter", func(t *testing.T) {
+ o := commitToGitObject(&git.Commit{})
+ commitVerification := ParseObjectWithSSHSignature(db.DefaultContext, &o, &user_model.User{})
+ assert.False(t, commitVerification.Verified)
+ assert.Equal(t, NoKeyFound, commitVerification.Reason)
+ })
+
+ t.Run("Commiter without keys", func(t *testing.T) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ o := commitToGitObject(&git.Commit{Committer: &git.Signature{Email: user.Email}})
+ commitVerification := ParseObjectWithSSHSignature(db.DefaultContext, &o, user)
+ assert.False(t, commitVerification.Verified)
+ assert.Equal(t, NoKeyFound, commitVerification.Reason)
+ })
+
+ t.Run("Correct signature with wrong email", func(t *testing.T) {
+ gitCommit := &git.Commit{
+ Committer: &git.Signature{
+ Email: "non-existent",
+ },
+ Signature: &git.ObjectSignature{
+ Payload: `tree 2d491b2985a7ff848d5c02748e7ea9f9f7619f9f
+parent 45b03601635a1f463b81963a4022c7f87ce96ef9
+author user2 <non-existent> 1699710556 +0100
+committer user2 <non-existent> 1699710556 +0100
+
+Using email that isn't known to Forgejo
+`,
+ Signature: `-----BEGIN SSH SIGNATURE-----
+U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgoGSe9Zy7Ez9bSJcaTNjh/Y7p95
+f5DujjqkpzFRtw6CEAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
+AAAAQIMufOuSjZeDUujrkVK4sl7ICa0WwEftas8UAYxx0Thdkiw2qWjR1U1PKfTLm16/w8
+/bS1LX1lZNuzm2LR2qEgw=
+-----END SSH SIGNATURE-----
+`,
+ },
+ }
+ o := commitToGitObject(gitCommit)
+ commitVerification := ParseObjectWithSSHSignature(db.DefaultContext, &o, user2)
+ assert.False(t, commitVerification.Verified)
+ assert.Equal(t, NoKeyFound, commitVerification.Reason)
+ })
+
+ t.Run("Incorrect signature with correct email", func(t *testing.T) {
+ gitCommit := &git.Commit{
+ Committer: &git.Signature{
+ Email: "user2@example.com",
+ },
+ Signature: &git.ObjectSignature{
+ Payload: `tree 853694aae8816094a0d875fee7ea26278dbf5d0f
+parent c2780d5c313da2a947eae22efd7dacf4213f4e7f
+author user2 <user2@example.com> 1699707877 +0100
+committer user2 <user2@example.com> 1699707877 +0100
+
+Add content
+`,
+ Signature: `-----BEGIN SSH SIGNATURE-----`,
+ },
+ }
+
+ o := commitToGitObject(gitCommit)
+ commitVerification := ParseObjectWithSSHSignature(db.DefaultContext, &o, user2)
+ assert.False(t, commitVerification.Verified)
+ assert.Equal(t, NoKeyFound, commitVerification.Reason)
+ })
+
+ t.Run("Valid signature with correct email", func(t *testing.T) {
+ gitCommit := &git.Commit{
+ Committer: &git.Signature{
+ Email: "user2@example.com",
+ },
+ Signature: &git.ObjectSignature{
+ Payload: `tree 853694aae8816094a0d875fee7ea26278dbf5d0f
+parent c2780d5c313da2a947eae22efd7dacf4213f4e7f
+author user2 <user2@example.com> 1699707877 +0100
+committer user2 <user2@example.com> 1699707877 +0100
+
+Add content
+`,
+ Signature: `-----BEGIN SSH SIGNATURE-----
+U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgoGSe9Zy7Ez9bSJcaTNjh/Y7p95
+f5DujjqkpzFRtw6CEAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
+AAAAQBe2Fwk/FKY3SBCnG6jSYcO6ucyahp2SpQ/0P+otslzIHpWNW8cQ0fGLdhhaFynJXQ
+fs9cMpZVM9BfIKNUSO8QY=
+-----END SSH SIGNATURE-----
+`,
+ },
+ }
+
+ o := commitToGitObject(gitCommit)
+ commitVerification := ParseObjectWithSSHSignature(db.DefaultContext, &o, user2)
+ assert.True(t, commitVerification.Verified)
+ assert.Equal(t, "user2 / SHA256:TKfwbZMR7e9OnlV2l1prfah1TXH8CmqR0PvFEXVCXA4", commitVerification.Reason)
+ assert.Equal(t, sshKey, commitVerification.SigningSSHKey)
+ })
+
+ t.Run("Valid signature with noreply email", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.Service.NoReplyAddress, "noreply.example.com")()
+
+ gitCommit := &git.Commit{
+ Committer: &git.Signature{
+ Email: "user2@noreply.example.com",
+ },
+ Signature: &git.ObjectSignature{
+ Payload: `tree 4836c7f639f37388bab4050ef5c97bbbd54272fc
+parent 795be1b0117ea5c65456050bb9fd84744d4fd9c6
+author user2 <user2@noreply.example.com> 1699709594 +0100
+committer user2 <user2@noreply.example.com> 1699709594 +0100
+
+Commit with noreply
+`,
+ Signature: `-----BEGIN SSH SIGNATURE-----
+U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgoGSe9Zy7Ez9bSJcaTNjh/Y7p95
+f5DujjqkpzFRtw6CEAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
+AAAAQJz83KKxD6Bz/ZvNpqkA3RPOSQ4LQ5FfEItbtoONkbwV9wAWMnmBqgggo/lnXCJ3oq
+muPLbvEduU+Ze/1Ol1pgk=
+-----END SSH SIGNATURE-----
+`,
+ },
+ }
+
+ o := commitToGitObject(gitCommit)
+ commitVerification := ParseObjectWithSSHSignature(db.DefaultContext, &o, user2)
+ assert.True(t, commitVerification.Verified)
+ assert.Equal(t, "user2 / SHA256:TKfwbZMR7e9OnlV2l1prfah1TXH8CmqR0PvFEXVCXA4", commitVerification.Reason)
+ assert.Equal(t, sshKey, commitVerification.SigningSSHKey)
+ })
+}
diff --git a/models/asymkey/ssh_key_parse.go b/models/asymkey/ssh_key_parse.go
new file mode 100644
index 0000000..94b1cf1
--- /dev/null
+++ b/models/asymkey/ssh_key_parse.go
@@ -0,0 +1,312 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/asn1"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/pem"
+ "fmt"
+ "math/big"
+ "os"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+
+ "golang.org/x/crypto/ssh"
+)
+
+// ____ __. __________
+// | |/ _|____ ___.__. \______ \_____ _______ ______ ___________
+// | <_/ __ < | | | ___/\__ \\_ __ \/ ___// __ \_ __ \
+// | | \ ___/\___ | | | / __ \| | \/\___ \\ ___/| | \/
+// |____|__ \___ > ____| |____| (____ /__| /____ >\___ >__|
+// \/ \/\/ \/ \/ \/
+//
+// This file contains functions for parsing ssh-keys
+//
+// TODO: Consider if these functions belong in models - no other models function call them or are called by them
+// They may belong in a service or a module
+
+const ssh2keyStart = "---- BEGIN SSH2 PUBLIC KEY ----"
+
+func extractTypeFromBase64Key(key string) (string, error) {
+ b, err := base64.StdEncoding.DecodeString(key)
+ if err != nil || len(b) < 4 {
+ return "", fmt.Errorf("invalid key format: %w", err)
+ }
+
+ keyLength := int(binary.BigEndian.Uint32(b))
+ if len(b) < 4+keyLength {
+ return "", fmt.Errorf("invalid key format: not enough length %d", keyLength)
+ }
+
+ 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) {
+ // remove whitespace at start and end
+ content = strings.TrimSpace(content)
+
+ var keyType, keyContent, keyComment string
+
+ if strings.HasPrefix(content, ssh2keyStart) {
+ // Parse SSH2 file format.
+
+ // Transform all legal line endings to a single "\n".
+ content = strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(content)
+
+ lines := strings.Split(content, "\n")
+ 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 += line
+ }
+ }
+
+ t, err := extractTypeFromBase64Key(keyContent)
+ if err != nil {
+ return "", fmt.Errorf("extractTypeFromBase64Key: %w", err)
+ }
+ keyType = t
+ } else {
+ if strings.Contains(content, "-----BEGIN") {
+ // Convert PEM Keys to OpenSSH format
+ // Transform all legal line endings to a single "\n".
+ content = strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(content)
+
+ block, _ := pem.Decode([]byte(content))
+ if block == nil {
+ return "", fmt.Errorf("failed to parse PEM block containing the public key")
+ }
+ if strings.Contains(block.Type, "PRIVATE") {
+ return "", ErrKeyIsPrivate
+ }
+
+ pub, err := x509.ParsePKIXPublicKey(block.Bytes)
+ if err != nil {
+ var pk rsa.PublicKey
+ _, err2 := asn1.Unmarshal(block.Bytes, &pk)
+ if err2 != nil {
+ return "", fmt.Errorf("failed to parse DER encoded public key as either PKIX or PEM RSA Key: %v %w", err, err2)
+ }
+ pub = &pk
+ }
+
+ sshKey, err := ssh.NewPublicKey(pub)
+ if err != nil {
+ return "", fmt.Errorf("unable to convert to ssh public key: %w", err)
+ }
+ content = string(ssh.MarshalAuthorizedKey(sshKey))
+ }
+ // Parse OpenSSH format.
+
+ // Remove all newlines
+ content = strings.NewReplacer("\r\n", "", "\n", "").Replace(content)
+
+ parts := strings.SplitN(content, " ", 3)
+ switch len(parts) {
+ case 0:
+ return "", util.NewInvalidArgumentErrorf("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.
+ t, err := extractTypeFromBase64Key(keyContent)
+ if err != nil {
+ return "", fmt.Errorf("extractTypeFromBase64Key: %w", err)
+ }
+ if len(keyType) == 0 {
+ keyType = t
+ } else if keyType != t {
+ return "", fmt.Errorf("key type and content does not match: %s - %s", keyType, t)
+ }
+ }
+ // Finally we need to check whether we can actually read the proposed key:
+ _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyType + " " + keyContent + " " + keyComment))
+ if err != nil {
+ return "", fmt.Errorf("invalid ssh public key: %w", err)
+ }
+ return keyType + " " + keyContent + " " + keyComment, nil
+}
+
+// CheckPublicKeyString checks if the given public key string is recognized by SSH.
+// It 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 "", util.NewInvalidArgumentErrorf("only a single line with a single key please")
+ }
+
+ // remove any unnecessary whitespace now
+ content = strings.TrimSpace(content)
+
+ if !setting.SSH.MinimumKeySizeCheck {
+ return content, nil
+ }
+
+ var (
+ fnName string
+ keyType string
+ length int
+ )
+ if len(setting.SSH.KeygenPath) == 0 {
+ fnName = "SSHNativeParsePublicKey"
+ keyType, length, err = SSHNativeParsePublicKey(content)
+ } else {
+ fnName = "SSHKeyGenParsePublicKey"
+ keyType, length, err = SSHKeyGenParsePublicKey(content)
+ }
+ if err != nil {
+ return "", fmt.Errorf("%s: %w", fnName, err)
+ }
+ log.Trace("Key info [native: %v]: %s-%d", setting.SSH.StartBuiltinServer, keyType, length)
+
+ if minLen, found := setting.SSH.MinimumKeySizes[keyType]; found && length >= minLen {
+ return content, nil
+ } else if found && length < minLen {
+ return "", fmt.Errorf("key length is not enough: got %d, needs %d", length, minLen)
+ }
+ return "", fmt.Errorf("key type is not allowed: %s", keyType)
+}
+
+// SSHNativeParsePublicKey extracts 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", keyLine)
+ }
+
+ raw, err := base64.StdEncoding.DecodeString(fields[1])
+ if err != nil {
+ return "", 0, err
+ }
+
+ pkey, err := ssh.ParsePublicKey(raw)
+ if err != nil {
+ if strings.Contains(err.Error(), "ssh: unknown key algorithm") {
+ return "", 0, ErrKeyUnableVerify{err.Error()}
+ }
+ return "", 0, fmt.Errorf("ParsePublicKey: %w", 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.KeyAlgoED25519:
+ return "ed25519", 256, nil
+ case ssh.KeyAlgoSKECDSA256:
+ return "ecdsa-sk", 256, nil
+ case ssh.KeyAlgoSKED25519:
+ return "ed25519-sk", 256, nil
+ }
+ return "", 0, fmt.Errorf("unsupported key length detection for type: %s", pkey.Type())
+}
+
+// writeTmpKeyFile writes key content to a temporary file
+// and returns the name of that file, along with any possible errors.
+func writeTmpKeyFile(content string) (string, error) {
+ tmpFile, err := os.CreateTemp(setting.SSH.KeyTestPath, "gitea_keytest")
+ if err != nil {
+ return "", fmt.Errorf("TempFile: %w", err)
+ }
+ defer tmpFile.Close()
+
+ if _, err = tmpFile.WriteString(content); err != nil {
+ return "", fmt.Errorf("WriteString: %w", err)
+ }
+ return tmpFile.Name(), nil
+}
+
+// SSHKeyGenParsePublicKey extracts key type and length using ssh-keygen.
+func SSHKeyGenParsePublicKey(key string) (string, int, error) {
+ tmpName, err := writeTmpKeyFile(key)
+ if err != nil {
+ return "", 0, fmt.Errorf("writeTmpKeyFile: %w", err)
+ }
+ defer func() {
+ if err := util.Remove(tmpName); err != nil {
+ log.Warn("Unable to remove temporary key file: %s: Error: %v", tmpName, err)
+ }
+ }()
+
+ keygenPath := setting.SSH.KeygenPath
+ if len(keygenPath) == 0 {
+ keygenPath = "ssh-keygen"
+ }
+
+ stdout, stderr, err := process.GetManager().Exec("SSHKeyGenParsePublicKey", keygenPath, "-lf", tmpName)
+ if err != nil {
+ return "", 0, fmt.Errorf("fail to parse public key: %s - %s", err, stderr)
+ }
+ if strings.Contains(stdout, "is not a public key file") {
+ return "", 0, ErrKeyUnableVerify{stdout}
+ }
+
+ fields := strings.Split(stdout, " ")
+ if len(fields) < 4 {
+ return "", 0, fmt.Errorf("invalid public key line: %s", stdout)
+ }
+
+ keyType := strings.Trim(fields[len(fields)-1], "()\r\n")
+ length, err := strconv.ParseInt(fields[0], 10, 32)
+ if err != nil {
+ return "", 0, err
+ }
+ return strings.ToLower(keyType), int(length), nil
+}
diff --git a/models/asymkey/ssh_key_principals.go b/models/asymkey/ssh_key_principals.go
new file mode 100644
index 0000000..4e7dee2
--- /dev/null
+++ b/models/asymkey/ssh_key_principals.go
@@ -0,0 +1,96 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/perm"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// AddPrincipalKey adds new principal to database and authorized_principals file.
+func AddPrincipalKey(ctx context.Context, ownerID int64, content string, authSourceID int64) (*PublicKey, error) {
+ dbCtx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer committer.Close()
+
+ // Principals cannot be duplicated.
+ has, err := db.GetEngine(dbCtx).
+ 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: perm.AccessModeWrite,
+ Type: KeyTypePrincipal,
+ LoginSourceID: authSourceID,
+ }
+ if err = db.Insert(dbCtx, key); err != nil {
+ return nil, fmt.Errorf("addKey: %w", err)
+ }
+
+ if err = committer.Commit(); err != nil {
+ return nil, err
+ }
+
+ committer.Close()
+
+ return key, RewriteAllPrincipalKeys(ctx)
+}
+
+// CheckPrincipalKeyString strips spaces and returns an error if the given principal contains newlines
+func CheckPrincipalKeyString(ctx context.Context, user *user_model.User, content string) (_ string, err error) {
+ if setting.SSH.Disabled {
+ return "", db.ErrSSHDisabled{}
+ }
+
+ content = strings.TrimSpace(content)
+ if strings.ContainsAny(content, "\r\n") {
+ return "", util.NewInvalidArgumentErrorf("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 := user_model.GetEmailAddresses(ctx, 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)
+}
diff --git a/models/asymkey/ssh_key_test.go b/models/asymkey/ssh_key_test.go
new file mode 100644
index 0000000..2625d6a
--- /dev/null
+++ b/models/asymkey/ssh_key_test.go
@@ -0,0 +1,513 @@
+// Copyright 2016 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "bytes"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/42wim/sshsig"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_SSHParsePublicKey(t *testing.T) {
+ testCases := []struct {
+ name string
+ skipSSHKeygen bool
+ keyType string
+ length int
+ content string
+ }{
+ {"rsa-1024", false, "rsa", 1024, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\n"},
+ {"rsa-2048", false, "rsa", 2048, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDMZXh+1OBUwSH9D45wTaxErQIN9IoC9xl7MKJkqvTvv6O5RR9YW/IK9FbfjXgXsppYGhsCZo1hFOOsXHMnfOORqu/xMDx4yPuyvKpw4LePEcg4TDipaDFuxbWOqc/BUZRZcXu41QAWfDLrInwsltWZHSeG7hjhpacl4FrVv9V1pS6Oc5Q1NxxEzTzuNLS/8diZrTm/YAQQ/+B+mzWI3zEtF4miZjjAljWd1LTBPvU23d29DcBmmFahcZ441XZsTeAwGxG/Q6j8NgNXj9WxMeWwxXV2jeAX/EBSpZrCVlCQ1yJswT6xCp8TuBnTiGWYMBNTbOZvPC4e0WI2/yZW/s5F nocomment"},
+ {"ecdsa-256", false, "ecdsa", 256, "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFQacN3PrOll7PXmN5B/ZNVahiUIqI05nbBlZk1KXsO3d06ktAWqbNflv2vEmA38bTFTfJ2sbn2B5ksT52cDDbA= nocomment"},
+ {"ecdsa-384", false, "ecdsa", 384, "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBINmioV+XRX1Fm9Qk2ehHXJ2tfVxW30ypUWZw670Zyq5GQfBAH6xjygRsJ5wWsHXBsGYgFUXIHvMKVAG1tpw7s6ax9oA+dJOJ7tj+vhn8joFqT+sg3LYHgZkHrfqryRasQ== nocomment"},
+ {"ecdsa-sk", true, "ecdsa-sk", 256, "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment"},
+ {"ed25519-sk", true, "ed25519-sk", 256, "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIE7kM1R02+4ertDKGKEDcKG0s+2vyDDcIvceJ0Gqv5f1AAAABHNzaDo= nocomment"},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Run("Native", func(t *testing.T) {
+ keyTypeN, lengthN, err := SSHNativeParsePublicKey(tc.content)
+ require.NoError(t, err)
+ assert.Equal(t, tc.keyType, keyTypeN)
+ assert.EqualValues(t, tc.length, lengthN)
+ })
+ if tc.skipSSHKeygen {
+ return
+ }
+ t.Run("SSHKeygen", func(t *testing.T) {
+ keyTypeK, lengthK, err := SSHKeyGenParsePublicKey(tc.content)
+ if err != nil {
+ // Some servers do not support ecdsa format.
+ if !strings.Contains(err.Error(), "line 1 too long:") {
+ assert.FailNow(t, "%v", err)
+ }
+ }
+ assert.Equal(t, tc.keyType, keyTypeK)
+ assert.EqualValues(t, tc.length, lengthK)
+ })
+ t.Run("SSHParseKeyNative", func(t *testing.T) {
+ keyTypeK, lengthK, err := SSHNativeParsePublicKey(tc.content)
+ if err != nil {
+ assert.FailNow(t, "%v", err)
+ }
+ assert.Equal(t, tc.keyType, keyTypeK)
+ assert.EqualValues(t, tc.length, lengthK)
+ })
+ })
+ }
+}
+
+func Test_CheckPublicKeyString(t *testing.T) {
+ oldValue := setting.SSH.MinimumKeySizeCheck
+ setting.SSH.MinimumKeySizeCheck = false
+ for _, test := range []struct {
+ content string
+ }{
+ {"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\n"},
+ {"ssh-rsa AAAAB3NzaC1yc2EA\r\nAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+\r\nBZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNx\r\nfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\r\n\r\n"},
+ {"ssh-rsa AAAAB3NzaC1yc2EA\r\nAAADAQABAAAAgQDAu7tvI\nvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+\r\nBZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvW\nqIwC4prx/WVk2wLTJjzBAhyNx\r\nfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\r\n\r\n"},
+ {"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf"},
+ {"\r\nssh-ed25519 \r\nAAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf\r\n\r\n"},
+ {"sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment"},
+ {"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIE7kM1R02+4ertDKGKEDcKG0s+2vyDDcIvceJ0Gqv5f1AAAABHNzaDo= nocomment"},
+ {`---- BEGIN SSH2 PUBLIC KEY ----
+Comment: "1024-bit DSA, converted by andrew@phaedra from OpenSSH"
+AAAAB3NzaC1kc3MAAACBAOChCC7lf6Uo9n7BmZ6M8St19PZf4Tn59NriyboW2x/DZuYAz3
+ibZ2OkQ3S0SqDIa0HXSEJ1zaExQdmbO+Ux/wsytWZmCczWOVsaszBZSl90q8UnWlSH6P+/
+YA+RWJm5SFtuV9PtGIhyZgoNuz5kBQ7K139wuQsecdKktISwTakzAAAAFQCzKsO2JhNKlL
++wwwLGOcLffoAmkwAAAIBpK7/3xvduajLBD/9vASqBQIHrgK2J+wiQnIb/Wzy0UsVmvfn8
+A+udRbBo+csM8xrSnlnlJnjkJS3qiM5g+eTwsLIV1IdKPEwmwB+VcP53Cw6lSyWyJcvhFb
+0N6s08NZysLzvj0N+ZC/FnhKTLzIyMtkHf/IrPCwlM+pV/M/96YgAAAIEAqQcGn9CKgzgP
+aguIZooTAOQdvBLMI5y0bQjOW6734XOpqQGf/Kra90wpoasLKZjSYKNPjE+FRUOrStLrxc
+Ns4BeVKhy2PYTRnybfYVk1/dmKgH6P1YSRONsGKvTsH6c5IyCRG0ncCgYeF8tXppyd6429
+82daopE7zQ/NPAnJfag=
+---- END SSH2 PUBLIC KEY ----
+`},
+ {`---- BEGIN SSH2 PUBLIC KEY ----
+Comment: "1024-bit RSA, converted by andrew@phaedra from OpenSSH"
+AAAAB3NzaC1yc2EAAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxB
+cQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jBeSjH2G7lxet9kbcH+kIV
+j0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C9CeiX9pQEbEqJfkKCQ==
+---- END SSH2 PUBLIC KEY ----
+`},
+ {`-----BEGIN RSA PUBLIC KEY-----
+MIGJAoGBAMC7u28i9fpketFe5k1+RHdcsdKy4Ir1mfdfnyXEFxDO6jnFmAHq9HDC
+b9C0m4X7Nk+1jmGxAgsEuYX4FnlakpmnWMF5KMfYbuXF632Rtwf6QhWPS08USjIo
+j3C9aojALimvH9ZWTbAtMmPMECHI3F8SrsL0J6Jf2lARsSol+QoJAgMBAAE=
+-----END RSA PUBLIC KEY-----
+`},
+ {`-----BEGIN PUBLIC KEY-----
+MIIBtzCCASsGByqGSM44BAEwggEeAoGBAOChCC7lf6Uo9n7BmZ6M8St19PZf4Tn5
+9NriyboW2x/DZuYAz3ibZ2OkQ3S0SqDIa0HXSEJ1zaExQdmbO+Ux/wsytWZmCczW
+OVsaszBZSl90q8UnWlSH6P+/YA+RWJm5SFtuV9PtGIhyZgoNuz5kBQ7K139wuQse
+cdKktISwTakzAhUAsyrDtiYTSpS/sMMCxjnC336AJpMCgYBpK7/3xvduajLBD/9v
+ASqBQIHrgK2J+wiQnIb/Wzy0UsVmvfn8A+udRbBo+csM8xrSnlnlJnjkJS3qiM5g
++eTwsLIV1IdKPEwmwB+VcP53Cw6lSyWyJcvhFb0N6s08NZysLzvj0N+ZC/FnhKTL
+zIyMtkHf/IrPCwlM+pV/M/96YgOBhQACgYEAqQcGn9CKgzgPaguIZooTAOQdvBLM
+I5y0bQjOW6734XOpqQGf/Kra90wpoasLKZjSYKNPjE+FRUOrStLrxcNs4BeVKhy2
+PYTRnybfYVk1/dmKgH6P1YSRONsGKvTsH6c5IyCRG0ncCgYeF8tXppyd642982da
+opE7zQ/NPAnJfag=
+-----END PUBLIC KEY-----
+`},
+ {`-----BEGIN PUBLIC KEY-----
+MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDAu7tvIvX6ZHrRXuZNfkR3XLHS
+suCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jB
+eSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C
+9CeiX9pQEbEqJfkKCQIDAQAB
+-----END PUBLIC KEY-----
+`},
+ {`-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzGV4ftTgVMEh/Q+OcE2s
+RK0CDfSKAvcZezCiZKr077+juUUfWFvyCvRW3414F7KaWBobAmaNYRTjrFxzJ3zj
+karv8TA8eMj7sryqcOC3jxHIOEw4qWgxbsW1jqnPwVGUWXF7uNUAFnwy6yJ8LJbV
+mR0nhu4Y4aWnJeBa1b/VdaUujnOUNTccRM087jS0v/HYma05v2AEEP/gfps1iN8x
+LReJomY4wJY1ndS0wT71Nt3dvQ3AZphWoXGeONV2bE3gMBsRv0Oo/DYDV4/VsTHl
+sMV1do3gF/xAUqWawlZQkNcibME+sQqfE7gZ04hlmDATU2zmbzwuHtFiNv8mVv7O
+RQIDAQAB
+-----END PUBLIC KEY-----
+`},
+ {`---- BEGIN SSH2 PUBLIC KEY ----
+Comment: "256-bit ED25519, converted by andrew@phaedra from OpenSSH"
+AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf
+---- END SSH2 PUBLIC KEY ----
+`},
+ } {
+ _, err := CheckPublicKeyString(test.content)
+ require.NoError(t, err)
+ }
+ setting.SSH.MinimumKeySizeCheck = oldValue
+ for _, invalidKeys := range []struct {
+ content string
+ }{
+ {"test"},
+ {"---- NOT A REAL KEY ----"},
+ {"bad\nkey"},
+ {"\t\t:)\t\r\n"},
+ {"\r\ntest \r\ngitea\r\n\r\n"},
+ } {
+ _, err := CheckPublicKeyString(invalidKeys.content)
+ require.Error(t, err)
+ }
+}
+
+func Test_calcFingerprint(t *testing.T) {
+ testCases := []struct {
+ name string
+ skipSSHKeygen bool
+ fp string
+ content string
+ }{
+ {"rsa-1024", false, "SHA256:vSnDkvRh/xM6kMxPidLgrUhq3mCN7CDaronCEm2joyQ", "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\n"},
+ {"rsa-2048", false, "SHA256:ZHD//a1b9VuTq9XSunAeYjKeU1xDa2tBFZYrFr2Okkg", "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDMZXh+1OBUwSH9D45wTaxErQIN9IoC9xl7MKJkqvTvv6O5RR9YW/IK9FbfjXgXsppYGhsCZo1hFOOsXHMnfOORqu/xMDx4yPuyvKpw4LePEcg4TDipaDFuxbWOqc/BUZRZcXu41QAWfDLrInwsltWZHSeG7hjhpacl4FrVv9V1pS6Oc5Q1NxxEzTzuNLS/8diZrTm/YAQQ/+B+mzWI3zEtF4miZjjAljWd1LTBPvU23d29DcBmmFahcZ441XZsTeAwGxG/Q6j8NgNXj9WxMeWwxXV2jeAX/EBSpZrCVlCQ1yJswT6xCp8TuBnTiGWYMBNTbOZvPC4e0WI2/yZW/s5F nocomment"},
+ {"ecdsa-256", false, "SHA256:Bqx/xgWqRKLtkZ0Lr4iZpgb+5lYsFpSwXwVZbPwuTRw", "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFQacN3PrOll7PXmN5B/ZNVahiUIqI05nbBlZk1KXsO3d06ktAWqbNflv2vEmA38bTFTfJ2sbn2B5ksT52cDDbA= nocomment"},
+ {"ecdsa-384", false, "SHA256:4qfJOgJDtUd8BrEjyVNdI8IgjiZKouztVde43aDhe1E", "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBINmioV+XRX1Fm9Qk2ehHXJ2tfVxW30ypUWZw670Zyq5GQfBAH6xjygRsJ5wWsHXBsGYgFUXIHvMKVAG1tpw7s6ax9oA+dJOJ7tj+vhn8joFqT+sg3LYHgZkHrfqryRasQ== nocomment"},
+ {"ecdsa-sk", true, "SHA256:4wcIu4z+53gHc+db85OPfy8IydyNzPLCr6kHIs625LQ", "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment"},
+ {"ed25519-sk", true, "SHA256:RB4ku1OeWKN7fLMrjxz38DK0mp1BnOPBx4BItjTvJ0g", "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIE7kM1R02+4ertDKGKEDcKG0s+2vyDDcIvceJ0Gqv5f1AAAABHNzaDo= nocomment"},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Run("Native", func(t *testing.T) {
+ fpN, err := calcFingerprintNative(tc.content)
+ require.NoError(t, err)
+ assert.Equal(t, tc.fp, fpN)
+ })
+ if tc.skipSSHKeygen {
+ return
+ }
+ t.Run("SSHKeygen", func(t *testing.T) {
+ fpK, err := calcFingerprintSSHKeygen(tc.content)
+ require.NoError(t, err)
+ assert.Equal(t, tc.fp, fpK)
+ })
+ })
+ }
+}
+
+var (
+ // Generated with "ssh-keygen -C test@rekor.dev -f id_rsa"
+ sshPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
+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
+-----END OPENSSH PRIVATE KEY-----
+`
+ sshPublicKey = `ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDXofkiahE7uavjWvxnwkUF27qMgz7pdTwzSv0XzVG6EtirOv3PDWct4YKoXE9c0EqbxnIfYEKwEextdvB7zkgwczdJSHxf/18jQumLn/FuoCmugVSk1H5Qli/qzwBpaTnOk3WuakGuoYUl8ZAokKKgOKLA0aZJ1WRQ2ZCZggA3EkwNZiY17y9Q6HqdgQcH6XN8aAMADNVJdMAJb33hSRJjjsAPTmzBTishP8lYDoGRSsSE7/8XRBCEV5E4I8mI9GElcZwV/1KJx98mpH8QvMzXM1idFcwPRtt1NTAOshwgUU0Fu1x8lU5RQIa6ZKW36qNQLvLxy/BscC7B/mdLptoDs/ot9NimUXZcgCR1a2Q3o7Wi6jIgcgJcyV10Nba81ol4RdN4qPHnVZIzuo+dBkqwG3CMtB4Rj84+Qi+7zyU01hIPreoxQDXaayiGPBUUIiAlW9gsiuRWJzNnu3cvuWDLVfQIkjh7Wug58z+v2NOJ7IMdyERillhzDcvVHaq14+U= test@rekor.dev
+`
+ // Generated with "ssh-keygen -C other-test@rekor.dev -f id_rsa"
+ otherSSHPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
+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
+-----END OPENSSH PRIVATE KEY-----
+`
+ otherSSHPublicKey = `ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDD9YJJYL1MS8JA7A75PrwS81rc5Ii/VjT6xPy5K/KM/IQ7T6nS3wKD/PLru87GJClTmM3owi2NcCMyZ4KbwYoy3qCT5wOhoB6/9l+O7NIvtFrITGmaEGV6HZfcYouSRcX0AEU1yGVOpIs5mISMOg2lsW/XopPWwToKpbwOPFdCRCT0krrmEsf4HF5Yw0IQlVoKZrhfThomYLvMkCLnIZ55PRIpWoyiFq8X3Q7peJgUJAe7Bc8/Id+hyqC52ZeejPP7oPprEkpkzBCw2ndYq6Y6OXNafEEIIHWXaM3pFqDxonbbvuIwVdHCNMv/yNoSxbgqTKwN/QaNXb+KpuvSrlvRqsNhu/sKsYFH64fTAbP9miDXHmJkA05uFlQukOstUmJ0QxzbsdcFvs8yw0PLsEZhEHXJzR3TLzenyZSq86VZICvGfVacBk7TikCBOtyWuESHhlc6SfZKfzZ67cOlDyKeSiVjgh+eEh9s4h56ahQ2rW05Sq6GjD0YtEzog2J4csE= other-test@rekor.dev
+`
+
+ // Generated with ssh-keygen -C test@rekor.dev -t ed25519 -f id_ed25519
+ ed25519PrivateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACBB45zRHxPPFtabwS3Vd6Lb9vMe+tIHZj2qN5VQ+bgLfQAAAJgyRa3cMkWt
+3AAAAAtzc2gtZWQyNTUxOQAAACBB45zRHxPPFtabwS3Vd6Lb9vMe+tIHZj2qN5VQ+bgLfQ
+AAAED7y4N/DsVnRQiBZNxEWdsJ9RmbranvtQ3X9jnb6gFed0HjnNEfE88W1pvBLdV3otv2
+8x760gdmPao3lVD5uAt9AAAADnRlc3RAcmVrb3IuZGV2AQIDBAUGBw==
+-----END OPENSSH PRIVATE KEY-----
+`
+ ed25519PublicKey = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEHjnNEfE88W1pvBLdV3otv28x760gdmPao3lVD5uAt9 test@rekor.dev
+`
+)
+
+func TestFromOpenSSH(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ pub string
+ priv string
+ }{
+ {
+ name: "rsa",
+ pub: sshPublicKey,
+ priv: sshPrivateKey,
+ },
+ {
+ name: "ed25519",
+ pub: ed25519PublicKey,
+ priv: ed25519PrivateKey,
+ },
+ } {
+ if _, err := exec.LookPath("ssh-keygen"); err != nil {
+ t.Skip("skip TestFromOpenSSH: missing ssh-keygen in PATH")
+ }
+ t.Run(tt.name, func(t *testing.T) {
+ tt := tt
+
+ // Test that a signature from the cli can validate here.
+ td := t.TempDir()
+
+ data := []byte("hello, ssh world")
+ dataPath := write(t, data, td, "data")
+
+ privPath := write(t, []byte(tt.priv), td, "id")
+ write(t, []byte(tt.pub), td, "id.pub")
+
+ sigPath := dataPath + ".sig"
+ run(t, nil, "ssh-keygen", "-Y", "sign", "-n", "file", "-f", privPath, dataPath)
+
+ sigBytes, err := os.ReadFile(sigPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := sshsig.Verify(bytes.NewReader(data), sigBytes, []byte(tt.pub), "file"); err != nil {
+ t.Error(err)
+ }
+
+ // It should not verify if we check against another public key
+ if err := sshsig.Verify(bytes.NewReader(data), sigBytes, []byte(otherSSHPublicKey), "file"); err == nil {
+ t.Error("expected error with incorrect key")
+ }
+
+ // It should not verify if the data is tampered
+ if err := sshsig.Verify(strings.NewReader("bad data"), sigBytes, []byte(sshPublicKey), "file"); err == nil {
+ t.Error("expected error with incorrect data")
+ }
+ })
+ }
+}
+
+func TestToOpenSSH(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ pub string
+ priv string
+ }{
+ {
+ name: "rsa",
+ pub: sshPublicKey,
+ priv: sshPrivateKey,
+ },
+ {
+ name: "ed25519",
+ pub: ed25519PublicKey,
+ priv: ed25519PrivateKey,
+ },
+ } {
+ if _, err := exec.LookPath("ssh-keygen"); err != nil {
+ t.Skip("skip TestToOpenSSH: missing ssh-keygen in PATH")
+ }
+ t.Run(tt.name, func(t *testing.T) {
+ tt := tt
+ // Test that a signature from here can validate in the CLI.
+ td := t.TempDir()
+
+ data := []byte("hello, ssh world")
+ write(t, data, td, "data")
+
+ armored, err := sshsig.Sign([]byte(tt.priv), bytes.NewReader(data), "file")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ sigPath := write(t, armored, td, "oursig")
+
+ // Create an allowed_signers file with two keys to check against.
+ allowedSigner := "test@rekor.dev " + tt.pub + "\n"
+ allowedSigner += "othertest@rekor.dev " + otherSSHPublicKey + "\n"
+ allowedSigners := write(t, []byte(allowedSigner), td, "allowed_signer")
+
+ // We use the correct principal here so it should work.
+ run(t, data, "ssh-keygen", "-Y", "verify", "-f", allowedSigners,
+ "-I", "test@rekor.dev", "-n", "file", "-s", sigPath)
+
+ // Just to be sure, check against the other public key as well.
+ runErr(t, data, "ssh-keygen", "-Y", "verify", "-f", allowedSigners,
+ "-I", "othertest@rekor.dev", "-n", "file", "-s", sigPath)
+
+ // It should error if we run it against other data
+ data = []byte("other data!")
+ runErr(t, data, "ssh-keygen", "-Y", "check-novalidate", "-n", "file", "-s", sigPath)
+ })
+ }
+}
+
+func TestRoundTrip(t *testing.T) {
+ data := []byte("my good data to be signed!")
+
+ // Create one extra signature for all the tests.
+ otherSig, err := sshsig.Sign([]byte(otherSSHPrivateKey), bytes.NewReader(data), "file")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for _, tt := range []struct {
+ name string
+ pub string
+ priv string
+ }{
+ {
+ name: "rsa",
+ pub: sshPublicKey,
+ priv: sshPrivateKey,
+ },
+ {
+ name: "ed25519",
+ pub: ed25519PublicKey,
+ priv: ed25519PrivateKey,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ tt := tt
+ sig, err := sshsig.Sign([]byte(tt.priv), bytes.NewReader(data), "file")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Check the signature against that data and public key
+ if err := sshsig.Verify(bytes.NewReader(data), sig, []byte(tt.pub), "file"); err != nil {
+ t.Error(err)
+ }
+
+ // Now check it against invalid data.
+ if err := sshsig.Verify(strings.NewReader("invalid data!"), sig, []byte(tt.pub), "file"); err == nil {
+ t.Error("expected error!")
+ }
+
+ // Now check it against the wrong key.
+ if err := sshsig.Verify(bytes.NewReader(data), sig, []byte(otherSSHPublicKey), "file"); err == nil {
+ t.Error("expected error!")
+ }
+
+ // Now check it against an invalid signature data.
+ if err := sshsig.Verify(bytes.NewReader(data), []byte("invalid signature!"), []byte(tt.pub), "file"); err == nil {
+ t.Error("expected error!")
+ }
+
+ // Once more, use the wrong signature and check it against the original (wrong public key)
+ if err := sshsig.Verify(bytes.NewReader(data), otherSig, []byte(tt.pub), "file"); err == nil {
+ t.Error("expected error!")
+ }
+ // It should work against the correct public key.
+ if err := sshsig.Verify(bytes.NewReader(data), otherSig, []byte(otherSSHPublicKey), "file"); err != nil {
+ t.Error(err)
+ }
+ })
+ }
+}
+
+func write(t *testing.T, d []byte, fp ...string) string {
+ p := filepath.Join(fp...)
+ if err := os.WriteFile(p, d, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+func run(t *testing.T, stdin []byte, args ...string) {
+ t.Helper()
+ /* #nosec */
+ cmd := exec.Command(args[0], args[1:]...)
+ cmd.Stdin = bytes.NewReader(stdin)
+ out, err := cmd.CombinedOutput()
+ t.Logf("cmd %v: %s", cmd, string(out))
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func runErr(t *testing.T, stdin []byte, args ...string) {
+ t.Helper()
+ /* #nosec */
+ cmd := exec.Command(args[0], args[1:]...)
+ cmd.Stdin = bytes.NewReader(stdin)
+ out, err := cmd.CombinedOutput()
+ t.Logf("cmd %v: %s", cmd, string(out))
+ if err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func Test_PublicKeysAreExternallyManaged(t *testing.T) {
+ key1 := unittest.AssertExistsAndLoadBean(t, &PublicKey{ID: 1})
+ externals, err := PublicKeysAreExternallyManaged(db.DefaultContext, []*PublicKey{key1})
+ require.NoError(t, err)
+ assert.Len(t, externals, 1)
+ assert.False(t, externals[0])
+}
diff --git a/models/asymkey/ssh_key_verify.go b/models/asymkey/ssh_key_verify.go
new file mode 100644
index 0000000..208288c
--- /dev/null
+++ b/models/asymkey/ssh_key_verify.go
@@ -0,0 +1,55 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package asymkey
+
+import (
+ "bytes"
+ "context"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/42wim/sshsig"
+)
+
+// VerifySSHKey marks a SSH key as verified
+func VerifySSHKey(ctx context.Context, ownerID int64, fingerprint, token, signature string) (string, error) {
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return "", err
+ }
+ defer committer.Close()
+
+ key := new(PublicKey)
+
+ has, err := db.GetEngine(ctx).Where("owner_id = ? AND fingerprint = ?", ownerID, fingerprint).Get(key)
+ if err != nil {
+ return "", err
+ } else if !has {
+ return "", ErrKeyNotExist{}
+ }
+
+ err = sshsig.Verify(bytes.NewBuffer([]byte(token)), []byte(signature), []byte(key.Content), "gitea")
+ if err != nil {
+ // edge case for Windows based shells that will add CR LF if piped to ssh-keygen command
+ // see https://github.com/PowerShell/PowerShell/issues/5974
+ if sshsig.Verify(bytes.NewBuffer([]byte(token+"\r\n")), []byte(signature), []byte(key.Content), "gitea") != nil {
+ log.Error("Unable to validate token signature. Error: %v", err)
+ return "", ErrSSHInvalidTokenSignature{
+ Fingerprint: key.Fingerprint,
+ }
+ }
+ }
+
+ key.Verified = true
+ if _, err := db.GetEngine(ctx).ID(key.ID).Cols("verified").Update(key); err != nil {
+ return "", err
+ }
+
+ if err := committer.Commit(); err != nil {
+ return "", err
+ }
+
+ return key.Fingerprint, nil
+}