From fcb535c5c3b6b782d9242028fed4cd8c027c4e41 Mon Sep 17 00:00:00 2001 From: zeripath Date: Wed, 16 Oct 2019 14:42:42 +0100 Subject: [PATCH] Sign merges, CRUD, Wiki and Repository initialisation with gpg key (#7631) This PR fixes #7598 by providing a configurable way of signing commits across the Gitea instance. Per repository configurability and import/generation of trusted secure keys is not provided by this PR - from a security PoV that's probably impossible to do properly. Similarly web-signing, that is asking the user to sign something, is not implemented - this could be done at a later stage however. ## Features - [x] If commit.gpgsign is set in .gitconfig sign commits and files created through repofiles. (merges should already have been signed.) - [x] Verify commits signed with the default gpg as valid - [x] Signer, Committer and Author can all be different - [x] Allow signer to be arbitrarily different - We still require the key to have an activated email on Gitea. A more complete implementation would be to use a keyserver and mark external-or-unactivated with an "unknown" trust level icon. - [x] Add a signing-key.gpg endpoint to get the default gpg pub key if available - Rather than add a fake web-flow user I've added this as an endpoint on /api/v1/signing-key.gpg - [x] Try to match the default key with a user on gitea - this is done at verification time - [x] Make things configurable? - app.ini configuration done - [x] when checking commits are signed need to check if they're actually verifiable too - [x] Add documentation I have decided that adjusting the docker to create a default gpg key is not the correct thing to do and therefore have not implemented this. --- Makefile | 4 + custom/conf/app.ini.sample | 31 ++ .../doc/advanced/config-cheat-sheet.en-us.md | 19 + docs/content/doc/advanced/signing.en-us.md | 162 ++++++++ .../api_helper_for_declarative_test.go | 35 ++ integrations/api_repo_file_create_test.go | 2 +- integrations/api_repo_file_update_test.go | 2 +- integrations/gpg_git_test.go | 252 ++++++++++++ integrations/mssql.ini.tmpl | 3 + integrations/mysql.ini.tmpl | 3 + integrations/mysql8.ini.tmpl | 3 + integrations/pgsql.ini.tmpl | 3 + integrations/repofiles_delete_test.go | 2 +- integrations/repofiles_update_test.go | 4 +- integrations/sqlite.ini | 3 + models/gpg_key.go | 359 ++++++++++++++---- models/repo.go | 60 ++- models/repo_sign.go | 303 +++++++++++++++ models/wiki.go | 21 +- modules/git/commit.go | 8 + modules/git/repo.go | 10 + modules/git/repo_gpg.go | 59 +++ modules/git/repo_tree.go | 11 +- modules/git/utils.go | 28 ++ modules/repofiles/file_test.go | 2 +- modules/repofiles/temp_repo.go | 13 +- modules/repofiles/verification.go | 14 +- modules/setting/repository.go | 29 ++ modules/structs/hook.go | 9 +- options/locale/locale_en-US.ini | 3 + public/css/index.css | 6 + public/less/_base.less | 16 + public/less/_repository.less | 9 + routers/api/v1/api.go | 2 + routers/api/v1/convert/convert.go | 21 +- routers/api/v1/misc/signing.go | 62 +++ services/pull/merge.go | 64 +++- templates/repo/commit_page.tmpl | 39 +- templates/repo/commits_table.tmpl | 13 +- templates/swagger/v1_json.tmpl | 59 +++ 40 files changed, 1627 insertions(+), 121 deletions(-) create mode 100644 docs/content/doc/advanced/signing.en-us.md create mode 100644 integrations/gpg_git_test.go create mode 100644 models/repo_sign.go create mode 100644 modules/git/repo_gpg.go create mode 100644 routers/api/v1/misc/signing.go diff --git a/Makefile b/Makefile index 953abe83b0c..ebcfadb21db 100644 --- a/Makefile +++ b/Makefile @@ -168,6 +168,10 @@ fmt-check: test: GO111MODULE=on $(GO) test -mod=vendor -tags='sqlite sqlite_unlock_notify' $(PACKAGES) +.PHONY: test\#% +test\#%: + GO111MODULE=on $(GO) test -mod=vendor -tags='sqlite sqlite_unlock_notify' -run $* $(PACKAGES) + .PHONY: coverage coverage: @hash gocovmerge > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ diff --git a/custom/conf/app.ini.sample b/custom/conf/app.ini.sample index aa526804f27..e4e791d4a7e 100644 --- a/custom/conf/app.ini.sample +++ b/custom/conf/app.ini.sample @@ -74,6 +74,37 @@ WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP] ; List of reasons why a Pull Request or Issue can be locked LOCK_REASONS=Too heated,Off-topic,Resolved,Spam +[repository.signing] +; GPG key to use to sign commits, Defaults to the default - that is the value of git config --get user.signingkey +; run in the context of the RUN_USER +; Switch to none to stop signing completely +SIGNING_KEY = default +; If a SIGNING_KEY ID is provided and is not set to default, use the provided Name and Email address as the signer. +; These should match a publicized name and email address for the key. (When SIGNING_KEY is default these are set to +; the results of git config --get user.name and git config --get user.email respectively and can only be overrided +; by setting the SIGNING_KEY ID to the correct ID.) +SIGNING_NAME = +SIGNING_EMAIL = +; Determines when gitea should sign the initial commit when creating a repository +; Either: +; - never +; - pubkey: only sign if the user has a pubkey +; - twofa: only sign if the user has logged in with twofa +; - always +; options other than none and always can be combined as comma separated list +INITIAL_COMMIT = always +; Determines when to sign for CRUD actions +; - as above +; - parentsigned: requires that the parent commit is signed. +CRUD_ACTIONS = pubkey, twofa, parentsigned +; Determines when to sign Wiki commits +; - as above +WIKI = never +; Determines when to sign on merges +; - basesigned: require that the parent of commit on the base repo is signed. +; - commitssigned: require that all the commits in the head branch are signed. +MERGES = pubkey, twofa, basesigned, commitssigned + [cors] ; More information about CORS can be found here: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#The_HTTP_response_headers ; enable cors headers (disabled by default) diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md index 959607dd11e..0df88c23e86 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -76,6 +76,25 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`. - `LOCK_REASONS`: **Too heated,Off-topic,Resolved,Spam**: A list of reasons why a Pull Request or Issue can be locked +### Repository - Signing (`repository.signing`) + +- `SIGNING_KEY`: **default**: \[none, KEYID, default \]: Key to sign with. +- `SIGNING_NAME` & `SIGNING_EMAIL`: if a KEYID is provided as the `SIGNING_KEY`, use these as the Name and Email address of the signer. These should match publicized name and email address for the key. +- `INITIAL_COMMIT`: **always**: \[never, pubkey, twofa, always\]: Sign initial commit. + - `never`: Never sign + - `pubkey`: Only sign if the user has a public key + - `twofa`: Only sign if the user is logged in with twofa + - `always`: Always sign + - Options other than `never` and `always` can be combined as a comma separated list. +- `WIKI`: **never**: \[never, pubkey, twofa, always, parentsigned\]: Sign commits to wiki. +- `CRUD_ACTIONS`: **pubkey, twofa, parentsigned**: \[never, pubkey, twofa, parentsigned, always\]: Sign CRUD actions. + - Options as above, with the addition of: + - `parentsigned`: Only sign if the parent commit is signed. +- `MERGES`: **pubkey, twofa, basesigned, commitssigned**: \[never, pubkey, twofa, basesigned, commitssigned, always\]: Sign merges. + - `basesigned`: Only sign if the parent commit in the base repo is signed. + - `headsigned`: Only sign if the head commit in the head branch is signed. + - `commitssigned`: Only sign if all the commits in the head branch to the merge point are signed. + ## CORS (`cors`) - `ENABLED`: **false**: enable cors headers (disabled by default) diff --git a/docs/content/doc/advanced/signing.en-us.md b/docs/content/doc/advanced/signing.en-us.md new file mode 100644 index 00000000000..b6c99e269e0 --- /dev/null +++ b/docs/content/doc/advanced/signing.en-us.md @@ -0,0 +1,162 @@ +--- +date: "2019-08-17T10:20:00+01:00" +title: "GPG Commit Signatures" +slug: "signing" +weight: 20 +toc: false +draft: false +menu: + sidebar: + parent: "advanced" + name: "GPG Commit Signatures" + weight: 20 + identifier: "signing" +--- + +# GPG Commit Signatures + +Gitea will verify GPG commit signatures in the provided tree by +checking if the commits are signed by a key within the gitea database, +or if the commit matches the default key for git. + +Keys are not checked to determine if they have expired or revoked. +Keys are also not checked with keyservers. + +A commit will be marked with a grey unlocked icon if no key can be +found to verify it. If a commit is marked with a red unlocked icon, +it is reported to be signed with a key with an id. + +Please note: The signer of a commit does not have to be an author or +committer of a commit. + +This functionality requires git >= 1.7.9 but for full functionality +this requires git >= 2.0.0. + +## Automatic Signing + +There are a number of places where Gitea will generate commits itself: + +* Repository Initialisation +* Wiki Changes +* CRUD actions using the editor or the API +* Merges from Pull Requests + +Depending on configuration and server trust you may want Gitea to +sign these commits. + +## General Configuration + +Gitea's configuration for signing can be found with the +`[repository.signing]` section of `app.ini`: + +```ini +... +[repository.signing] +SIGNING_KEY = default +SIGNING_NAME = +SIGNING_EMAIL = +INITIAL_COMMIT = always +CRUD_ACTIONS = pubkey, twofa, parentsigned +WIKI = never +MERGES = pubkey, twofa, basesigned, commitssigned + +... +``` + +### `SIGNING_KEY` + +The first option to discuss is the `SIGNING_KEY`. There are three main +options: + +* `none` - this prevents Gitea from signing any commits +* `default` - Gitea will default to the key configured within +`git config` +* `KEYID` - Gitea will sign commits with the gpg key with the ID +`KEYID`. In this case you should provide a `SIGNING_NAME` and +`SIGNING_EMAIL` to be displayed for this key. + +The `default` option will interrogate `git config` for +`commit.gpgsign` option - if this is set, then it will use the results +of the `user.signingkey`, `user.name` and `user.email` as appropriate. + +Please note: by adjusting git's `config` file within Gitea's +repositories, `SIGNING_KEY=default` could be used to provide different +signing keys on a per-repository basis. However, this is cleary not an +ideal UI and therefore subject to change. + +### `INITIAL_COMMIT` + +This option determines whether Gitea should sign the initial commit +when creating a repository. The possible values are: + +* `never`: Never sign +* `pubkey`: Only sign if the user has a public key +* `twofa`: Only sign if the user logs in with two factor authentication +* `always`: Always sign + +Options other than `never` and `always` can be combined as a comma +separated list. + +### `WIKI` + +This options determines if Gitea should sign commits to the Wiki. +The possible values are: + +* `never`: Never sign +* `pubkey`: Only sign if the user has a public key +* `twofa`: Only sign if the user logs in with two factor authentication +* `parentsigned`: Only sign if the parent commit is signed. +* `always`: Always sign + +Options other than `never` and `always` can be combined as a comma +separated list. + +### `CRUD_ACTIONS` + +This option determines if Gitea should sign commits from the web +editor or API CRUD actions. The possible values are: + +* `never`: Never sign +* `pubkey`: Only sign if the user has a public key +* `twofa`: Only sign if the user logs in with two factor authentication +* `parentsigned`: Only sign if the parent commit is signed. +* `always`: Always sign + +Options other than `never` and `always` can be combined as a comma +separated list. + +### `MERGES` + +This option determines if Gitea should sign merge commits from PRs. +The possible options are: + +* `never`: Never sign +* `pubkey`: Only sign if the user has a public key +* `twofa`: Only sign if the user logs in with two factor authentication +* `basesigned`: Only sign if the parent commit in the base repo is signed. +* `headsigned`: Only sign if the head commit in the head branch is signed. +* `commitssigned`: Only sign if all the commits in the head branch to the merge point are signed. +* `always`: Always sign + +Options other than `never` and `always` can be combined as a comma +separated list. + +## Installing and generating a GPG key for Gitea + +It is up to a server administrator to determine how best to install +a signing key. Gitea generates all its commits using the server `git` +command at present - and therefore the server `gpg` will be used for +signing (if configured.) Administrators should review best-practices +for gpg - in particular it is probably advisable to only install a +signing secret subkey without the master signing and certifying secret +key. + +## Obtaining the Public Key of the Signing Key + +The public key used to sign Gitea's commits can be obtained from the API at: + +```/api/v1/signing-key.gpg``` + +In cases where there is a repository specific key this can be obtained from: + +```/api/v1/repos/:username/:reponame/signing-key.gpg``` diff --git a/integrations/api_helper_for_declarative_test.go b/integrations/api_helper_for_declarative_test.go index 805a986ae37..cae7691c4b9 100644 --- a/integrations/api_helper_for_declarative_test.go +++ b/integrations/api_helper_for_declarative_test.go @@ -231,3 +231,38 @@ func doAPIMergePullRequest(ctx APITestContext, owner, repo string, index int64) ctx.Session.MakeRequest(t, req, 200) } } + +func doAPIGetBranch(ctx APITestContext, branch string, callback ...func(*testing.T, api.Branch)) func(*testing.T) { + return func(t *testing.T) { + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/branches/%s?token=%s", ctx.Username, ctx.Reponame, branch, ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + resp := ctx.Session.MakeRequest(t, req, http.StatusOK) + + var branch api.Branch + DecodeJSON(t, resp, &branch) + if len(callback) > 0 { + callback[0](t, branch) + } + } +} + +func doAPICreateFile(ctx APITestContext, treepath string, options *api.CreateFileOptions, callback ...func(*testing.T, api.FileResponse)) func(*testing.T) { + return func(t *testing.T) { + url := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", ctx.Username, ctx.Reponame, treepath, ctx.Token) + req := NewRequestWithJSON(t, "POST", url, &options) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + resp := ctx.Session.MakeRequest(t, req, http.StatusCreated) + + var contents api.FileResponse + DecodeJSON(t, resp, &contents) + if len(callback) > 0 { + callback[0](t, contents) + } + } +} diff --git a/integrations/api_repo_file_create_test.go b/integrations/api_repo_file_create_test.go index 42898bf259f..4d76ff00ceb 100644 --- a/integrations/api_repo_file_create_test.go +++ b/integrations/api_repo_file_create_test.go @@ -91,7 +91,7 @@ func getExpectedFileResponseForCreate(commitID, treePath string) *api.FileRespon }, Verification: &api.PayloadCommitVerification{ Verified: false, - Reason: "unsigned", + Reason: "gpg.error.not_signed_commit", Signature: "", Payload: "", }, diff --git a/integrations/api_repo_file_update_test.go b/integrations/api_repo_file_update_test.go index 366eb5e9189..bf695d4344c 100644 --- a/integrations/api_repo_file_update_test.go +++ b/integrations/api_repo_file_update_test.go @@ -94,7 +94,7 @@ func getExpectedFileResponseForUpdate(commitID, treePath string) *api.FileRespon }, Verification: &api.PayloadCommitVerification{ Verified: false, - Reason: "unsigned", + Reason: "gpg.error.not_signed_commit", Signature: "", Payload: "", }, diff --git a/integrations/gpg_git_test.go b/integrations/gpg_git_test.go new file mode 100644 index 00000000000..12f0a138c75 --- /dev/null +++ b/integrations/gpg_git_test.go @@ -0,0 +1,252 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "encoding/base64" + "fmt" + "io/ioutil" + "net/url" + "os" + "path/filepath" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "github.com/stretchr/testify/assert" + "golang.org/x/crypto/openpgp" + "golang.org/x/crypto/openpgp/armor" +) + +func TestGPGGit(t *testing.T) { + onGiteaRun(t, testGPGGit) +} + +func testGPGGit(t *testing.T, u *url.URL) { + username := "user2" + baseAPITestContext := NewAPITestContext(t, username, "repo1") + + u.Path = baseAPITestContext.GitPath() + + // OK Set a new GPG home + tmpDir, err := ioutil.TempDir("", "temp-gpg") + assert.NoError(t, err) + defer os.RemoveAll(tmpDir) + + err = os.Chmod(tmpDir, 0700) + assert.NoError(t, err) + + oldGNUPGHome := os.Getenv("GNUPGHOME") + err = os.Setenv("GNUPGHOME", tmpDir) + assert.NoError(t, err) + defer os.Setenv("GNUPGHOME", oldGNUPGHome) + + // Need to create a root key + rootKeyPair, err := createGPGKey(tmpDir, "gitea", "gitea@fake.local") + assert.NoError(t, err) + + rootKeyID := rootKeyPair.PrimaryKey.KeyIdShortString() + + oldKeyID := setting.Repository.Signing.SigningKey + oldName := setting.Repository.Signing.SigningName + oldEmail := setting.Repository.Signing.SigningEmail + defer func() { + setting.Repository.Signing.SigningKey = oldKeyID + setting.Repository.Signing.SigningName = oldName + setting.Repository.Signing.SigningEmail = oldEmail + }() + + setting.Repository.Signing.SigningKey = rootKeyID + setting.Repository.Signing.SigningName = "gitea" + setting.Repository.Signing.SigningEmail = "gitea@fake.local" + user := models.AssertExistsAndLoadBean(t, &models.User{Name: username}).(*models.User) + + t.Run("Unsigned-Initial", func(t *testing.T) { + PrintCurrentTest(t) + setting.Repository.Signing.InitialCommit = []string{"never"} + testCtx := NewAPITestContext(t, username, "initial-unsigned") + t.Run("CreateRepository", doAPICreateRepository(testCtx, false)) + t.Run("CheckMasterBranchUnsigned", doAPIGetBranch(testCtx, "master", func(t *testing.T, branch api.Branch) { + assert.NotNil(t, branch.Commit) + assert.NotNil(t, branch.Commit.Verification) + assert.False(t, branch.Commit.Verification.Verified) + assert.Empty(t, branch.Commit.Verification.Signature) + })) + setting.Repository.Signing.CRUDActions = []string{"never"} + t.Run("CreateCRUDFile-Never", crudActionCreateFile( + t, testCtx, user, "master", "never", "unsigned-never.txt", func(t *testing.T, response api.FileResponse) { + assert.False(t, response.Verification.Verified) + })) + t.Run("CreateCRUDFile-Never", crudActionCreateFile( + t, testCtx, user, "never", "never2", "unsigned-never2.txt", func(t *testing.T, response api.FileResponse) { + assert.False(t, response.Verification.Verified) + })) + setting.Repository.Signing.CRUDActions = []string{"parentsigned"} + t.Run("CreateCRUDFile-ParentSigned", crudActionCreateFile( + t, testCtx, user, "master", "parentsigned", "signed-parent.txt", func(t *testing.T, response api.FileResponse) { + assert.False(t, response.Verification.Verified) + })) + t.Run("CreateCRUDFile-ParentSigned", crudActionCreateFile( + t, testCtx, user, "parentsigned", "parentsigned2", "signed-parent2.txt", func(t *testing.T, response api.FileResponse) { + assert.False(t, response.Verification.Verified) + })) + setting.Repository.Signing.CRUDActions = []string{"never"} + t.Run("CreateCRUDFile-Never", crudActionCreateFile( + t, testCtx, user, "parentsigned", "parentsigned-never", "unsigned-never2.txt", func(t *testing.T, response api.FileResponse) { + assert.False(t, response.Verification.Verified) + })) + setting.Repository.Signing.CRUDActions = []string{"always"} + t.Run("CreateCRUDFile-Always", crudActionCreateFile( + t, testCtx, user, "master", "always", "signed-always.txt", func(t *testing.T, response api.FileResponse) { + assert.True(t, response.Verification.Verified) + assert.Equal(t, "gitea@fake.local", response.Verification.Signer.Email) + })) + t.Run("CreateCRUDFile-ParentSigned-always", crudActionCreateFile( + t, testCtx, user, "parentsigned", "parentsigned-always", "signed-parent2.txt", func(t *testing.T, response api.FileResponse) { + assert.True(t, response.Verification.Verified) + assert.Equal(t, "gitea@fake.local", response.Verification.Signer.Email) + })) + setting.Repository.Signing.CRUDActions = []string{"parentsigned"} + t.Run("CreateCRUDFile-Always-ParentSigned", crudActionCreateFile( + t, testCtx, user, "always", "always-parentsigned", "signed-always-parentsigned.txt", func(t *testing.T, response api.FileResponse) { + assert.True(t, response.Verification.Verified) + assert.Equal(t, "gitea@fake.local", response.Verification.Signer.Email) + })) + }) + t.Run("AlwaysSign-Initial", func(t *testing.T) { + PrintCurrentTest(t) + setting.Repository.Signing.InitialCommit = []string{"always"} + testCtx := NewAPITestContext(t, username, "initial-always") + t.Run("CreateRepository", doAPICreateRepository(testCtx, false)) + t.Run("CheckMasterBranchSigned", doAPIGetBranch(testCtx, "master", func(t *testing.T, branch api.Branch) { + assert.NotNil(t, branch.Commit) + assert.NotNil(t, branch.Commit.Verification) + assert.True(t, branch.Commit.Verification.Verified) + assert.Equal(t, "gitea@fake.local", branch.Commit.Verification.Signer.Email) + })) + setting.Repository.Signing.CRUDActions = []string{"never"} + t.Run("CreateCRUDFile-Never", crudActionCreateFile( + t, testCtx, user, "master", "never", "unsigned-never.txt", func(t *testing.T, response api.FileResponse) { + assert.False(t, response.Verification.Verified) + })) + setting.Repository.Signing.CRUDActions = []string{"parentsigned"} + t.Run("CreateCRUDFile-ParentSigned", crudActionCreateFile( + t, testCtx, user, "master", "parentsigned", "signed-parent.txt", func(t *testing.T, response api.FileResponse) { + assert.True(t, response.Verification.Verified) + assert.Equal(t, "gitea@fake.local", response.Verification.Signer.Email) + })) + setting.Repository.Signing.CRUDActions = []string{"always"} + t.Run("CreateCRUDFile-Always", crudActionCreateFile( + t, testCtx, user, "master", "always", "signed-always.txt", func(t *testing.T, response api.FileResponse) { + assert.True(t, response.Verification.Verified) + assert.Equal(t, "gitea@fake.local", response.Verification.Signer.Email) + })) + + }) + t.Run("UnsignedMerging", func(t *testing.T) { + PrintCurrentTest(t) + testCtx := NewAPITestContext(t, username, "initial-unsigned") + var pr api.PullRequest + var err error + t.Run("CreatePullRequest", func(t *testing.T) { + pr, err = doAPICreatePullRequest(testCtx, testCtx.Username, testCtx.Reponame, "master", "never2")(t) + assert.NoError(t, err) + }) + setting.Repository.Signing.Merges = []string{"commitssigned"} + t.Run("MergePR", doAPIMergePullRequest(testCtx, testCtx.Username, testCtx.Reponame, pr.Index)) + t.Run("CheckMasterBranchUnsigned", doAPIGetBranch(testCtx, "master", func(t *testing.T, branch api.Branch) { + assert.NotNil(t, branch.Commit) + assert.NotNil(t, branch.Commit.Verification) + assert.False(t, branch.Commit.Verification.Verified) + assert.Empty(t, branch.Commit.Verification.Signature) + })) + setting.Repository.Signing.Merges = []string{"basesigned"} + t.Run("CreatePullRequest", func(t *testing.T) { + pr, err = doAPICreatePullRequest(testCtx, testCtx.Username, testCtx.Reponame, "master", "parentsigned2")(t) + assert.NoError(t, err) + }) + t.Run("MergePR", doAPIMergePullRequest(testCtx, testCtx.Username, testCtx.Reponame, pr.Index)) + t.Run("CheckMasterBranchUnsigned", doAPIGetBranch(testCtx, "master", func(t *testing.T, branch api.Branch) { + assert.NotNil(t, branch.Commit) + assert.NotNil(t, branch.Commit.Verification) + assert.False(t, branch.Commit.Verification.Verified) + assert.Empty(t, branch.Commit.Verification.Signature) + })) + setting.Repository.Signing.Merges = []string{"commitssigned"} + t.Run("CreatePullRequest", func(t *testing.T) { + pr, err = doAPICreatePullRequest(testCtx, testCtx.Username, testCtx.Reponame, "master", "always-parentsigned")(t) + assert.NoError(t, err) + }) + t.Run("MergePR", doAPIMergePullRequest(testCtx, testCtx.Username, testCtx.Reponame, pr.Index)) + t.Run("CheckMasterBranchUnsigned", doAPIGetBranch(testCtx, "master", func(t *testing.T, branch api.Branch) { + assert.NotNil(t, branch.Commit) + assert.NotNil(t, branch.Commit.Verification) + assert.True(t, branch.Commit.Verification.Verified) + })) + + }) +} + +func crudActionCreateFile(t *testing.T, ctx APITestContext, user *models.User, from, to, path string, callback ...func(*testing.T, api.FileResponse)) func(*testing.T) { + return doAPICreateFile(ctx, path, &api.CreateFileOptions{ + FileOptions: api.FileOptions{ + BranchName: from, + NewBranchName: to, + Message: fmt.Sprintf("from:%s to:%s path:%s", from, to, path), + Author: api.Identity{ + Name: user.FullName, + Email: user.Email, + }, + Committer: api.Identity{ + Name: user.FullName, + Email: user.Email, + }, + }, + Content: base64.StdEncoding.EncodeToString([]byte("This is new text")), + }, callback...) +} + +func createGPGKey(tmpDir, name, email string) (*openpgp.Entity, error) { + keyPair, err := openpgp.NewEntity(name, "test", email, nil) + if err != nil { + return nil, err + } + + for _, id := range keyPair.Identities { + err := id.SelfSignature.SignUserId(id.UserId.Id, keyPair.PrimaryKey, keyPair.PrivateKey, nil) + if err != nil { + return nil, err + } + } + + keyFile := filepath.Join(tmpDir, "temporary.key") + keyWriter, err := os.Create(keyFile) + if err != nil { + return nil, err + } + defer keyWriter.Close() + defer os.Remove(keyFile) + + w, err := armor.Encode(keyWriter, openpgp.PrivateKeyType, nil) + if err != nil { + return nil, err + } + defer w.Close() + + keyPair.SerializePrivate(w, nil) + if err := w.Close(); err != nil { + return nil, err + } + if err := keyWriter.Close(); err != nil { + return nil, err + } + + if _, _, err := process.GetManager().Exec("gpg --import temporary.key", "gpg", "--import", keyFile); err != nil { + return nil, err + } + return keyPair, nil +} diff --git a/integrations/mssql.ini.tmpl b/integrations/mssql.ini.tmpl index d38d038a4e3..931e923cf48 100644 --- a/integrations/mssql.ini.tmpl +++ b/integrations/mssql.ini.tmpl @@ -21,6 +21,9 @@ ROOT = integrations/gitea-integration-mssql/gitea-repositories LOCAL_COPY_PATH = tmp/local-repo-mssql LOCAL_WIKI_PATH = tmp/local-wiki-mssql +[repository.signing] +SIGNING_KEY = none + [server] SSH_DOMAIN = localhost HTTP_PORT = 3003 diff --git a/integrations/mysql.ini.tmpl b/integrations/mysql.ini.tmpl index 6eed7e1578b..4dde212798d 100644 --- a/integrations/mysql.ini.tmpl +++ b/integrations/mysql.ini.tmpl @@ -21,6 +21,9 @@ ROOT = integrations/gitea-integration-mysql/gitea-repositories LOCAL_COPY_PATH = tmp/local-repo-mysql LOCAL_WIKI_PATH = tmp/local-wiki-mysql +[repository.signing] +SIGNING_KEY = none + [server] SSH_DOMAIN = localhost HTTP_PORT = 3001 diff --git a/integrations/mysql8.ini.tmpl b/integrations/mysql8.ini.tmpl index 1e14bc13561..1b1d3d24367 100644 --- a/integrations/mysql8.ini.tmpl +++ b/integrations/mysql8.ini.tmpl @@ -21,6 +21,9 @@ ROOT = integrations/gitea-integration-mysql8/gitea-repositories LOCAL_COPY_PATH = tmp/local-repo-mysql8 LOCAL_WIKI_PATH = tmp/local-wiki-mysql8 +[repository.signing] +SIGNING_KEY = none + [server] SSH_DOMAIN = localhost HTTP_PORT = 3004 diff --git a/integrations/pgsql.ini.tmpl b/integrations/pgsql.ini.tmpl index cd5dc44ea81..6265e0d98ec 100644 --- a/integrations/pgsql.ini.tmpl +++ b/integrations/pgsql.ini.tmpl @@ -21,6 +21,9 @@ ROOT = integrations/gitea-integration-pgsql/gitea-repositories LOCAL_COPY_PATH = tmp/local-repo-pgsql LOCAL_WIKI_PATH = tmp/local-wiki-pgsql +[repository.signing] +SIGNING_KEY = none + [server] SSH_DOMAIN = localhost HTTP_PORT = 3002 diff --git a/integrations/repofiles_delete_test.go b/integrations/repofiles_delete_test.go index f4cb4510be4..b4c535188bc 100644 --- a/integrations/repofiles_delete_test.go +++ b/integrations/repofiles_delete_test.go @@ -53,7 +53,7 @@ func getExpectedDeleteFileResponse(u *url.URL) *api.FileResponse { }, Verification: &api.PayloadCommitVerification{ Verified: false, - Reason: "", + Reason: "gpg.error.not_signed_commit", Signature: "", Payload: "", }, diff --git a/integrations/repofiles_update_test.go b/integrations/repofiles_update_test.go index a4ce16d8479..c475c70008f 100644 --- a/integrations/repofiles_update_test.go +++ b/integrations/repofiles_update_test.go @@ -108,7 +108,7 @@ func getExpectedFileResponseForRepofilesCreate(commitID string) *api.FileRespons }, Verification: &api.PayloadCommitVerification{ Verified: false, - Reason: "unsigned", + Reason: "gpg.error.not_signed_commit", Signature: "", Payload: "", }, @@ -175,7 +175,7 @@ func getExpectedFileResponseForRepofilesUpdate(commitID, filename string) *api.F }, Verification: &api.PayloadCommitVerification{ Verified: false, - Reason: "unsigned", + Reason: "gpg.error.not_signed_commit", Signature: "", Payload: "", }, diff --git a/integrations/sqlite.ini b/integrations/sqlite.ini index b188406ee9c..de3355c166b 100644 --- a/integrations/sqlite.ini +++ b/integrations/sqlite.ini @@ -17,6 +17,9 @@ ROOT = integrations/gitea-integration-sqlite/gitea-repositories LOCAL_COPY_PATH = tmp/local-repo-sqlite LOCAL_WIKI_PATH = tmp/local-wiki-sqlite +[repository.signing] +SIGNING_KEY = none + [server] SSH_DOMAIN = localhost HTTP_PORT = 3003 diff --git a/models/gpg_key.go b/models/gpg_key.go index 72c6891d4d1..5cfe67435e4 100644 --- a/models/gpg_key.go +++ b/models/gpg_key.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" "github.com/go-xorm/xorm" @@ -80,6 +81,12 @@ func GetGPGKeyByID(keyID int64) (*GPGKey, error) { return key, nil } +// GetGPGKeysByKeyID returns public key by given ID. +func GetGPGKeysByKeyID(keyID string) ([]*GPGKey, error) { + keys := make([]*GPGKey, 0, 1) + return keys, x.Where("key_id=?", keyID).Find(&keys) +} + // GetGPGImportByKeyID returns the import public armored key by given KeyID. func GetGPGImportByKeyID(keyID string) (*GPGKeyImport, error) { key := new(GPGKeyImport) @@ -355,10 +362,13 @@ func DeleteGPGKey(doer *User, id int64) (err error) { // CommitVerification represents a commit validation of signature type CommitVerification struct { - Verified bool - Reason string - SigningUser *User - SigningKey *GPGKey + Verified bool + Warning bool + Reason string + SigningUser *User + CommittingUser *User + SigningEmail string + SigningKey *GPGKey } // SignCommit represents a commit with validation of signature. @@ -367,6 +377,17 @@ type SignCommit struct { *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" +) + func readerFromBase64(s string) (io.Reader, error) { bs, err := base64.StdEncoding.DecodeString(s) if err != nil { @@ -424,49 +445,207 @@ func verifySign(s *packet.Signature, h hash.Hash, k *GPGKey) error { return pkey.VerifySignature(h, s) } -// ParseCommitWithSignature check if signature is good against keystore. -func ParseCommitWithSignature(c *git.Commit) *CommitVerification { - if c.Signature != nil && c.Committer != nil { - //Parsing signature - sig, err := extractSignature(c.Signature.Signature) - if err != nil { //Skipping failed to extract sign - log.Error("SignatureRead err: %v", err) - return &CommitVerification{ - Verified: false, - Reason: "gpg.error.extract_sign", +func hashAndVerify(sig *packet.Signature, payload string, k *GPGKey, committer, signer *User, email string) *CommitVerification { + //Generating hash of commit + hash, err := populateHash(sig.Hash, []byte(payload)) + if err != nil { //Skipping failed to generate hash + log.Error("PopulateHash: %v", err) + return &CommitVerification{ + CommittingUser: committer, + Verified: false, + Reason: "gpg.error.generate_hash", + } + } + + if err := verifySign(sig, hash, k); err == nil { + return &CommitVerification{ //Everything is ok + CommittingUser: committer, + Verified: true, + Reason: fmt.Sprintf("%s <%s> / %s", signer.Name, signer.Email, k.KeyID), + SigningUser: signer, + SigningKey: k, + SigningEmail: email, + } + } + return nil +} + +func hashAndVerifyWithSubKeys(sig *packet.Signature, payload string, k *GPGKey, committer, signer *User, email string) *CommitVerification { + commitVerification := hashAndVerify(sig, payload, k, committer, signer, email) + if commitVerification != nil { + return commitVerification + } + + //And test also SubsKey + for _, sk := range k.SubsKey { + commitVerification := hashAndVerify(sig, payload, sk, committer, signer, email) + if commitVerification != nil { + return commitVerification + } + } + return nil +} + +func hashAndVerifyForKeyID(sig *packet.Signature, payload string, committer *User, keyID, name, email string) *CommitVerification { + if keyID == "" { + return nil + } + keys, err := GetGPGKeysByKeyID(keyID) + 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 { + activated := false + if len(email) != 0 { + for _, e := range key.Emails { + if e.IsActivated && strings.EqualFold(e.Email, email) { + activated = true + email = e.Email + break + } + } + } else { + for _, e := range key.Emails { + if e.IsActivated { + activated = true + email = e.Email + break + } + } + } + if !activated { + continue + } + signer := &User{ + Name: name, + Email: email, + } + if key.OwnerID != 0 { + owner, err := GetUserByID(key.OwnerID) + if err == nil { + signer = owner + } else if !IsErrUserNotExist(err) { + log.Error("Failed to 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 := hashAndVerifyWithSubKeys(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, + } +} +// ParseCommitWithSignature check if signature is good against keystore. +func ParseCommitWithSignature(c *git.Commit) *CommitVerification { + var committer *User + if c.Committer != nil { + var err error //Find Committer account - committer, err := GetUserByEmail(c.Committer.Email) //This find 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 commiter + committer, err = GetUserByEmail(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 commiter + committer = &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 !IsErrUserNotExist(err) { log.Error("GetUserByEmail: %v", err) + return &CommitVerification{ + CommittingUser: committer, + Verified: false, + Reason: "gpg.error.no_committer_account", + } } - return &CommitVerification{ - 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 + } + } + + //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( + 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 := ListGPGKeys(committer.ID) if err != nil { //Skipping failed to get gpg keys of user log.Error("ListGPGKeys: %v", err) return &CommitVerification{ - Verified: false, - Reason: "gpg.error.failed_retrieval_gpg_keys", + CommittingUser: committer, + Verified: false, + Reason: "gpg.error.failed_retrieval_gpg_keys", } } for _, k := range keys { //Pre-check (& optimization) that emails attached to key can be attached to the commiter email and can validate canValidate := false - lowerCommiterEmail := strings.ToLower(c.Committer.Email) + email := "" for _, e := range k.Emails { - if e.IsActivated && strings.ToLower(e.Email) == lowerCommiterEmail { + if e.IsActivated && strings.EqualFold(e.Email, c.Committer.Email) { canValidate = true + email = e.Email break } } @@ -474,56 +653,102 @@ func ParseCommitWithSignature(c *git.Commit) *CommitVerification { continue //Skip this key } - //Generating hash of commit - hash, err := populateHash(sig.Hash, []byte(c.Signature.Payload)) - if err != nil { //Skipping ailed to generate hash - log.Error("PopulateHash: %v", err) - return &CommitVerification{ - Verified: false, - Reason: "gpg.error.generate_hash", - } - } - //We get PK - if err := verifySign(sig, hash, k); err == nil { - return &CommitVerification{ //Everything is ok - Verified: true, - Reason: fmt.Sprintf("%s <%s> / %s", c.Committer.Name, c.Committer.Email, k.KeyID), - SigningUser: committer, - SigningKey: k, - } + commitVerification := hashAndVerifyWithSubKeys(sig, c.Signature.Payload, k, committer, committer, email) + if commitVerification != nil { + return commitVerification } - //And test also SubsKey - for _, sk := range k.SubsKey { - - //Generating hash of commit - hash, err := populateHash(sig.Hash, []byte(c.Signature.Payload)) - if err != nil { //Skipping ailed to generate hash - log.Error("PopulateHash: %v", err) - return &CommitVerification{ - Verified: false, - Reason: "gpg.error.generate_hash", - } - } - if err := verifySign(sig, hash, sk); err == nil { - return &CommitVerification{ //Everything is ok - Verified: true, - Reason: fmt.Sprintf("%s <%s> / %s", c.Committer.Name, c.Committer.Email, sk.KeyID), - SigningUser: committer, - SigningKey: sk, - } - } + } + } + + 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(&gpgSettings, sig, c.Signature.Payload, committer, keyID); commitVerification != nil { + if commitVerification.Reason == BadSignature { + defaultReason = BadSignature + } else { + return commitVerification } } - return &CommitVerification{ //Default at this stage - Verified: false, - Reason: "gpg.error.no_gpg_keys_found", + } + + defaultGPGSettings, err := c.GetRepositoryDefaultPublicGPGKey(false) + if err != nil { + log.Error("Error getting default public gpg key: %v", err) + } else if defaultGPGSettings.Sign { + if commitVerification := verifyWithGPGSettings(defaultGPGSettings, sig, c.Signature.Payload, committer, keyID); commitVerification != nil { + if commitVerification.Reason == BadSignature { + defaultReason = BadSignature + } else { + return commitVerification + } } } - return &CommitVerification{ - Verified: false, //Default value - Reason: "gpg.error.not_signed_commit", //Default value + return &CommitVerification{ //Default at this stage + CommittingUser: committer, + Verified: false, + Warning: defaultReason != NoKeyFound, + Reason: defaultReason, + SigningKey: &GPGKey{ + KeyID: keyID, + }, + } +} + +func verifyWithGPGSettings(gpgSettings *git.GPGSettings, sig *packet.Signature, payload string, committer *User, keyID string) *CommitVerification { + // First try to find the key in the db + if commitVerification := hashAndVerifyForKeyID(sig, payload, committer, gpgSettings.KeyID, gpgSettings.Name, gpgSettings.Email); commitVerification != nil { + return commitVerification } + + // Otherwise we have to parse the key + ekey, 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", + } + } + 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(), + } + if commitVerification := hashAndVerifyWithSubKeys(sig, payload, k, committer, &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 } // ParseCommitsWithSignature checks if signaute of commits are corresponding to users gpg keys. diff --git a/models/repo.go b/models/repo.go index d8a462c37bc..06708d24ab3 100644 --- a/models/repo.go +++ b/models/repo.go @@ -38,6 +38,7 @@ import ( "code.gitea.io/gitea/modules/timeutil" "github.com/go-xorm/xorm" + "github.com/mcuadros/go-version" "github.com/unknwon/com" ini "gopkg.in/ini.v1" "xorm.io/builder" @@ -1126,7 +1127,20 @@ func CleanUpMigrateInfo(repo *Repository) (*Repository, error) { } // initRepoCommit temporarily changes with work directory. -func initRepoCommit(tmpPath string, sig *git.Signature) (err error) { +func initRepoCommit(tmpPath string, u *User) (err error) { + commitTimeStr := time.Now().Format(time.RFC3339) + + sig := u.NewGitSig() + // Because this may call hooks we should pass in the environment + env := append(os.Environ(), + "GIT_AUTHOR_NAME="+sig.Name, + "GIT_AUTHOR_EMAIL="+sig.Email, + "GIT_AUTHOR_DATE="+commitTimeStr, + "GIT_COMMITTER_NAME="+sig.Name, + "GIT_COMMITTER_EMAIL="+sig.Email, + "GIT_COMMITTER_DATE="+commitTimeStr, + ) + var stderr string if _, stderr, err = process.GetManager().ExecDir(-1, tmpPath, fmt.Sprintf("initRepoCommit (git add): %s", tmpPath), @@ -1134,10 +1148,29 @@ func initRepoCommit(tmpPath string, sig *git.Signature) (err error) { return fmt.Errorf("git add: %s", stderr) } - if _, stderr, err = process.GetManager().ExecDir(-1, + binVersion, err := git.BinVersion() + if err != nil { + return fmt.Errorf("Unable to get git version: %v", err) + } + + args := []string{ + "commit", fmt.Sprintf("--author='%s <%s>'", sig.Name, sig.Email), + "-m", "Initial commit", + } + + if version.Compare(binVersion, "1.7.9", ">=") { + sign, keyID := SignInitialCommit(tmpPath, u) + if sign { + args = append(args, "-S"+keyID) + } else if version.Compare(binVersion, "2.0.0", ">=") { + args = append(args, "--no-gpg-sign") + } + } + + if _, stderr, err = process.GetManager().ExecDirEnv(-1, tmpPath, fmt.Sprintf("initRepoCommit (git commit): %s", tmpPath), - git.GitExecutable, "commit", fmt.Sprintf("--author='%s <%s>'", sig.Name, sig.Email), - "-m", "Initial commit"); err != nil { + env, + git.GitExecutable, args...); err != nil { return fmt.Errorf("git commit: %s", stderr) } @@ -1189,9 +1222,24 @@ func getRepoInitFile(tp, name string) ([]byte, error) { } func prepareRepoCommit(e Engine, repo *Repository, tmpDir, repoPath string, opts CreateRepoOptions) error { + commitTimeStr := time.Now().Format(time.RFC3339) + authorSig := repo.Owner.NewGitSig() + + // Because this may call hooks we should pass in the environment + env := append(os.Environ(), + "GIT_AUTHOR_NAME="+authorSig.Name, + "GIT_AUTHOR_EMAIL="+authorSig.Email, + "GIT_AUTHOR_DATE="+commitTimeStr, + "GIT_COMMITTER_NAME="+authorSig.Name, + "GIT_COMMITTER_EMAIL="+authorSig.Email, + "GIT_COMMITTER_DATE="+commitTimeStr, + ) + // Clone to temporary path and do the init commit. - _, stderr, err := process.GetManager().Exec( + _, stderr, err := process.GetManager().ExecDirEnv( + -1, "", fmt.Sprintf("initRepository(git clone): %s", repoPath), + env, git.GitExecutable, "clone", repoPath, tmpDir, ) if err != nil { @@ -1282,7 +1330,7 @@ func initRepository(e Engine, repoPath string, u *User, repo *Repository, opts C } // Apply changes and commit. - if err = initRepoCommit(tmpDir, u.NewGitSig()); err != nil { + if err = initRepoCommit(tmpDir, u); err != nil { return fmt.Errorf("initRepoCommit: %v", err) } } diff --git a/models/repo_sign.go b/models/repo_sign.go new file mode 100644 index 00000000000..bac69f76a89 --- /dev/null +++ b/models/repo_sign.go @@ -0,0 +1,303 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package models + +import ( + "strings" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/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" +) + +func signingModeFromStrings(modeStrings []string) []signingMode { + returnable := make([]signingMode, 0, len(modeStrings)) + for _, mode := range modeStrings { + signMode := signingMode(strings.ToLower(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 commitsSigned: + returnable = append(returnable, signMode) + } + } + if len(returnable) == 0 { + return []signingMode{never} + } + return returnable +} + +func signingKey(repoPath string) string { + if setting.Repository.Signing.SigningKey == "none" { + return "" + } + + if setting.Repository.Signing.SigningKey == "default" || setting.Repository.Signing.SigningKey == "" { + // Can ignore the error here as it means that commit.gpgsign is not set + value, _ := git.NewCommand("config", "--get", "commit.gpgsign").RunInDir(repoPath) + sign, valid := git.ParseBool(strings.TrimSpace(value)) + if !sign || !valid { + return "" + } + + signingKey, _ := git.NewCommand("config", "--get", "user.signingkey").RunInDir(repoPath) + return strings.TrimSpace(signingKey) + } + + return setting.Repository.Signing.SigningKey +} + +// PublicSigningKey gets the public signing key within a provided repository directory +func PublicSigningKey(repoPath string) (string, error) { + signingKey := signingKey(repoPath) + if signingKey == "" { + return "", nil + } + + content, stderr, err := process.GetManager().ExecDir(-1, repoPath, + "gpg --export -a", "gpg", "--export", "-a", signingKey) + if err != nil { + log.Error("Unable to get default signing key in %s: %s, %s, %v", repoPath, signingKey, stderr, err) + return "", err + } + return content, nil +} + +// SignInitialCommit determines if we should sign the initial commit to this repository +func SignInitialCommit(repoPath string, u *User) (bool, string) { + rules := signingModeFromStrings(setting.Repository.Signing.InitialCommit) + signingKey := signingKey(repoPath) + if signingKey == "" { + return false, "" + } + + for _, rule := range rules { + switch rule { + case never: + return false, "" + case always: + break + case pubkey: + keys, err := ListGPGKeys(u.ID) + if err != nil || len(keys) == 0 { + return false, "" + } + case twofa: + twofa, err := GetTwoFactorByUID(u.ID) + if err != nil || twofa == nil { + return false, "" + } + } + } + return true, signingKey +} + +// SignWikiCommit determines if we should sign the commits to this repository wiki +func (repo *Repository) SignWikiCommit(u *User) (bool, string) { + rules := signingModeFromStrings(setting.Repository.Signing.Wiki) + signingKey := signingKey(repo.WikiPath()) + if signingKey == "" { + return false, "" + } + + for _, rule := range rules { + switch rule { + case never: + return false, "" + case always: + break + case pubkey: + keys, err := ListGPGKeys(u.ID) + if err != nil || len(keys) == 0 { + return false, "" + } + case twofa: + twofa, err := GetTwoFactorByUID(u.ID) + if err != nil || twofa == nil { + return false, "" + } + case parentSigned: + gitRepo, err := git.OpenRepository(repo.WikiPath()) + if err != nil { + return false, "" + } + commit, err := gitRepo.GetCommit("HEAD") + if err != nil { + return false, "" + } + if commit.Signature == nil { + return false, "" + } + verification := ParseCommitWithSignature(commit) + if !verification.Verified { + return false, "" + } + } + } + return true, signingKey +} + +// SignCRUDAction determines if we should sign a CRUD commit to this repository +func (repo *Repository) SignCRUDAction(u *User, tmpBasePath, parentCommit string) (bool, string) { + rules := signingModeFromStrings(setting.Repository.Signing.CRUDActions) + signingKey := signingKey(repo.RepoPath()) + if signingKey == "" { + return false, "" + } + + for _, rule := range rules { + switch rule { + case never: + return false, "" + case always: + break + case pubkey: + keys, err := ListGPGKeys(u.ID) + if err != nil || len(keys) == 0 { + return false, "" + } + case twofa: + twofa, err := GetTwoFactorByUID(u.ID) + if err != nil || twofa == nil { + return false, "" + } + case parentSigned: + gitRepo, err := git.OpenRepository(tmpBasePath) + if err != nil { + return false, "" + } + commit, err := gitRepo.GetCommit(parentCommit) + if err != nil { + return false, "" + } + if commit.Signature == nil { + return false, "" + } + verification := ParseCommitWithSignature(commit) + if !verification.Verified { + return false, "" + } + } + } + return true, signingKey +} + +// SignMerge determines if we should sign a merge commit to this repository +func (repo *Repository) SignMerge(u *User, tmpBasePath, baseCommit, headCommit string) (bool, string) { + rules := signingModeFromStrings(setting.Repository.Signing.Merges) + signingKey := signingKey(repo.RepoPath()) + if signingKey == "" { + return false, "" + } + var gitRepo *git.Repository + var err error + + for _, rule := range rules { + switch rule { + case never: + return false, "" + case always: + break + case pubkey: + keys, err := ListGPGKeys(u.ID) + if err != nil || len(keys) == 0 { + return false, "" + } + case twofa: + twofa, err := GetTwoFactorByUID(u.ID) + if err != nil || twofa == nil { + return false, "" + } + case baseSigned: + if gitRepo == nil { + gitRepo, err = git.OpenRepository(tmpBasePath) + if err != nil { + return false, "" + } + } + commit, err := gitRepo.GetCommit(baseCommit) + if err != nil { + return false, "" + } + verification := ParseCommitWithSignature(commit) + if !verification.Verified { + return false, "" + } + case headSigned: + if gitRepo == nil { + gitRepo, err = git.OpenRepository(tmpBasePath) + if err != nil { + return false, "" + } + } + commit, err := gitRepo.GetCommit(headCommit) + if err != nil { + return false, "" + } + verification := ParseCommitWithSignature(commit) + if !verification.Verified { + return false, "" + } + case commitsSigned: + if gitRepo == nil { + gitRepo, err = git.OpenRepository(tmpBasePath) + if err != nil { + return false, "" + } + } + commit, err := gitRepo.GetCommit(headCommit) + if err != nil { + return false, "" + } + verification := ParseCommitWithSignature(commit) + if !verification.Verified { + return false, "" + } + // need to work out merge-base + mergeBaseCommit, _, err := gitRepo.GetMergeBase("", baseCommit, headCommit) + if err != nil { + return false, "" + } + commitList, err := commit.CommitsBeforeUntil(mergeBaseCommit) + if err != nil { + return false, "" + } + for e := commitList.Front(); e != nil; e = e.Next() { + commit = e.Value.(*git.Commit) + verification := ParseCommitWithSignature(commit) + if !verification.Verified { + return false, "" + } + } + } + } + return true, signingKey +} diff --git a/models/wiki.go b/models/wiki.go index 0460e0f0794..858fe1d6d04 100644 --- a/models/wiki.go +++ b/models/wiki.go @@ -205,6 +205,13 @@ func (repo *Repository) updateWikiPage(doer *User, oldWikiName, newWikiName, con commitTreeOpts := git.CommitTreeOpts{ Message: message, } + + sign, signingKey := repo.SignWikiCommit(doer) + if sign { + commitTreeOpts.KeyID = signingKey + } else { + commitTreeOpts.NoGPGSign = true + } if hasMasterBranch { commitTreeOpts.Parents = []string{"HEAD"} } @@ -307,11 +314,19 @@ func (repo *Repository) DeleteWikiPage(doer *User, wikiName string) (err error) return err } message := "Delete page '" + wikiName + "'" - - commitHash, err := gitRepo.CommitTree(doer.NewGitSig(), tree, git.CommitTreeOpts{ + commitTreeOpts := git.CommitTreeOpts{ Message: message, Parents: []string{"HEAD"}, - }) + } + + sign, signingKey := repo.SignWikiCommit(doer) + if sign { + commitTreeOpts.KeyID = signingKey + } else { + commitTreeOpts.NoGPGSign = true + } + + commitHash, err := gitRepo.CommitTree(doer.NewGitSig(), tree, commitTreeOpts) if err != nil { return err } diff --git a/modules/git/commit.go b/modules/git/commit.go index eb442f988d4..45b943e79ee 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -498,3 +498,11 @@ func GetFullCommitID(repoPath, shortID string) (string, error) { } return strings.TrimSpace(commitID), nil } + +// GetRepositoryDefaultPublicGPGKey returns the default public key for this commit +func (c *Commit) GetRepositoryDefaultPublicGPGKey(forceUpdate bool) (*GPGSettings, error) { + if c.repo == nil { + return nil, nil + } + return c.repo.GetDefaultPublicGPGKey(forceUpdate) +} diff --git a/modules/git/repo.go b/modules/git/repo.go index 1a9112132f9..dd886f3a2e6 100644 --- a/modules/git/repo.go +++ b/modules/git/repo.go @@ -32,6 +32,16 @@ type Repository struct { gogitRepo *gogit.Repository gogitStorage *filesystem.Storage + gpgSettings *GPGSettings +} + +// GPGSettings represents the default GPG settings for this repository +type GPGSettings struct { + Sign bool + KeyID string + Email string + Name string + PublicKeyContent string } const prettyLogFormat = `--pretty=format:%H` diff --git a/modules/git/repo_gpg.go b/modules/git/repo_gpg.go new file mode 100644 index 00000000000..b4c3f3b4314 --- /dev/null +++ b/modules/git/repo_gpg.go @@ -0,0 +1,59 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "fmt" + "strings" + + "code.gitea.io/gitea/modules/process" +) + +// LoadPublicKeyContent will load the key from gpg +func (gpgSettings *GPGSettings) LoadPublicKeyContent() error { + content, stderr, err := process.GetManager().Exec( + "gpg -a --export", + "gpg", "-a", "--export", gpgSettings.KeyID) + if err != nil { + return fmt.Errorf("Unable to get default signing key: %s, %s, %v", gpgSettings.KeyID, stderr, err) + } + gpgSettings.PublicKeyContent = content + return nil +} + +// GetDefaultPublicGPGKey will return and cache the default public GPG settings for this repository +func (repo *Repository) GetDefaultPublicGPGKey(forceUpdate bool) (*GPGSettings, error) { + if repo.gpgSettings != nil && !forceUpdate { + return repo.gpgSettings, nil + } + + gpgSettings := &GPGSettings{ + Sign: true, + } + + value, _ := NewCommand("config", "--get", "commit.gpgsign").RunInDir(repo.Path) + sign, valid := ParseBool(strings.TrimSpace(value)) + if !sign || !valid { + gpgSettings.Sign = false + repo.gpgSettings = gpgSettings + return gpgSettings, nil + } + + signingKey, _ := NewCommand("config", "--get", "user.signingkey").RunInDir(repo.Path) + gpgSettings.KeyID = strings.TrimSpace(signingKey) + + defaultEmail, _ := NewCommand("config", "--get", "user.email").RunInDir(repo.Path) + gpgSettings.Email = strings.TrimSpace(defaultEmail) + + defaultName, _ := NewCommand("config", "--get", "user.name").RunInDir(repo.Path) + gpgSettings.Name = strings.TrimSpace(defaultName) + + if err := gpgSettings.LoadPublicKeyContent(); err != nil { + return nil, err + } + repo.gpgSettings = gpgSettings + return repo.gpgSettings, nil +} diff --git a/modules/git/repo_tree.go b/modules/git/repo_tree.go index f5262ba81cb..8f91f4efacc 100644 --- a/modules/git/repo_tree.go +++ b/modules/git/repo_tree.go @@ -56,10 +56,11 @@ func (repo *Repository) GetTree(idStr string) (*Tree, error) { // CommitTreeOpts represents the possible options to CommitTree type CommitTreeOpts struct { - Parents []string - Message string - KeyID string - NoGPGSign bool + Parents []string + Message string + KeyID string + NoGPGSign bool + AlwaysSign bool } // CommitTree creates a commit from a given tree id for the user with provided message @@ -90,7 +91,7 @@ func (repo *Repository) CommitTree(sig *Signature, tree *Tree, opts CommitTreeOp _, _ = messageBytes.WriteString(opts.Message) _, _ = messageBytes.WriteString("\n") - if opts.KeyID != "" { + if version.Compare(binVersion, "1.7.9", ">=") && (opts.KeyID != "" || opts.AlwaysSign) { cmd.AddArguments(fmt.Sprintf("-S%s", opts.KeyID)) } diff --git a/modules/git/utils.go b/modules/git/utils.go index 83cd21f34e7..e791f16041e 100644 --- a/modules/git/utils.go +++ b/modules/git/utils.go @@ -7,6 +7,7 @@ package git import ( "fmt" "os" + "strconv" "strings" "sync" ) @@ -86,3 +87,30 @@ func RefEndName(refStr string) string { return refStr } + +// ParseBool returns the boolean value represented by the string as per git's git_config_bool +// true will be returned for the result if the string is empty, but valid will be false. +// "true", "yes", "on" are all true, true +// "false", "no", "off" are all false, true +// 0 is false, true +// Any other integer is true, true +// Anything else will return false, false +func ParseBool(value string) (result bool, valid bool) { + // Empty strings are true but invalid + if len(value) == 0 { + return true, false + } + // These are the git expected true and false values + if strings.EqualFold(value, "true") || strings.EqualFold(value, "yes") || strings.EqualFold(value, "on") { + return true, true + } + if strings.EqualFold(value, "false") || strings.EqualFold(value, "no") || strings.EqualFold(value, "off") { + return false, true + } + // Try a number + intValue, err := strconv.ParseInt(value, 10, 32) + if err != nil { + return false, false + } + return intValue != 0, true +} diff --git a/modules/repofiles/file_test.go b/modules/repofiles/file_test.go index 7c45139dd94..95ec175ed47 100644 --- a/modules/repofiles/file_test.go +++ b/modules/repofiles/file_test.go @@ -73,7 +73,7 @@ func getExpectedFileResponse() *api.FileResponse { }, Verification: &api.PayloadCommitVerification{ Verified: false, - Reason: "", + Reason: "gpg.error.not_signed_commit", Signature: "", Payload: "", }, diff --git a/modules/repofiles/temp_repo.go b/modules/repofiles/temp_repo.go index 4a50e641927..b07d2a89735 100644 --- a/modules/repofiles/temp_repo.go +++ b/modules/repofiles/temp_repo.go @@ -261,7 +261,6 @@ func (t *TemporaryUploadRepository) CommitTree(author, committer *models.User, t return "", fmt.Errorf("Unable to get git version: %v", err) } - // FIXME: Should we add SSH_ORIGINAL_COMMAND to this // Because this may call hooks we should pass in the environment env := append(os.Environ(), "GIT_AUTHOR_NAME="+authorSig.Name, @@ -271,13 +270,21 @@ func (t *TemporaryUploadRepository) CommitTree(author, committer *models.User, t "GIT_COMMITTER_EMAIL="+committerSig.Email, "GIT_COMMITTER_DATE="+commitTimeStr, ) + messageBytes := new(bytes.Buffer) _, _ = messageBytes.WriteString(message) _, _ = messageBytes.WriteString("\n") args := []string{"commit-tree", treeHash, "-p", "HEAD"} - if version.Compare(binVersion, "2.0.0", ">=") { - args = append(args, "--no-gpg-sign") + + // Determine if we should sign + if version.Compare(binVersion, "1.7.9", ">=") { + sign, keyID := t.repo.SignCRUDAction(author, t.basePath, "HEAD") + if sign { + args = append(args, "-S"+keyID) + } else if version.Compare(binVersion, "2.0.0", ">=") { + args = append(args, "--no-gpg-sign") + } } commitHash, stderr, err := process.GetManager().ExecDirEnvStdIn(5*time.Minute, diff --git a/modules/repofiles/verification.go b/modules/repofiles/verification.go index 9fc084daafe..3889b7993c7 100644 --- a/modules/repofiles/verification.go +++ b/modules/repofiles/verification.go @@ -18,10 +18,16 @@ func GetPayloadCommitVerification(commit *git.Commit) *structs.PayloadCommitVeri verification.Signature = commit.Signature.Signature verification.Payload = commit.Signature.Payload } - if verification.Reason != "" { - verification.Reason = commitVerification.Reason - } else if verification.Verified { - verification.Reason = "unsigned" + if commitVerification.SigningUser != nil { + verification.Signer = &structs.PayloadUser{ + Name: commitVerification.SigningUser.Name, + Email: commitVerification.SigningUser.Email, + } + } + verification.Verified = commitVerification.Verified + verification.Reason = commitVerification.Reason + if verification.Reason == "" && !verification.Verified { + verification.Reason = "gpg.error.not_signed_commit" } return verification } diff --git a/modules/setting/repository.go b/modules/setting/repository.go index 728741576dc..19c68d003ff 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -65,6 +65,16 @@ var ( Issue struct { LockReasons []string } `ini:"repository.issue"` + + Signing struct { + SigningKey string + SigningName string + SigningEmail string + InitialCommit []string + CRUDActions []string `ini:"CRUD_ACTIONS"` + Merges []string + Wiki []string + } `ini:"repository.signing"` }{ AnsiCharset: "", ForcePrivate: false, @@ -122,6 +132,25 @@ var ( }{ LockReasons: strings.Split("Too heated,Off-topic,Spam,Resolved", ","), }, + + // Signing settings + Signing: struct { + SigningKey string + SigningName string + SigningEmail string + InitialCommit []string + CRUDActions []string `ini:"CRUD_ACTIONS"` + Merges []string + Wiki []string + }{ + SigningKey: "default", + SigningName: "", + SigningEmail: "", + InitialCommit: []string{"always"}, + CRUDActions: []string{"pubkey", "twofa", "parentsigned"}, + Merges: []string{"pubkey", "twofa", "basesigned", "commitssigned"}, + Wiki: []string{"never"}, + }, } RepoRootPath string ScriptType = "bash" diff --git a/modules/structs/hook.go b/modules/structs/hook.go index 9a25219e36e..2c923d36c51 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -91,10 +91,11 @@ type PayloadCommit struct { // PayloadCommitVerification represents the GPG verification of a commit type PayloadCommitVerification struct { - Verified bool `json:"verified"` - Reason string `json:"reason"` - Signature string `json:"signature"` - Payload string `json:"payload"` + Verified bool `json:"verified"` + Reason string `json:"reason"` + Signature string `json:"signature"` + Signer *PayloadUser `json:"signer"` + Payload string `json:"payload"` } var ( diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 76a1daa4518..4d73d91aa26 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1974,12 +1974,15 @@ mark_as_unread = Mark as unread mark_all_as_read = Mark all as read [gpg] +default_key=Signed with default key error.extract_sign = Failed to extract signature error.generate_hash = Failed to generate hash of commit error.no_committer_account = No account linked to committer's email address error.no_gpg_keys_found = "No known key found for this signature in database" error.not_signed_commit = "Not a signed commit" error.failed_retrieval_gpg_keys = "Failed to retrieve any key attached to the committer's account" +error.probable_bad_signature = "WARNING! Although there is a key with this ID in the database it does not verify this commit! This commit is SUSPICIOUS." +error.probable_bad_default_signature = "WARNING! Although the default key has this ID it does not verify this commit! This commit is SUSPICIOUS." [units] error.no_unit_allowed_repo = You are not allowed to access any section of this repository. diff --git a/public/css/index.css b/public/css/index.css index fda26f4e084..6fdcb5c2258 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -225,6 +225,10 @@ footer .ui.left,footer .ui.right{line-height:40px} .inline-grouped-list{display:inline-block;vertical-align:top} .inline-grouped-list>.ui{display:block;margin-top:5px;margin-bottom:10px} .inline-grouped-list>.ui:first-child{margin-top:1px} +i.icons .icon:first-child{margin-right:0} +i.icon.centerlock{top:1.5em} +.ui.label>.detail .icons{margin-right:.25em} +.ui.label>.detail .icons .icon{margin-right:0} .lines-num{vertical-align:top;text-align:right!important;color:#999;background:#f5f5f5;width:1%;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none} .lines-num span:before{content:attr(data-line-number);line-height:20px!important;padding:0 10px;cursor:pointer;display:block} .lines-code,.lines-num{padding:0!important} @@ -654,6 +658,8 @@ footer .ui.left,footer .ui.right{line-height:40px} .repository #commits-table.ui.basic.striped.table tbody tr:nth-child(2n){background-color:rgba(0,0,0,.02)!important} .repository #commits-table td.sha .sha.label,.repository #repo-files-table .sha.label{border:1px solid #bbb} .repository #commits-table td.sha .sha.label .detail.icon,.repository #repo-files-table .sha.label .detail.icon{background:#fafafa;margin:-6px -10px -4px 0;padding:5px 3px 5px 6px;border-left:1px solid #bbb;border-top-left-radius:0;border-bottom-left-radius:0} +.repository #commits-table td.sha .sha.label.isSigned.isWarning,.repository #repo-files-table .sha.label.isSigned.isWarning{border:1px solid #db2828;background:rgba(219,40,40,.1)} +.repository #commits-table td.sha .sha.label.isSigned.isWarning .detail.icon,.repository #repo-files-table .sha.label.isSigned.isWarning .detail.icon{border-left:1px solid rgba(219,40,40,.5)} .repository #commits-table td.sha .sha.label.isSigned.isVerified,.repository #repo-files-table .sha.label.isSigned.isVerified{border:1px solid #21ba45;background:rgba(33,186,69,.1)} .repository #commits-table td.sha .sha.label.isSigned.isVerified .detail.icon,.repository #repo-files-table .sha.label.isSigned.isVerified .detail.icon{border-left:1px solid #21ba45} .repository #commits-table td.sha .sha.label.isSigned.isVerified:hover,.repository #repo-files-table .sha.label.isSigned.isVerified:hover{background:rgba(33,186,69,.3)!important} diff --git a/public/less/_base.less b/public/less/_base.less index e295be368df..62b2084a3b3 100644 --- a/public/less/_base.less +++ b/public/less/_base.less @@ -950,6 +950,22 @@ footer { } } +i.icons .icon:first-child { + margin-right: 0; +} + +i.icon.centerlock { + top: 1.5em; +} + +.ui.label > .detail .icons { + margin-right: 0.25em; +} + +.ui.label > .detail .icons .icon { + margin-right: 0; +} + .lines-num { vertical-align: top; text-align: right !important; diff --git a/public/less/_repository.less b/public/less/_repository.less index 5f6a7fbd97c..3586eeccf0a 100644 --- a/public/less/_repository.less +++ b/public/less/_repository.less @@ -1212,6 +1212,15 @@ border-bottom-left-radius: 0; } + &.isSigned.isWarning { + border: 1px solid #db2828; + background: fade(#db2828, 10%); + + .detail.icon { + border-left: 1px solid fade(#db2828, 50%); + } + } + &.isSigned.isVerified { border: 1px solid #21ba45; background: fade(#21ba45, 10%); diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 04ff91fbbf9..f8ab9025b78 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -507,6 +507,7 @@ func RegisterRoutes(m *macaron.Macaron) { m.Get("/swagger", misc.Swagger) } m.Get("/version", misc.Version) + m.Get("/signing-key.gpg", misc.SigningKey) m.Post("/markdown", bind(api.MarkdownOption{}), misc.Markdown) m.Post("/markdown/raw", misc.MarkdownRaw) @@ -771,6 +772,7 @@ func RegisterRoutes(m *macaron.Macaron) { m.Delete("", bind(api.DeleteFileOptions{}), repo.DeleteFile) }, reqRepoWriter(models.UnitTypeCode), reqToken()) }, reqRepoReader(models.UnitTypeCode)) + m.Get("/signing-key.gpg", misc.SigningKey) m.Group("/topics", func() { m.Combo("").Get(repo.ListTopics). Put(reqToken(), reqAdmin(), bind(api.RepoTopicOptions{}), repo.UpdateTopics) diff --git a/routers/api/v1/convert/convert.go b/routers/api/v1/convert/convert.go index e0e7f609c74..02620513903 100644 --- a/routers/api/v1/convert/convert.go +++ b/routers/api/v1/convert/convert.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" @@ -84,17 +85,21 @@ func ToCommit(repo *models.Repository, c *git.Commit) *api.PayloadCommit { // ToVerification convert a git.Commit.Signature to an api.PayloadCommitVerification func ToVerification(c *git.Commit) *api.PayloadCommitVerification { verif := models.ParseCommitWithSignature(c) - var signature, payload string + commitVerification := &api.PayloadCommitVerification{ + Verified: verif.Verified, + Reason: verif.Reason, + } if c.Signature != nil { - signature = c.Signature.Signature - payload = c.Signature.Payload + commitVerification.Signature = c.Signature.Signature + commitVerification.Payload = c.Signature.Payload } - return &api.PayloadCommitVerification{ - Verified: verif.Verified, - Reason: verif.Reason, - Signature: signature, - Payload: payload, + if verif.SigningUser != nil { + commitVerification.Signer = &structs.PayloadUser{ + Name: verif.SigningUser.Name, + Email: verif.SigningUser.Email, + } } + return commitVerification } // ToPublicKey convert models.PublicKey to api.PublicKey diff --git a/routers/api/v1/misc/signing.go b/routers/api/v1/misc/signing.go new file mode 100644 index 00000000000..f5428670af6 --- /dev/null +++ b/routers/api/v1/misc/signing.go @@ -0,0 +1,62 @@ +package misc + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" +) + +// SigningKey returns the public key of the default signing key if it exists +func SigningKey(ctx *context.Context) { + // swagger:operation GET /signing-key.gpg miscellaneous getSigningKey + // --- + // summary: Get default signing-key.gpg + // produces: + // - text/plain + // responses: + // "200": + // description: "GPG armored public key" + // schema: + // type: string + + // swagger:operation GET /repos/{owner}/{repo}/signing-key.gpg repository repoSigningKey + // --- + // summary: Get signing-key.gpg for given repository + // produces: + // - text/plain + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "200": + // description: "GPG armored public key" + // schema: + // type: string + + path := "" + if ctx.Repo != nil && ctx.Repo.Repository != nil { + path = ctx.Repo.Repository.RepoPath() + } + + content, err := models.PublicSigningKey(path) + if err != nil { + ctx.ServerError("gpg export", err) + return + } + _, err = ctx.Write([]byte(content)) + if err != nil { + log.Error("Error writing key content %v", err) + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("%v", err)) + } +} diff --git a/services/pull/merge.go b/services/pull/merge.go index 355d6dd9110..0d762dbc30d 100644 --- a/services/pull/merge.go +++ b/services/pull/merge.go @@ -13,6 +13,7 @@ import ( "os" "path/filepath" "strings" + "time" "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/cache" @@ -28,6 +29,11 @@ import ( // Merge merges pull request to base repository. // FIXME: add repoWorkingPull make sure two merges does not happen at same time. func Merge(pr *models.PullRequest, doer *models.User, baseGitRepo *git.Repository, mergeStyle models.MergeStyle, message string) (err error) { + binVersion, err := git.BinVersion() + if err != nil { + return fmt.Errorf("Unable to get git version: %v", err) + } + if err = pr.GetHeadRepo(); err != nil { return fmt.Errorf("GetHeadRepo: %v", err) } else if err = pr.GetBaseRepo(); err != nil { @@ -176,6 +182,30 @@ func Merge(pr *models.PullRequest, doer *models.User, baseGitRepo *git.Repositor return fmt.Errorf("git read-tree HEAD: %s", errbuf.String()) } + // Determine if we should sign + signArg := "" + if version.Compare(binVersion, "1.7.9", ">=") { + sign, keyID := pr.BaseRepo.SignMerge(doer, tmpBasePath, "HEAD", trackingBranch) + if sign { + signArg = "-S" + keyID + } else if version.Compare(binVersion, "2.0.0", ">=") { + signArg = "--no-gpg-sign" + } + } + + sig := doer.NewGitSig() + commitTimeStr := time.Now().Format(time.RFC3339) + + // Because this may call hooks we should pass in the environment + env := append(os.Environ(), + "GIT_AUTHOR_NAME="+sig.Name, + "GIT_AUTHOR_EMAIL="+sig.Email, + "GIT_AUTHOR_DATE="+commitTimeStr, + "GIT_COMMITTER_NAME="+sig.Name, + "GIT_COMMITTER_EMAIL="+sig.Email, + "GIT_COMMITTER_DATE="+commitTimeStr, + ) + // Merge commits. switch mergeStyle { case models.MergeStyleMerge: @@ -183,9 +213,14 @@ func Merge(pr *models.PullRequest, doer *models.User, baseGitRepo *git.Repositor return fmt.Errorf("git merge --no-ff --no-commit [%s]: %v - %s", tmpBasePath, err, errbuf.String()) } - sig := doer.NewGitSig() - if err := git.NewCommand("commit", fmt.Sprintf("--author='%s <%s>'", sig.Name, sig.Email), "-m", message).RunInDirPipeline(tmpBasePath, nil, &errbuf); err != nil { - return fmt.Errorf("git commit [%s]: %v - %s", tmpBasePath, err, errbuf.String()) + if signArg == "" { + if err := git.NewCommand("commit", "-m", message).RunInDirTimeoutEnvPipeline(env, -1, tmpBasePath, nil, &errbuf); err != nil { + return fmt.Errorf("git commit [%s]: %v - %s", tmpBasePath, err, errbuf.String()) + } + } else { + if err := git.NewCommand("commit", signArg, "-m", message).RunInDirTimeoutEnvPipeline(env, -1, tmpBasePath, nil, &errbuf); err != nil { + return fmt.Errorf("git commit [%s]: %v - %s", tmpBasePath, err, errbuf.String()) + } } case models.MergeStyleRebase: // Checkout head branch @@ -223,9 +258,14 @@ func Merge(pr *models.PullRequest, doer *models.User, baseGitRepo *git.Repositor } // Set custom message and author and create merge commit - sig := doer.NewGitSig() - if err := git.NewCommand("commit", fmt.Sprintf("--author='%s <%s>'", sig.Name, sig.Email), "-m", message).RunInDirPipeline(tmpBasePath, nil, &errbuf); err != nil { - return fmt.Errorf("git commit [%s]: %v - %s", tmpBasePath, err, errbuf.String()) + if signArg == "" { + if err := git.NewCommand("commit", "-m", message).RunInDirTimeoutEnvPipeline(env, -1, tmpBasePath, nil, &errbuf); err != nil { + return fmt.Errorf("git commit [%s]: %v - %s", tmpBasePath, err, errbuf.String()) + } + } else { + if err := git.NewCommand("commit", signArg, "-m", message).RunInDirTimeoutEnvPipeline(env, -1, tmpBasePath, nil, &errbuf); err != nil { + return fmt.Errorf("git commit [%s]: %v - %s", tmpBasePath, err, errbuf.String()) + } } case models.MergeStyleSquash: @@ -234,8 +274,14 @@ func Merge(pr *models.PullRequest, doer *models.User, baseGitRepo *git.Repositor return fmt.Errorf("git merge --squash [%s -> %s]: %s", headRepoPath, tmpBasePath, errbuf.String()) } sig := pr.Issue.Poster.NewGitSig() - if err := git.NewCommand("commit", fmt.Sprintf("--author='%s <%s>'", sig.Name, sig.Email), "-m", message).RunInDirPipeline(tmpBasePath, nil, &errbuf); err != nil { - return fmt.Errorf("git commit [%s]: %v - %s", tmpBasePath, err, errbuf.String()) + if signArg == "" { + if err := git.NewCommand("commit", fmt.Sprintf("--author='%s <%s>'", sig.Name, sig.Email), "-m", message).RunInDirTimeoutEnvPipeline(env, -1, tmpBasePath, nil, &errbuf); err != nil { + return fmt.Errorf("git commit [%s]: %v - %s", tmpBasePath, err, errbuf.String()) + } + } else { + if err := git.NewCommand("commit", signArg, fmt.Sprintf("--author='%s <%s>'", sig.Name, sig.Email), "-m", message).RunInDirTimeoutEnvPipeline(env, -1, tmpBasePath, nil, &errbuf); err != nil { + return fmt.Errorf("git commit [%s]: %v - %s", tmpBasePath, err, errbuf.String()) + } } default: return models.ErrInvalidMergeStyle{ID: pr.BaseRepo.ID, Style: mergeStyle} @@ -270,7 +316,7 @@ func Merge(pr *models.PullRequest, doer *models.User, baseGitRepo *git.Repositor headUser = doer } - env := models.FullPushingEnvironment( + env = models.FullPushingEnvironment( headUser, doer, pr.BaseRepo, diff --git a/templates/repo/commit_page.tmpl b/templates/repo/commit_page.tmpl index 0cfdf5156d4..5b19523cf2a 100644 --- a/templates/repo/commit_page.tmpl +++ b/templates/repo/commit_page.tmpl @@ -26,6 +26,16 @@ {{.Commit.Author.Name}} {{end}} + {{if or (ne .Commit.Committer.Name .Commit.Author.Name) (ne .Commit.Committer.Email .Commit.Author.Email)}} + + {{if ne .Verification.CommittingUser.ID 0}} + + {{.Commit.Committer.Name}} <{{.Commit.Committer.Email}}> + {{else}} + + {{.Commit.Committer.Name}} + {{end}} + {{end}} {{TimeSince .Commit.Author.When $.Lang}}
@@ -50,15 +60,36 @@ {{if .Commit.Signature}} {{if .Verification.Verified }}
- - {{.i18n.Tr "repo.commits.signed_by"}}: - {{.Commit.Committer.Name}} <{{.Commit.Committer.Email}}> - {{.i18n.Tr "repo.commits.gpg_key_id"}}: {{.Verification.SigningKey.KeyID}} + {{if ne .Verification.SigningUser.ID 0}} + + {{.i18n.Tr "repo.commits.signed_by"}}: + + {{.Verification.SigningUser.Name}} <{{.Verification.SigningEmail}}> + {{.i18n.Tr "repo.commits.gpg_key_id"}}: {{.Verification.SigningKey.KeyID}} + {{else}} + + + + + {{.i18n.Tr "repo.commits.signed_by"}}: + + {{.Verification.SigningUser.Name}} <{{.Verification.SigningEmail}}> + {{.i18n.Tr "repo.commits.gpg_key_id"}}: {{.Verification.SigningKey.KeyID}} + {{end}} +
+ {{else if .Verification.Warning}} +
+ + {{.i18n.Tr .Verification.Reason}} + {{.i18n.Tr "repo.commits.gpg_key_id"}}: {{.Verification.SigningKey.KeyID}}
{{else}}
{{.i18n.Tr .Verification.Reason}} + {{if and .Verification.SigningKey (ne .Verification.SigningKey.KeyID "")}} + {{.i18n.Tr "repo.commits.gpg_key_id"}}: {{.Verification.SigningKey.KeyID}} + {{end}}
{{end}} {{end}} diff --git a/templates/repo/commits_table.tmpl b/templates/repo/commits_table.tmpl index e11bbee0e83..09a2c072b13 100644 --- a/templates/repo/commits_table.tmpl +++ b/templates/repo/commits_table.tmpl @@ -56,12 +56,21 @@ {{end}} - + {{ShortSha .ID.String}} {{if .Signature}}
{{if .Verification.Verified}} - + {{if ne .Verification.SigningUser.ID 0}} + + {{else}} + + + + + {{end}} + {{else if .Verification.Warning}} + {{else}} {{end}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index d8750d8bcce..5be36d23be9 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -5140,6 +5140,42 @@ } } }, + "/repos/{owner}/{repo}/signing-key.gpg": { + "get": { + "produces": [ + "text/plain" + ], + "tags": [ + "repository" + ], + "summary": "Get signing-key.gpg for given repository", + "operationId": "repoSigningKey", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "GPG armored public key", + "schema": { + "type": "string" + } + } + } + } + }, "/repos/{owner}/{repo}/stargazers": { "get": { "produces": [ @@ -5691,6 +5727,26 @@ } } }, + "/signing-key.gpg": { + "get": { + "produces": [ + "text/plain" + ], + "tags": [ + "miscellaneous" + ], + "summary": "Get default signing-key.gpg", + "operationId": "getSigningKey", + "responses": { + "200": { + "description": "GPG armored public key", + "schema": { + "type": "string" + } + } + } + } + }, "/teams/{id}": { "get": { "produces": [ @@ -9525,6 +9581,9 @@ "type": "string", "x-go-name": "Signature" }, + "signer": { + "$ref": "#/definitions/PayloadUser" + }, "verified": { "type": "boolean", "x-go-name": "Verified"