mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
379 lines
11 KiB
Go
379 lines
11 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package asymkey
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
asymkey_model "github.com/hanzoai/git/models/asymkey"
|
|
"github.com/hanzoai/git/models/auth"
|
|
"github.com/hanzoai/git/models/db"
|
|
git_model "github.com/hanzoai/git/models/git"
|
|
issues_model "github.com/hanzoai/git/models/issues"
|
|
repo_model "github.com/hanzoai/git/models/repo"
|
|
user_model "github.com/hanzoai/git/models/user"
|
|
"github.com/hanzoai/git/modules/git"
|
|
"github.com/hanzoai/git/modules/gitrepo"
|
|
"github.com/hanzoai/git/modules/log"
|
|
"github.com/hanzoai/git/modules/process"
|
|
"github.com/hanzoai/git/modules/setting"
|
|
)
|
|
|
|
type signingMode string
|
|
|
|
const (
|
|
never signingMode = "never"
|
|
always signingMode = "always"
|
|
pubkey signingMode = "pubkey"
|
|
twofa signingMode = "twofa"
|
|
parentSigned signingMode = "parentsigned"
|
|
baseSigned signingMode = "basesigned"
|
|
headSigned signingMode = "headsigned"
|
|
commitsSigned signingMode = "commitssigned"
|
|
approved signingMode = "approved"
|
|
noKey signingMode = "nokey"
|
|
)
|
|
|
|
func signingModeFromStrings(modeStrings []string) []signingMode {
|
|
returnable := make([]signingMode, 0, len(modeStrings))
|
|
for _, mode := range modeStrings {
|
|
signMode := signingMode(strings.ToLower(strings.TrimSpace(mode)))
|
|
switch signMode {
|
|
case never:
|
|
return []signingMode{never}
|
|
case always:
|
|
return []signingMode{always}
|
|
case pubkey:
|
|
fallthrough
|
|
case twofa:
|
|
fallthrough
|
|
case parentSigned:
|
|
fallthrough
|
|
case baseSigned:
|
|
fallthrough
|
|
case headSigned:
|
|
fallthrough
|
|
case approved:
|
|
fallthrough
|
|
case commitsSigned:
|
|
returnable = append(returnable, signMode)
|
|
}
|
|
}
|
|
if len(returnable) == 0 {
|
|
return []signingMode{never}
|
|
}
|
|
return returnable
|
|
}
|
|
|
|
func userHasPubkeysGPG(ctx context.Context, userID int64) (bool, error) {
|
|
return db.Exist[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{
|
|
OwnerID: userID,
|
|
IncludeSubKeys: true,
|
|
}.ToConds())
|
|
}
|
|
|
|
func userHasPubkeysSSH(ctx context.Context, userID int64) (bool, error) {
|
|
return db.Exist[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{
|
|
OwnerID: userID,
|
|
NotKeytype: asymkey_model.KeyTypePrincipal,
|
|
}.ToConds())
|
|
}
|
|
|
|
// userHasPubkeys checks if a user has any public keys (GPG or SSH)
|
|
func userHasPubkeys(ctx context.Context, userID int64) (bool, error) {
|
|
has, err := userHasPubkeysGPG(ctx, userID)
|
|
if has || err != nil {
|
|
return has, err
|
|
}
|
|
return userHasPubkeysSSH(ctx, userID)
|
|
}
|
|
|
|
// ErrWontSign explains the first reason why a commit would not be signed
|
|
// There may be other reasons - this is just the first reason found
|
|
type ErrWontSign struct {
|
|
Reason signingMode
|
|
}
|
|
|
|
func (e *ErrWontSign) Error() string {
|
|
return fmt.Sprintf("wont sign: %s", e.Reason)
|
|
}
|
|
|
|
// IsErrWontSign checks if an error is a ErrWontSign
|
|
func IsErrWontSign(err error) bool {
|
|
_, ok := err.(*ErrWontSign)
|
|
return ok
|
|
}
|
|
|
|
// PublicSigningKey gets the public signing key of the entire instance
|
|
func PublicSigningKey(ctx context.Context) (content, format string, err error) {
|
|
signingKey, _ := git.GetSigningKey(ctx)
|
|
if signingKey == nil {
|
|
return "", "", nil
|
|
}
|
|
if signingKey.Format == git.SigningKeyFormatSSH {
|
|
content, err := os.ReadFile(signingKey.KeyID)
|
|
if err != nil {
|
|
log.Error("Unable to read SSH public key file: %s, %v", signingKey, err)
|
|
return "", signingKey.Format, err
|
|
}
|
|
return string(content), signingKey.Format, nil
|
|
}
|
|
|
|
content, stderr, err := process.GetManager().ExecDir(ctx, -1, setting.Git.HomePath,
|
|
"gpg --export -a", "gpg", "--export", "-a", signingKey.KeyID)
|
|
if err != nil {
|
|
log.Error("Unable to get default signing key: %s, %s, %v", signingKey, stderr, err)
|
|
return "", signingKey.Format, err
|
|
}
|
|
return content, signingKey.Format, nil
|
|
}
|
|
|
|
// SignInitialCommit determines if we should sign the initial commit to this repository
|
|
func SignInitialCommit(ctx context.Context, u *user_model.User) (bool, *git.SigningKey, *git.Signature, error) {
|
|
rules := signingModeFromStrings(setting.Repository.Signing.InitialCommit)
|
|
signingKey, sig := git.GetSigningKey(ctx)
|
|
if signingKey == nil {
|
|
return false, nil, nil, &ErrWontSign{noKey}
|
|
}
|
|
|
|
Loop:
|
|
for _, rule := range rules {
|
|
switch rule {
|
|
case never:
|
|
return false, nil, nil, &ErrWontSign{never}
|
|
case always:
|
|
break Loop
|
|
case pubkey:
|
|
hasKeys, err := userHasPubkeys(ctx, u.ID)
|
|
if err != nil {
|
|
return false, nil, nil, err
|
|
}
|
|
if !hasKeys {
|
|
return false, nil, nil, &ErrWontSign{pubkey}
|
|
}
|
|
case twofa:
|
|
twofaModel, err := auth.GetTwoFactorByUID(ctx, u.ID)
|
|
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
|
|
return false, nil, nil, err
|
|
}
|
|
if twofaModel == nil {
|
|
return false, nil, nil, &ErrWontSign{twofa}
|
|
}
|
|
}
|
|
}
|
|
return true, signingKey, sig, nil
|
|
}
|
|
|
|
// SignWikiCommit determines if we should sign the commits to this repository wiki
|
|
func SignWikiCommit(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, u *user_model.User) (bool, *git.SigningKey, *git.Signature, error) {
|
|
rules := signingModeFromStrings(setting.Repository.Signing.Wiki)
|
|
signingKey, sig := gitrepo.GetSigningKey(ctx)
|
|
if signingKey == nil {
|
|
return false, nil, nil, &ErrWontSign{noKey}
|
|
}
|
|
|
|
Loop:
|
|
for _, rule := range rules {
|
|
switch rule {
|
|
case never:
|
|
return false, nil, nil, &ErrWontSign{never}
|
|
case always:
|
|
break Loop
|
|
case pubkey:
|
|
hasKeys, err := userHasPubkeys(ctx, u.ID)
|
|
if err != nil {
|
|
return false, nil, nil, err
|
|
}
|
|
if !hasKeys {
|
|
return false, nil, nil, &ErrWontSign{pubkey}
|
|
}
|
|
case twofa:
|
|
twofaModel, err := auth.GetTwoFactorByUID(ctx, u.ID)
|
|
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
|
|
return false, nil, nil, err
|
|
}
|
|
if twofaModel == nil {
|
|
return false, nil, nil, &ErrWontSign{twofa}
|
|
}
|
|
case parentSigned:
|
|
commit, err := gitRepo.GetCommit("HEAD")
|
|
if err != nil {
|
|
return false, nil, nil, err
|
|
}
|
|
if commit.Signature == nil {
|
|
return false, nil, nil, &ErrWontSign{parentSigned}
|
|
}
|
|
verification := ParseCommitWithSignature(ctx, commit)
|
|
if !verification.Verified {
|
|
return false, nil, nil, &ErrWontSign{parentSigned}
|
|
}
|
|
}
|
|
}
|
|
return true, signingKey, sig, nil
|
|
}
|
|
|
|
// SignCRUDAction determines if we should sign a CRUD commit to this repository
|
|
func SignCRUDAction(ctx context.Context, u *user_model.User, gitRepo *git.Repository, parentCommit string) (bool, *git.SigningKey, *git.Signature, error) {
|
|
rules := signingModeFromStrings(setting.Repository.Signing.CRUDActions)
|
|
signingKey, sig := git.GetSigningKey(ctx)
|
|
if signingKey == nil {
|
|
return false, nil, nil, &ErrWontSign{noKey}
|
|
}
|
|
|
|
Loop:
|
|
for _, rule := range rules {
|
|
switch rule {
|
|
case never:
|
|
return false, nil, nil, &ErrWontSign{never}
|
|
case always:
|
|
break Loop
|
|
case pubkey:
|
|
hasKeys, err := userHasPubkeys(ctx, u.ID)
|
|
if err != nil {
|
|
return false, nil, nil, err
|
|
}
|
|
if !hasKeys {
|
|
return false, nil, nil, &ErrWontSign{pubkey}
|
|
}
|
|
case twofa:
|
|
twofaModel, err := auth.GetTwoFactorByUID(ctx, u.ID)
|
|
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
|
|
return false, nil, nil, err
|
|
}
|
|
if twofaModel == nil {
|
|
return false, nil, nil, &ErrWontSign{twofa}
|
|
}
|
|
case parentSigned:
|
|
isEmpty, err := gitRepo.IsEmpty()
|
|
if err != nil {
|
|
return false, nil, nil, err
|
|
}
|
|
if !isEmpty {
|
|
commit, err := gitRepo.GetCommit(parentCommit)
|
|
if err != nil {
|
|
return false, nil, nil, err
|
|
}
|
|
if commit.Signature == nil {
|
|
return false, nil, nil, &ErrWontSign{parentSigned}
|
|
}
|
|
verification := ParseCommitWithSignature(ctx, commit)
|
|
if !verification.Verified {
|
|
return false, nil, nil, &ErrWontSign{parentSigned}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return true, signingKey, sig, nil
|
|
}
|
|
|
|
// SignMerge determines if we should sign a PR merge commit to the base repository
|
|
func SignMerge(ctx context.Context, pr *issues_model.PullRequest, u *user_model.User, gitRepo *git.Repository) (bool, *git.SigningKey, *git.Signature, error) {
|
|
if err := pr.LoadBaseRepo(ctx); err != nil {
|
|
log.Error("Unable to get Base Repo for pull request")
|
|
return false, nil, nil, err
|
|
}
|
|
repo := pr.BaseRepo
|
|
|
|
baseCommit, err := gitRepo.GetCommit(pr.BaseBranch)
|
|
if err != nil {
|
|
return false, nil, nil, err
|
|
}
|
|
headCommit, err := gitRepo.GetCommit(pr.GetGitHeadRefName())
|
|
if err != nil {
|
|
return false, nil, nil, err
|
|
}
|
|
|
|
signingKey, signer := gitrepo.GetSigningKey(ctx)
|
|
if signingKey == nil {
|
|
return false, nil, nil, &ErrWontSign{noKey}
|
|
}
|
|
rules := signingModeFromStrings(setting.Repository.Signing.Merges)
|
|
|
|
Loop:
|
|
for _, rule := range rules {
|
|
switch rule {
|
|
case never:
|
|
return false, nil, nil, &ErrWontSign{never}
|
|
case always:
|
|
break Loop
|
|
case pubkey:
|
|
hasKeys, err := userHasPubkeys(ctx, u.ID)
|
|
if err != nil {
|
|
return false, nil, nil, err
|
|
}
|
|
if !hasKeys {
|
|
return false, nil, nil, &ErrWontSign{pubkey}
|
|
}
|
|
case twofa:
|
|
twofaModel, err := auth.GetTwoFactorByUID(ctx, u.ID)
|
|
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
|
|
return false, nil, nil, err
|
|
}
|
|
if twofaModel == nil {
|
|
return false, nil, nil, &ErrWontSign{twofa}
|
|
}
|
|
case approved:
|
|
protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, pr.BaseBranch)
|
|
if err != nil {
|
|
return false, nil, nil, err
|
|
}
|
|
if protectedBranch == nil {
|
|
return false, nil, nil, &ErrWontSign{approved}
|
|
}
|
|
if issues_model.GetGrantedApprovalsCount(ctx, protectedBranch, pr) < 1 {
|
|
return false, nil, nil, &ErrWontSign{approved}
|
|
}
|
|
case baseSigned:
|
|
verification := ParseCommitWithSignature(ctx, baseCommit)
|
|
if !verification.Verified {
|
|
return false, nil, nil, &ErrWontSign{baseSigned}
|
|
}
|
|
case headSigned:
|
|
verification := ParseCommitWithSignature(ctx, headCommit)
|
|
if !verification.Verified {
|
|
return false, nil, nil, &ErrWontSign{headSigned}
|
|
}
|
|
case commitsSigned:
|
|
verified, err := AllHeadCommitsVerified(ctx, pr, gitRepo)
|
|
if err != nil {
|
|
return false, nil, nil, err
|
|
}
|
|
if !verified {
|
|
return false, nil, nil, &ErrWontSign{commitsSigned}
|
|
}
|
|
}
|
|
}
|
|
return true, signingKey, signer, nil
|
|
}
|
|
|
|
// AllHeadCommitsVerified checks that every new commit in the PR head has a
|
|
// verified signature.
|
|
func AllHeadCommitsVerified(ctx context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository) (bool, error) {
|
|
baseCommit, err := gitRepo.GetCommit(pr.BaseBranch)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
headCommit, err := gitRepo.GetCommit(pr.GetGitHeadRefName())
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
mergeBaseCommit, err := gitrepo.MergeBase(ctx, pr.BaseRepo, baseCommit.ID.String(), headCommit.ID.String())
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
commitList, err := headCommit.CommitsBeforeUntil(gitRepo, git.RefNameFromCommit(mergeBaseCommit))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, commit := range commitList {
|
|
if !ParseCommitWithSignature(ctx, commit).Verified {
|
|
return false, nil
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|