diff --git a/models/asymkey/gpg_key_commit_verification.go b/models/asymkey/gpg_key_commit_verification.go index 83fbab5d3..9aa606405 100644 --- a/models/asymkey/gpg_key_commit_verification.go +++ b/models/asymkey/gpg_key_commit_verification.go @@ -5,18 +5,10 @@ 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/keybase/go-crypto/openpgp/packet" ) // __________________ ________ ____ __. @@ -40,45 +32,22 @@ import ( // This file provides functions relating commit verification -// CommitVerification represents a commit validation of signature -type CommitVerification struct { - Verified bool - Warning bool - Reason string - SigningUser *user_model.User - CommittingUser *user_model.User - SigningEmail string - SigningKey *GPGKey - SigningSSHKey *PublicKey - TrustStatus string -} - // SignCommit represents a commit with validation of signature. type SignCommit struct { - Verification *CommitVerification + Verification *ObjectVerification *user_model.UserCommit } -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" -) - // 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: ParseCommitWithSignature(ctx, c.Commit), + Verification: ParseObjectWithSignature(ctx, &o), } _ = CalculateTrustStatus(signCommit.Verification, repoTrustModel, isOwnerMemberCollaborator, &keyMap) @@ -88,456 +57,7 @@ func ParseCommitsWithSignature(ctx context.Context, oldCommits []*user_model.Use return newCommits } -// ParseCommitWithSignature check if signature is good against keystore. -func ParseCommitWithSignature(ctx context.Context, c *git.Commit) *CommitVerification { - 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 &CommitVerification{ - CommittingUser: committer, - Verified: false, - Reason: "gpg.error.no_committer_account", - } - } - - } - } - - // If no signature just report the committer - if c.Signature == nil { - return &CommitVerification{ - 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 ParseCommitWithSSHSignature(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 &CommitVerification{ - CommittingUser: committer, - Verified: false, - Reason: "gpg.error.extract_sign", - } - } - - keyID := "" - if sig.IssuerKeyId != nil && (*sig.IssuerKeyId) != 0 { - keyID = fmt.Sprintf("%X", *sig.IssuerKeyId) - } - if keyID == "" && sig.IssuerFingerprint != nil && len(sig.IssuerFingerprint) > 0 { - keyID = fmt.Sprintf("%X", sig.IssuerFingerprint[12:20]) - } - 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 &CommitVerification{ - 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 &CommitVerification{ - 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 := hashAndVerifyWithSubKeysCommitVerification(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.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.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 &CommitVerification{ // 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) *CommitVerification { - // 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 &CommitVerification{ - CommittingUser: committer, - Verified: false, - Reason: "gpg.error.generate_hash", - } - } - for _, ekey := range ekeys { - pubkey := ekey.PrimaryKey - content, err := base64EncPubKey(pubkey) - if err != nil { - return &CommitVerification{ - 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 &CommitVerification{ - 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 := hashAndVerifyWithSubKeysCommitVerification(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 &CommitVerification{ - 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 hashAndVerifyWithSubKeysCommitVerification(sig *packet.Signature, payload string, k *GPGKey, committer, signer *user_model.User, email string) *CommitVerification { - key, err := hashAndVerifyWithSubKeys(sig, payload, k) - if err != nil { // Skipping failed to generate hash - return &CommitVerification{ - CommittingUser: committer, - Verified: false, - Reason: "gpg.error.generate_hash", - } - } - - if key != nil { - return &CommitVerification{ // 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) *CommitVerification { - if keyID == "" { - return nil - } - keys, err := db.Find[GPGKey](ctx, FindGPGKeyOptions{ - KeyID: keyID, - IncludeSubKeys: true, - }) - if err != nil { - log.Error("GetGPGKeysByKeyID: %v", err) - return &CommitVerification{ - 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 &CommitVerification{ - 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 &CommitVerification{ - CommittingUser: committer, - Verified: false, - Reason: "gpg.error.no_committer_account", - } - } - } - commitVerification := hashAndVerifyWithSubKeysCommitVerification(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 &CommitVerification{ - 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 *CommitVerification, 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 +func ParseCommitWithSignature(ctx context.Context, c *git.Commit) *ObjectVerification { + o := commitToGitObject(c) + return ParseObjectWithSignature(ctx, &o) } diff --git a/models/asymkey/gpg_key_object_verification.go b/models/asymkey/gpg_key_object_verification.go new file mode 100644 index 000000000..cbae280c6 --- /dev/null +++ b/models/asymkey/gpg_key_object_verification.go @@ -0,0 +1,527 @@ +// 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/keybase/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 := "" + if sig.IssuerKeyId != nil && (*sig.IssuerKeyId) != 0 { + keyID = fmt.Sprintf("%X", *sig.IssuerKeyId) + } + if keyID == "" && sig.IssuerFingerprint != nil && len(sig.IssuerFingerprint) > 0 { + keyID = fmt.Sprintf("%X", sig.IssuerFingerprint[12:20]) + } + 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 000000000..5fd3983e5 --- /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/ssh_key_commit_verification.go b/models/asymkey/ssh_key_object_verification.go similarity index 79% rename from models/asymkey/ssh_key_commit_verification.go rename to models/asymkey/ssh_key_object_verification.go index 2b802710a..5ad6fdb0a 100644 --- a/models/asymkey/ssh_key_commit_verification.go +++ b/models/asymkey/ssh_key_object_verification.go @@ -11,14 +11,13 @@ import ( "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "github.com/42wim/sshsig" ) -// ParseCommitWithSSHSignature check if signature is good against keystore. -func ParseCommitWithSSHSignature(ctx context.Context, c *git.Commit, committer *user_model.User) *CommitVerification { +// 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{ @@ -27,7 +26,7 @@ func ParseCommitWithSSHSignature(ctx context.Context, c *git.Commit, committer * }) if err != nil { // Skipping failed to get ssh keys of user log.Error("ListPublicKeys: %v", err) - return &CommitVerification{ + return &ObjectVerification{ CommittingUser: committer, Verified: false, Reason: "gpg.error.failed_retrieval_gpg_keys", @@ -55,7 +54,7 @@ func ParseCommitWithSSHSignature(ctx context.Context, c *git.Commit, committer * for _, k := range keys { if k.Verified && activated { - commitVerification := verifySSHCommitVerification(c.Signature.Signature, c.Signature.Payload, k, committer, committer, c.Committer.Email) + commitVerification := verifySSHObjectVerification(c.Signature.Signature, c.Signature.Payload, k, committer, committer, c.Committer.Email) if commitVerification != nil { return commitVerification } @@ -63,19 +62,19 @@ func ParseCommitWithSSHSignature(ctx context.Context, c *git.Commit, committer * } } - return &CommitVerification{ + return &ObjectVerification{ CommittingUser: committer, Verified: false, Reason: NoKeyFound, } } -func verifySSHCommitVerification(sig, payload string, k *PublicKey, committer, signer *user_model.User, email string) *CommitVerification { +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 &CommitVerification{ // Everything is ok + return &ObjectVerification{ // Everything is ok CommittingUser: committer, Verified: true, Reason: fmt.Sprintf("%s / %s", signer.Name, k.Fingerprint), diff --git a/models/asymkey/ssh_key_commit_verification_test.go b/models/asymkey/ssh_key_object_verification_test.go similarity index 85% rename from models/asymkey/ssh_key_commit_verification_test.go rename to models/asymkey/ssh_key_object_verification_test.go index 320e114b3..4e229c9b1 100644 --- a/models/asymkey/ssh_key_commit_verification_test.go +++ b/models/asymkey/ssh_key_object_verification_test.go @@ -22,7 +22,8 @@ func TestParseCommitWithSSHSignature(t *testing.T) { sshKey := unittest.AssertExistsAndLoadBean(t, &PublicKey{ID: 1000, OwnerID: 2}) t.Run("No commiter", func(t *testing.T) { - commitVerification := ParseCommitWithSSHSignature(db.DefaultContext, &git.Commit{}, &user_model.User{}) + o := commitToGitObject(&git.Commit{}) + commitVerification := ParseObjectWithSSHSignature(db.DefaultContext, &o, &user_model.User{}) assert.False(t, commitVerification.Verified) assert.Equal(t, NoKeyFound, commitVerification.Reason) }) @@ -30,7 +31,8 @@ func TestParseCommitWithSSHSignature(t *testing.T) { t.Run("Commiter without keys", func(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - commitVerification := ParseCommitWithSSHSignature(db.DefaultContext, &git.Commit{Committer: &git.Signature{Email: user.Email}}, user) + 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) }) @@ -57,7 +59,8 @@ AAAAQIMufOuSjZeDUujrkVK4sl7ICa0WwEftas8UAYxx0Thdkiw2qWjR1U1PKfTLm16/w8 `, }, } - commitVerification := ParseCommitWithSSHSignature(db.DefaultContext, gitCommit, user2) + o := commitToGitObject(gitCommit) + commitVerification := ParseObjectWithSSHSignature(db.DefaultContext, &o, user2) assert.False(t, commitVerification.Verified) assert.Equal(t, NoKeyFound, commitVerification.Reason) }) @@ -79,7 +82,8 @@ Add content }, } - commitVerification := ParseCommitWithSSHSignature(db.DefaultContext, gitCommit, user2) + o := commitToGitObject(gitCommit) + commitVerification := ParseObjectWithSSHSignature(db.DefaultContext, &o, user2) assert.False(t, commitVerification.Verified) assert.Equal(t, NoKeyFound, commitVerification.Reason) }) @@ -107,7 +111,8 @@ fs9cMpZVM9BfIKNUSO8QY= }, } - commitVerification := ParseCommitWithSSHSignature(db.DefaultContext, gitCommit, user2) + 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) @@ -138,7 +143,8 @@ muPLbvEduU+Ze/1Ol1pgk= }, } - commitVerification := ParseCommitWithSSHSignature(db.DefaultContext, gitCommit, user2) + 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/modules/gitgraph/graph_models.go b/modules/gitgraph/graph_models.go index e48fef8b9..e9c100196 100644 --- a/modules/gitgraph/graph_models.go +++ b/modules/gitgraph/graph_models.go @@ -238,7 +238,7 @@ func newRefsFromRefNames(refNames []byte) []git.Reference { type Commit struct { Commit *git.Commit User *user_model.User - Verification *asymkey_model.CommitVerification + Verification *asymkey_model.ObjectVerification Status *git_model.CommitStatus Flow int64 Row int diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index 38bb1305f..54e9aed20 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -11,6 +11,7 @@ import ( "strings" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" @@ -18,6 +19,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" @@ -192,6 +194,7 @@ func Releases(ctx *context.Context) { } ctx.Data["Releases"] = releases + addVerifyTagToContext(ctx) numReleases := ctx.Data["NumReleases"].(int64) pager := context.NewPagination(int(numReleases), listOptions.PageSize, listOptions.Page, 5) @@ -201,6 +204,44 @@ func Releases(ctx *context.Context) { ctx.HTML(http.StatusOK, tplReleasesList) } +func verifyTagSignature(ctx *context.Context, r *repo_model.Release) (*asymkey.ObjectVerification, error) { + if err := r.LoadAttributes(ctx); err != nil { + return nil, err + } + gitRepo, err := gitrepo.OpenRepository(ctx, r.Repo) + if err != nil { + return nil, err + } + defer gitRepo.Close() + + tag, err := gitRepo.GetTag(r.TagName) + if err != nil { + return nil, err + } + if tag.Signature == nil { + return nil, nil + } + + verification := asymkey.ParseTagWithSignature(ctx, gitRepo, tag) + return verification, nil +} + +func addVerifyTagToContext(ctx *context.Context) { + ctx.Data["VerifyTag"] = func(r *repo_model.Release) *asymkey.ObjectVerification { + v, err := verifyTagSignature(ctx, r) + if err != nil { + return nil + } + return v + } + ctx.Data["HasSignature"] = func(verification *asymkey.ObjectVerification) bool { + if verification == nil { + return false + } + return verification.Reason != "gpg.error.not_signed_commit" + } +} + // TagsList render tags list page func TagsList(ctx *context.Context) { ctx.Data["PageIsTagList"] = true @@ -240,6 +281,7 @@ func TagsList(ctx *context.Context) { } ctx.Data["Releases"] = releases + addVerifyTagToContext(ctx) numTags := ctx.Data["NumTags"].(int64) pager := context.NewPagination(int(numTags), opts.PageSize, opts.Page, 5) @@ -304,6 +346,7 @@ func SingleRelease(ctx *context.Context) { if release.IsTag && release.Title == "" { release.Title = release.TagName } + addVerifyTagToContext(ctx) ctx.Data["PageIsSingleTag"] = release.IsTag if release.IsTag { diff --git a/templates/repo/release/list.tmpl b/templates/repo/release/list.tmpl index 5aa4e51f3..f3b4bc844 100644 --- a/templates/repo/release/list.tmpl +++ b/templates/repo/release/list.tmpl @@ -60,6 +60,7 @@
{{$release.RenderedNote}}
+ {{template "repo/tag/verification_line" (dict "ctxData" $ "release" $release)}}
diff --git a/templates/repo/tag/list.tmpl b/templates/repo/tag/list.tmpl index 5378a8a32..82f3dc04a 100644 --- a/templates/repo/tag/list.tmpl +++ b/templates/repo/tag/list.tmpl @@ -16,12 +16,13 @@ {{range $idx, $release := .Releases}} -

+

{{if $canReadReleases}} {{.TagName}} {{else}} {{.TagName}} {{end}} + {{template "repo/tag/verification_box" (dict "ctxData" $ "release" $release)}}

{{if $.Permission.CanRead $.UnitTypeCode}} diff --git a/templates/repo/tag/verification_box.tmpl b/templates/repo/tag/verification_box.tmpl new file mode 100644 index 000000000..3cf88ac23 --- /dev/null +++ b/templates/repo/tag/verification_box.tmpl @@ -0,0 +1,27 @@ +{{$v := call .ctxData.VerifyTag .release}} +{{if call .ctxData.HasSignature $v}} + {{$class := "isSigned"}} + {{$href := ""}} + {{if $v.Verified}} + {{$href = $v.SigningUser.HomeLink}} + {{$class = (print $class " isVerified")}} + {{else}} + {{$class = (print $class " isWarning")}} + {{end}} + + + {{if $v.Verified}} +
+ {{if ne $v.SigningUser.ID 0}} + {{svg "gitea-lock"}} + {{ctx.AvatarUtils.Avatar $v.SigningUser 28 "signature"}} + {{else}} + {{svg "gitea-lock-cog"}} + {{ctx.AvatarUtils.AvatarByEmail $v.Verification.SigningEmail "" 28 "signature"}} + {{end}} +
+ {{else}} + {{svg "gitea-unlock"}} + {{end}} +
+{{end}} diff --git a/templates/repo/tag/verification_line.tmpl b/templates/repo/tag/verification_line.tmpl new file mode 100644 index 000000000..f83719de2 --- /dev/null +++ b/templates/repo/tag/verification_line.tmpl @@ -0,0 +1,80 @@ +{{$v := call .ctxData.VerifyTag .release}} +{{if call .ctxData.HasSignature $v}} + {{$class := "isSigned"}} + {{$href := ""}} + {{if $v.Verified}} + {{$href = $v.SigningUser.HomeLink}} + {{$class = (print $class " isVerified")}} + {{else}} + {{$class = (print $class " isWarning")}} + {{end}} + +
+
+ {{if $v.Verified}} + {{if ne $v.SigningUser.ID 0}} + {{svg "gitea-lock" 16 "tw-mr-2"}} + {{ctx.Locale.Tr "repo.commits.signed_by"}} + {{ctx.AvatarUtils.Avatar $v.SigningUser 28 "tw-mr-2"}} + {{$v.SigningUser.GetDisplayName}} + {{else}} + {{svg "gitea-lock-cog" 16 "tw-mr-2"}} + {{ctx.Locale.Tr "repo.commits.signed_by"}}: + {{ctx.AvatarUtils.AvatarByEmail $v.SigningEmail "" 28 "tw-mr-2"}} + {{$v.SigningUser.GetDisplayName}} + {{end}} + {{else}} + {{svg "gitea-unlock" 16 "tw-mr-2"}} + {{ctx.Locale.Tr $v.Reason}} + {{end}} +
+ +
+ {{if $v.Verified}} + {{if ne $v.SigningUser.ID 0}} + {{svg "octicon-verified" 16 "tw-mr-2"}} + {{if $v.SigningSSHKey}} + {{ctx.Locale.Tr "repo.commits.ssh_key_fingerprint"}}: + {{$v.SigningSSHKey.Fingerprint}} + {{else}} + {{ctx.Locale.Tr "repo.commits.gpg_key_id"}}: + {{$v.SigningKey.PaddedKeyID}} + {{end}} + {{else}} + {{svg "octicon-unverified" 16 "tw-mr-2"}} + {{if $v.SigningSSHKey}} + {{ctx.Locale.Tr "repo.commits.ssh_key_fingerprint"}}: + {{$v.SigningSSHKey.Fingerprint}} + {{else}} + {{ctx.Locale.Tr "repo.commits.gpg_key_id"}}: + {{$v.SigningKey.PaddedKeyID}} + {{end}} + {{end}} + {{else if $v.Warning}} + {{svg "octicon-unverified" 16 "tw-mr-2"}} + {{if $v.SigningSSHKey}} + {{ctx.Locale.Tr "repo.commits.ssh_key_fingerprint"}}: + {{$v.SigningSSHKey.Fingerprint}} + {{else}} + {{ctx.Locale.Tr "repo.commits.gpg_key_id"}}: + {{$v.SigningKey.PaddedKeyID}} + {{end}} + {{else}} + {{if $v.SigningKey}} + {{if ne $v.SigningKey.KeyID ""}} + {{svg "octicon-verified" 16 "tw-mr-2"}} + {{ctx.Locale.Tr "repo.commits.gpg_key_id"}}: + {{$v.SigningKey.PaddedKeyID}} + {{end}} + {{end}} + {{if $v.SigningSSHKey}} + {{if ne $v.SigningSSHKey.Fingerprint ""}} + {{svg "octicon-verified" 16 "tw-mr-2"}} + {{ctx.Locale.Tr "repo.commits.ssh_key_fingerprint"}}: + {{$v.SigningSSHKey.Fingerprint}} + {{end}} + {{end}} + {{end}} +
+
+{{end}} diff --git a/tests/integration/repo_signed_tag_test.go b/tests/integration/repo_signed_tag_test.go new file mode 100644 index 000000000..a2904dc07 --- /dev/null +++ b/tests/integration/repo_signed_tag_test.go @@ -0,0 +1,107 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "os" + "os/exec" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "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/gitrepo" + "code.gitea.io/gitea/modules/graceful" + repo_module "code.gitea.io/gitea/modules/repository" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestRepoSSHSignedTags(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // Preparations + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo, _, f := CreateDeclarativeRepo(t, user, "", nil, nil, nil) + defer f() + + // Set up an SSH key for the tagger + tmpDir := t.TempDir() + err := os.Chmod(tmpDir, 0o700) + assert.NoError(t, err) + + signingKey := fmt.Sprintf("%s/ssh_key", tmpDir) + + cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-f", signingKey) + err = cmd.Run() + assert.NoError(t, err) + + // Set up git config for the tagger + _ = git.NewCommand(git.DefaultContext, "config", "user.name").AddDynamicArguments(user.Name).Run(&git.RunOpts{Dir: repo.RepoPath()}) + _ = git.NewCommand(git.DefaultContext, "config", "user.email").AddDynamicArguments(user.Email).Run(&git.RunOpts{Dir: repo.RepoPath()}) + _ = git.NewCommand(git.DefaultContext, "config", "gpg.format", "ssh").Run(&git.RunOpts{Dir: repo.RepoPath()}) + _ = git.NewCommand(git.DefaultContext, "config", "user.signingkey").AddDynamicArguments(signingKey).Run(&git.RunOpts{Dir: repo.RepoPath()}) + + // Open the git repo + gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo) + defer gitRepo.Close() + + // Create a signed tag + err = git.NewCommand(git.DefaultContext, "tag", "-s", "-m", "this is a signed tag", "ssh-signed-tag").Run(&git.RunOpts{Dir: repo.RepoPath()}) + assert.NoError(t, err) + + // Sync the tag to the DB + repo_module.SyncRepoTags(graceful.GetManager().ShutdownContext(), repo.ID) + + // Helper functions + assertTagSignedStatus := func(t *testing.T, isSigned bool) { + t.Helper() + + req := NewRequestf(t, "GET", "%s/releases/tag/ssh-signed-tag", repo.HTMLURL()) + resp := MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + doc.AssertElement(t, ".tag-signature-row .gitea-unlock", !isSigned) + doc.AssertElement(t, ".tag-signature-row .gitea-lock", isSigned) + } + + t.Run("unverified", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertTagSignedStatus(t, false) + }) + + t.Run("verified", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Upload the signing key + keyData, err := os.ReadFile(fmt.Sprintf("%s.pub", signingKey)) + assert.NoError(t, err) + key := string(keyData) + + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + req := NewRequestWithJSON(t, "POST", "/api/v1/user/keys", &api.CreateKeyOption{ + Key: key, + Title: "test key", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var pubkey *api.PublicKey + DecodeJSON(t, resp, &pubkey) + + // Mark the key as verified + db.GetEngine(db.DefaultContext).Exec("UPDATE `public_key` SET verified = true WHERE id = ?", pubkey.ID) + + // Check the tag page + assertTagSignedStatus(t, true) + }) +} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 3d867fcf1..225755305 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -1878,6 +1878,31 @@ border-bottom: 1px solid var(--color-warning-border); } +.repository .release-tag-name .ui.label.isSigned, +.repository .release-list-title .ui.label.isSigned { + padding: 0 0.5em; + box-shadow: none; +} + +.repository .release-tag-name .ui.label.isSigned .avatar, +.repository .release-list-title .ui.label.isSigned .avatar { + margin-left: .5rem; +} + +.repository .release-tag-name .ui.label.isSigned.isVerified, +.repository .release-list-title .ui.label.isSigned.isVerified { + border: 1px solid var(--color-success-border); + background-color: var(--color-success-bg); + color: var(--color-success-text); +} + +.repository .release-tag-name .ui.label.isSigned.isWarning, +.repository .release-list-title .ui.label.isSigned.isWarning { + border: 1px solid var(--color-warning-border); + background-color: var(--color-warning-bg); + color: var(--color-warning-text); +} + .repository .segment.reactions.dropdown .menu, .repository .select-reaction.dropdown .menu { right: 0 !important; @@ -2111,12 +2136,19 @@ padding-top: 15px; } -.commit-header-row { +.commit-header-row, +.tag-signature-row { min-height: 50px !important; padding-top: 0 !important; padding-bottom: 0 !important; } +.tag-signature-row div { + margin-top: auto !important; + margin-bottom: auto !important; + display: inline-block !important; +} + .settings.webhooks .list > .item:not(:first-child), .settings.githooks .list > .item:not(:first-child), .settings.actions .list > .item:not(:first-child) {