From 7d0ffbfbc0e4be14cebefc45b2fa2e48f1f0ad05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ga=C4=87e=C5=A1a?= Date: Wed, 2 Jul 2025 11:13:37 +0000 Subject: [PATCH] feat: [CODE-2554]: add commit and tag signature parse and verify (#3915) * empty commit * Merge remote-tracking branch 'origin/main' into mg/publickey/verify * addressing PR comments * addressing PR comments * addressing PR comments * commit signature parsing --- app/api/controller/repo/content_get.go | 8 + .../controller/repo/content_paths_details.go | 29 +- app/api/controller/repo/controller.go | 150 +++---- app/api/controller/repo/create_commit_tag.go | 4 +- app/api/controller/repo/list_commit_tags.go | 64 ++- app/api/controller/repo/list_commits.go | 22 +- app/api/controller/repo/wire.go | 4 +- app/api/controller/user/controller.go | 47 +- app/api/controller/user/publickey_create.go | 15 +- app/api/controller/user/publickey_edit.go | 182 ++++++++ app/api/controller/user/wire.go | 2 + app/api/controller/util.go | 19 + app/api/handler/repo/list_commit_tags.go | 3 - app/api/handler/user/publickey_edit.go | 53 +++ app/api/openapi/repo.go | 4 +- app/api/openapi/user.go | 12 + app/router/api.go | 2 + .../{key_pgp.go => keypgp/parse_pgp.go} | 82 ++-- app/services/publickey/keypgp/verify_pgp.go | 229 ++++++++++ .../{key_ssh.go => keyssh/parse_ssh.go} | 44 +- app/services/publickey/keyssh/verify_ssh.go | 256 +++++++++++ app/services/publickey/parse.go | 8 +- app/services/publickey/service_ssh.go | 3 +- app/services/publickey/service_verify.go | 407 ++++++++++++++++++ app/services/publickey/verify_ssh.go | 54 --- app/services/publickey/wire.go | 14 + app/store/database.go | 22 + app/store/database/git_signature_result.go | 239 ++++++++++ app/store/database/membership.go | 2 +- ...reate_table_git_signature_results.down.sql | 2 + ..._create_table_git_signature_results.up.sql | 27 ++ ...reate_table_git_signature_results.down.sql | 2 + ..._create_table_git_signature_results.up.sql | 27 ++ app/store/database/publickey.go | 26 ++ app/store/database/publickey_subkey.go | 36 +- app/store/database/wire.go | 5 + cmd/gitness/wire_gen.go | 6 +- git/api/cat-file.go | 10 +- git/parser/raw_object_test.go | 69 ++- git/sha/sha.go | 5 + git/sharedrepo/sharedrepo.go | 2 +- ssh/server.go | 3 +- types/commit.go | 9 +- types/enum/public_key.go | 45 ++ types/git.go | 53 ++- 45 files changed, 1973 insertions(+), 334 deletions(-) create mode 100644 app/api/controller/user/publickey_edit.go create mode 100644 app/api/handler/user/publickey_edit.go rename app/services/publickey/{key_pgp.go => keypgp/parse_pgp.go} (75%) create mode 100644 app/services/publickey/keypgp/verify_pgp.go rename app/services/publickey/{key_ssh.go => keyssh/parse_ssh.go} (67%) create mode 100644 app/services/publickey/keyssh/verify_ssh.go create mode 100644 app/services/publickey/service_verify.go delete mode 100644 app/services/publickey/verify_ssh.go create mode 100644 app/store/database/git_signature_result.go create mode 100644 app/store/database/migrate/postgres/0122_create_table_git_signature_results.down.sql create mode 100644 app/store/database/migrate/postgres/0122_create_table_git_signature_results.up.sql create mode 100644 app/store/database/migrate/sqlite/0122_create_table_git_signature_results.down.sql create mode 100644 app/store/database/migrate/sqlite/0122_create_table_git_signature_results.up.sql diff --git a/app/api/controller/repo/content_get.go b/app/api/controller/repo/content_get.go index b32d09492..c0cd76aa9 100644 --- a/app/api/controller/repo/content_get.go +++ b/app/api/controller/repo/content_get.go @@ -151,6 +151,14 @@ func (c *Controller) GetContent(ctx context.Context, return nil, err } + if info.LatestCommit != nil { + err = c.signatureVerifyService.VerifyCommits(ctx, repo.ID, []*types.Commit{info.LatestCommit}) + if err != nil { + return nil, fmt.Errorf("failed to verify signature of the last commit SHA=%s: %w", + info.LatestCommit.SHA.String(), err) + } + } + return &GetContentOutput{ ContentInfo: info, Content: content, diff --git a/app/api/controller/repo/content_paths_details.go b/app/api/controller/repo/content_paths_details.go index d75d4e678..08d246e05 100644 --- a/app/api/controller/repo/content_paths_details.go +++ b/app/api/controller/repo/content_paths_details.go @@ -16,10 +16,13 @@ package repo import ( "context" + "fmt" + "github.com/harness/gitness/app/api/controller" "github.com/harness/gitness/app/api/usererror" "github.com/harness/gitness/app/auth" "github.com/harness/gitness/git" + "github.com/harness/gitness/types" "github.com/harness/gitness/types/enum" ) @@ -28,7 +31,7 @@ type PathsDetailsInput struct { } type PathsDetailsOutput struct { - Details []git.PathDetails `json:"details"` + Details []types.PathDetails `json:"details"` } // PathsDetails finds the additional info about the provided paths of the repo. @@ -68,7 +71,25 @@ func (c *Controller) PathsDetails(ctx context.Context, return PathsDetailsOutput{}, err } - return PathsDetailsOutput{ - Details: result.Details, - }, nil + commits := make([]*types.Commit, 0, len(result.Details)) + output := PathsDetailsOutput{ + Details: make([]types.PathDetails, len(result.Details)), + } + for i, d := range result.Details { + lastCommit := controller.MapCommit(d.LastCommit) + output.Details[i] = types.PathDetails{ + Path: d.Path, + LastCommit: lastCommit, + } + if lastCommit != nil { + commits = append(commits, lastCommit) + } + } + + err = c.signatureVerifyService.VerifyCommits(ctx, repo.ID, commits) + if err != nil { + return PathsDetailsOutput{}, fmt.Errorf("failed to verify signature of commits: %w", err) + } + + return output, nil } diff --git a/app/api/controller/repo/controller.go b/app/api/controller/repo/controller.go index d96f71cc5..bf187714c 100644 --- a/app/api/controller/repo/controller.go +++ b/app/api/controller/repo/controller.go @@ -36,6 +36,7 @@ import ( "github.com/harness/gitness/app/services/locker" "github.com/harness/gitness/app/services/protection" "github.com/harness/gitness/app/services/publicaccess" + "github.com/harness/gitness/app/services/publickey" "github.com/harness/gitness/app/services/refcache" "github.com/harness/gitness/app/services/rules" "github.com/harness/gitness/app/services/settings" @@ -78,42 +79,43 @@ func (r RepositoryOutput) MarshalJSON() ([]byte, error) { type Controller struct { defaultBranch string - tx dbtx.Transactor - urlProvider url.Provider - authorizer authz.Authorizer - repoStore store.RepoStore - spaceStore store.SpaceStore - pipelineStore store.PipelineStore - executionStore store.ExecutionStore - principalStore store.PrincipalStore - ruleStore store.RuleStore - checkStore store.CheckStore - pullReqStore store.PullReqStore - settings *settings.Service - principalInfoCache store.PrincipalInfoCache - userGroupStore store.UserGroupStore - userGroupService usergroup.Service - protectionManager *protection.Manager - git git.Interface - spaceFinder refcache.SpaceFinder - repoFinder refcache.RepoFinder - importer *importer.Repository - codeOwners *codeowners.Service - eventReporter *repoevents.Reporter - indexer keywordsearch.Indexer - resourceLimiter limiter.ResourceLimiter - locker *locker.Locker - auditService audit.Service - mtxManager lock.MutexManager - identifierCheck check.RepoIdentifier - repoCheck Check - publicAccess publicaccess.Service - labelSvc *label.Service - instrumentation instrument.Service - rulesSvc *rules.Service - sseStreamer sse.Streamer - lfsCtrl *lfs.Controller - favoriteStore store.FavoriteStore + tx dbtx.Transactor + urlProvider url.Provider + authorizer authz.Authorizer + repoStore store.RepoStore + spaceStore store.SpaceStore + pipelineStore store.PipelineStore + executionStore store.ExecutionStore + principalStore store.PrincipalStore + ruleStore store.RuleStore + checkStore store.CheckStore + pullReqStore store.PullReqStore + settings *settings.Service + principalInfoCache store.PrincipalInfoCache + userGroupStore store.UserGroupStore + userGroupService usergroup.Service + protectionManager *protection.Manager + git git.Interface + spaceFinder refcache.SpaceFinder + repoFinder refcache.RepoFinder + importer *importer.Repository + codeOwners *codeowners.Service + eventReporter *repoevents.Reporter + indexer keywordsearch.Indexer + resourceLimiter limiter.ResourceLimiter + locker *locker.Locker + auditService audit.Service + mtxManager lock.MutexManager + identifierCheck check.RepoIdentifier + repoCheck Check + publicAccess publicaccess.Service + labelSvc *label.Service + instrumentation instrument.Service + rulesSvc *rules.Service + sseStreamer sse.Streamer + lfsCtrl *lfs.Controller + favoriteStore store.FavoriteStore + signatureVerifyService publickey.SignatureVerifyService } func NewController( @@ -154,45 +156,47 @@ func NewController( sseStreamer sse.Streamer, lfsCtrl *lfs.Controller, favoriteStore store.FavoriteStore, + signatureVerifyService publickey.SignatureVerifyService, ) *Controller { return &Controller{ - defaultBranch: config.Git.DefaultBranch, - tx: tx, - urlProvider: urlProvider, - authorizer: authorizer, - repoStore: repoStore, - spaceStore: spaceStore, - pipelineStore: pipelineStore, - executionStore: executionStore, - principalStore: principalStore, - ruleStore: ruleStore, - checkStore: checkStore, - pullReqStore: pullReqStore, - settings: settings, - principalInfoCache: principalInfoCache, - protectionManager: protectionManager, - git: git, - spaceFinder: spaceFinder, - repoFinder: repoFinder, - importer: importer, - codeOwners: codeOwners, - eventReporter: eventReporter, - indexer: indexer, - resourceLimiter: limiter, - locker: locker, - auditService: auditService, - mtxManager: mtxManager, - identifierCheck: identifierCheck, - repoCheck: repoCheck, - publicAccess: publicAccess, - labelSvc: labelSvc, - instrumentation: instrumentation, - userGroupStore: userGroupStore, - userGroupService: userGroupService, - rulesSvc: rulesSvc, - sseStreamer: sseStreamer, - lfsCtrl: lfsCtrl, - favoriteStore: favoriteStore, + defaultBranch: config.Git.DefaultBranch, + tx: tx, + urlProvider: urlProvider, + authorizer: authorizer, + repoStore: repoStore, + spaceStore: spaceStore, + pipelineStore: pipelineStore, + executionStore: executionStore, + principalStore: principalStore, + ruleStore: ruleStore, + checkStore: checkStore, + pullReqStore: pullReqStore, + settings: settings, + principalInfoCache: principalInfoCache, + protectionManager: protectionManager, + git: git, + spaceFinder: spaceFinder, + repoFinder: repoFinder, + importer: importer, + codeOwners: codeOwners, + eventReporter: eventReporter, + indexer: indexer, + resourceLimiter: limiter, + locker: locker, + auditService: auditService, + mtxManager: mtxManager, + identifierCheck: identifierCheck, + repoCheck: repoCheck, + publicAccess: publicAccess, + labelSvc: labelSvc, + instrumentation: instrumentation, + userGroupStore: userGroupStore, + userGroupService: userGroupService, + rulesSvc: rulesSvc, + sseStreamer: sseStreamer, + lfsCtrl: lfsCtrl, + favoriteStore: favoriteStore, + signatureVerifyService: signatureVerifyService, } } diff --git a/app/api/controller/repo/create_commit_tag.go b/app/api/controller/repo/create_commit_tag.go index d3c2b04bd..e3e0120b0 100644 --- a/app/api/controller/repo/create_commit_tag.go +++ b/app/api/controller/repo/create_commit_tag.go @@ -49,7 +49,7 @@ func (c *Controller) CreateCommitTag(ctx context.Context, session *auth.Session, repoRef string, in *CreateCommitTagInput, -) (*CommitTag, []types.RuleViolations, error) { +) (*types.CommitTag, []types.RuleViolations, error) { repo, err := c.getRepoCheckAccess(ctx, session, repoRef, enum.PermissionRepoPush) if err != nil { return nil, nil, err @@ -100,7 +100,7 @@ func (c *Controller) CreateCommitTag(ctx context.Context, return nil, nil, err } - commitTag := mapCommitTag(rpcOut.CommitTag) + commitTag := controller.MapCommitTag(rpcOut.CommitTag) err = c.instrumentation.Track(ctx, instrument.Event{ Type: instrument.EventTypeCreateTag, diff --git a/app/api/controller/repo/list_commit_tags.go b/app/api/controller/repo/list_commit_tags.go index 229fb33b6..949f1739c 100644 --- a/app/api/controller/repo/list_commit_tags.go +++ b/app/api/controller/repo/list_commit_tags.go @@ -16,39 +16,28 @@ package repo import ( "context" + "fmt" "github.com/harness/gitness/app/api/controller" "github.com/harness/gitness/app/auth" "github.com/harness/gitness/git" - "github.com/harness/gitness/git/sha" "github.com/harness/gitness/types" "github.com/harness/gitness/types/enum" ) -type CommitTag struct { - Name string `json:"name"` - SHA sha.SHA `json:"sha"` - IsAnnotated bool `json:"is_annotated"` - Title string `json:"title,omitempty"` - Message string `json:"message,omitempty"` - Tagger *types.Signature `json:"tagger,omitempty"` - SignedData *types.SignedData `json:"-"` - Commit *types.Commit `json:"commit,omitempty"` -} - // ListCommitTags lists the commit tags of a repo. func (c *Controller) ListCommitTags(ctx context.Context, session *auth.Session, repoRef string, includeCommit bool, filter *types.TagFilter, -) ([]CommitTag, error) { +) ([]*types.CommitTag, error) { repo, err := c.getRepoCheckAccess(ctx, session, repoRef, enum.PermissionRepoView) if err != nil { return nil, err } - rpcOut, err := c.git.ListCommitTags(ctx, &git.ListCommitTagsParams{ + result, err := c.git.ListCommitTags(ctx, &git.ListCommitTagsParams{ ReadParams: git.CreateReadParams(repo), IncludeCommit: includeCommit, Query: filter.Query, @@ -61,11 +50,33 @@ func (c *Controller) ListCommitTags(ctx context.Context, return nil, err } - tags := make([]CommitTag, len(rpcOut.Tags)) - for i := range rpcOut.Tags { - tags[i] = mapCommitTag(rpcOut.Tags[i]) + tags := make([]*types.CommitTag, len(result.Tags)) + for i := range result.Tags { + t := controller.MapCommitTag(result.Tags[i]) + tags[i] = &t } + verifySession := c.signatureVerifyService.NewVerifySession(repo.ID) + + err = verifySession.VerifyCommitTags(ctx, tags) + if err != nil { + return nil, fmt.Errorf("failed to verify tags: %w", err) + } + + commits := make([]*types.Commit, 0, len(tags)) + for _, tag := range tags { + if tag.Commit != nil { + commits = append(commits, tag.Commit) + } + } + + err = verifySession.VerifyCommits(ctx, commits) + if err != nil { + return nil, fmt.Errorf("failed to verify signature of tags' commits: %w", err) + } + + verifySession.StoreSignatures(ctx) + return tags, nil } @@ -82,22 +93,3 @@ func mapToRPCTagSortOption(o enum.TagSortOption) git.TagSortOption { return git.TagSortOptionDefault } } - -func mapCommitTag(t git.CommitTag) CommitTag { - var tagger *types.Signature - if t.Tagger != nil { - tagger = &types.Signature{} - *tagger = controller.MapSignature(*t.Tagger) - } - - return CommitTag{ - Name: t.Name, - SHA: t.SHA, - IsAnnotated: t.IsAnnotated, - Title: t.Title, - Message: t.Message, - Tagger: tagger, - SignedData: (*types.SignedData)(t.SignedData), - Commit: controller.MapCommit(t.Commit), - } -} diff --git a/app/api/controller/repo/list_commits.go b/app/api/controller/repo/list_commits.go index 2e0c0203d..11f1640ab 100644 --- a/app/api/controller/repo/list_commits.go +++ b/app/api/controller/repo/list_commits.go @@ -56,7 +56,7 @@ func (c *Controller) ListCommits(ctx context.Context, return types.ListCommitResponse{}, fmt.Errorf("failed create author regex: %w", err) } - rpcOut, err := c.git.ListCommits(ctx, &git.ListCommitsParams{ + result, err := c.git.ListCommits(ctx, &git.ListCommitsParams{ ReadParams: git.CreateReadParams(repo), GitREF: gitRef, After: filter.After, @@ -74,23 +74,29 @@ func (c *Controller) ListCommits(ctx context.Context, return types.ListCommitResponse{}, err } - commits := make([]types.Commit, len(rpcOut.Commits)) - for i := range rpcOut.Commits { - commits[i] = *controller.MapCommit(&rpcOut.Commits[i]) + commits := make([]*types.Commit, len(result.Commits)) + for i := range result.Commits { + commits[i] = controller.MapCommit(&result.Commits[i]) } - renameDetailList := make([]types.RenameDetails, len(rpcOut.RenameDetails)) - for i := range rpcOut.RenameDetails { - renameDetails := controller.MapRenameDetails(rpcOut.RenameDetails[i]) + err = c.signatureVerifyService.VerifyCommits(ctx, repo.ID, commits) + if err != nil { + return types.ListCommitResponse{}, fmt.Errorf("failed to verify signature of commits: %w", err) + } + + renameDetailList := make([]types.RenameDetails, len(result.RenameDetails)) + for i := range result.RenameDetails { + renameDetails := controller.MapRenameDetails(result.RenameDetails[i]) if renameDetails == nil { return types.ListCommitResponse{}, fmt.Errorf("rename details was nil") } renameDetailList[i] = *renameDetails } + return types.ListCommitResponse{ Commits: commits, RenameDetails: renameDetailList, - TotalCommits: rpcOut.TotalCommits, + TotalCommits: result.TotalCommits, }, nil } diff --git a/app/api/controller/repo/wire.go b/app/api/controller/repo/wire.go index 2f02eb253..ee72b0305 100644 --- a/app/api/controller/repo/wire.go +++ b/app/api/controller/repo/wire.go @@ -27,6 +27,7 @@ import ( "github.com/harness/gitness/app/services/locker" "github.com/harness/gitness/app/services/protection" "github.com/harness/gitness/app/services/publicaccess" + "github.com/harness/gitness/app/services/publickey" "github.com/harness/gitness/app/services/refcache" "github.com/harness/gitness/app/services/rules" "github.com/harness/gitness/app/services/settings" @@ -87,6 +88,7 @@ func ProvideController( sseStreamer sse.Streamer, lfsCtrl *lfs.Controller, favoriteStore store.FavoriteStore, + signatureVerifyService publickey.SignatureVerifyService, ) *Controller { return NewController(config, tx, urlProvider, authorizer, @@ -95,7 +97,7 @@ func ProvideController( principalInfoCache, protectionManager, rpcClient, spaceFinder, repoFinder, importer, codeOwners, repoReporter, indexer, limiter, locker, auditService, mtxManager, identifierCheck, repoChecks, publicAccess, labelSvc, instrumentation, userGroupStore, userGroupService, - rulesSvc, sseStreamer, lfsCtrl, favoriteStore, + rulesSvc, sseStreamer, lfsCtrl, favoriteStore, signatureVerifyService, ) } diff --git a/app/api/controller/user/controller.go b/app/api/controller/user/controller.go index 7a5fac695..ad163df92 100644 --- a/app/api/controller/user/controller.go +++ b/app/api/controller/user/controller.go @@ -30,17 +30,18 @@ import ( ) type Controller struct { - tx dbtx.Transactor - principalUIDCheck check.PrincipalUID - authorizer authz.Authorizer - principalStore store.PrincipalStore - tokenStore store.TokenStore - membershipStore store.MembershipStore - publicKeyStore store.PublicKeyStore - publicKeySubKeyStore store.PublicKeySubKeyStore - eventReporter *userevents.Reporter - repoFinder refcache.RepoFinder - favoriteStore store.FavoriteStore + tx dbtx.Transactor + principalUIDCheck check.PrincipalUID + authorizer authz.Authorizer + principalStore store.PrincipalStore + tokenStore store.TokenStore + membershipStore store.MembershipStore + publicKeyStore store.PublicKeyStore + publicKeySubKeyStore store.PublicKeySubKeyStore + gitSignatureResultStore store.GitSignatureResultStore + eventReporter *userevents.Reporter + repoFinder refcache.RepoFinder + favoriteStore store.FavoriteStore } func NewController( @@ -52,22 +53,24 @@ func NewController( membershipStore store.MembershipStore, publicKeyStore store.PublicKeyStore, publicKeySubKeyStore store.PublicKeySubKeyStore, + gitSignatureResultStore store.GitSignatureResultStore, eventReporter *userevents.Reporter, repoFinder refcache.RepoFinder, favoriteStore store.FavoriteStore, ) *Controller { return &Controller{ - tx: tx, - principalUIDCheck: principalUIDCheck, - authorizer: authorizer, - principalStore: principalStore, - tokenStore: tokenStore, - membershipStore: membershipStore, - publicKeyStore: publicKeyStore, - publicKeySubKeyStore: publicKeySubKeyStore, - eventReporter: eventReporter, - repoFinder: repoFinder, - favoriteStore: favoriteStore, + tx: tx, + principalUIDCheck: principalUIDCheck, + authorizer: authorizer, + principalStore: principalStore, + tokenStore: tokenStore, + membershipStore: membershipStore, + publicKeyStore: publicKeyStore, + publicKeySubKeyStore: publicKeySubKeyStore, + gitSignatureResultStore: gitSignatureResultStore, + eventReporter: eventReporter, + repoFinder: repoFinder, + favoriteStore: favoriteStore, } } diff --git a/app/api/controller/user/publickey_create.go b/app/api/controller/user/publickey_create.go index b4329a659..33b3dc931 100644 --- a/app/api/controller/user/publickey_create.go +++ b/app/api/controller/user/publickey_create.go @@ -17,7 +17,6 @@ package user import ( "context" "fmt" - "slices" "strings" "time" @@ -77,7 +76,7 @@ func (c *Controller) CreatePublicKey( return nil, err } - key, err := publickey.ParseString(in.Content) + key, err := publickey.ParseString(in.Content, &session.Principal) if err != nil { return nil, errors.InvalidArgument("unrecognized key content") } @@ -86,18 +85,6 @@ func (c *Controller) CreatePublicKey( return nil, errors.InvalidArgument("key is not a valid %s key", in.Scheme) } - // SSH keys don't have an embedded identity, by PGP keys do. - // If a key has identities, one of those must match the current user's. - // The email address must match, the name can be different. - if identities := key.Identities(); len(identities) > 0 { - found := slices.ContainsFunc(identities, func(identity types.Identity) bool { - return strings.EqualFold(identity.Email, session.Principal.Email) - }) - if !found { - return nil, errors.InvalidArgument("key identities don't contain the current user's email address") - } - } - switch key.Scheme() { case enum.PublicKeySchemeSSH: if in.Usage == "" { diff --git a/app/api/controller/user/publickey_edit.go b/app/api/controller/user/publickey_edit.go new file mode 100644 index 000000000..f483e562c --- /dev/null +++ b/app/api/controller/user/publickey_edit.go @@ -0,0 +1,182 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package user + +import ( + "context" + "fmt" + "time" + + apiauth "github.com/harness/gitness/app/api/auth" + "github.com/harness/gitness/app/auth" + "github.com/harness/gitness/errors" + "github.com/harness/gitness/types" + "github.com/harness/gitness/types/enum" +) + +type UpdatePublicKeyInput struct { + RevocationReason *enum.RevocationReason `json:"revocation_reason"` + ValidFrom *int64 `json:"valid_from"` + ValidTo *int64 `json:"valid_to"` +} + +func (in *UpdatePublicKeyInput) Sanitize() error { + if in.RevocationReason != nil { + if _, ok := in.RevocationReason.Sanitize(); !ok { + return errors.InvalidArgument("invalid public key revocation reason") + } + + if in.ValidFrom != nil || in.ValidTo != nil { + return errors.InvalidArgument("must either revoke the key or update its validity period") + } + } + + if in.ValidFrom != nil && in.ValidTo != nil { + if *in.ValidFrom > *in.ValidTo { + return errors.InvalidArgument("invalid validity period") + } + } + + return nil +} + +func (c *Controller) UpdatePublicKey( + ctx context.Context, + session *auth.Session, + userUID string, + identifier string, + in *UpdatePublicKeyInput, +) (*types.PublicKey, error) { + user, err := c.principalStore.FindUserByUID(ctx, userUID) + if err != nil { + return nil, fmt.Errorf("failed to fetch user by uid: %w", err) + } + + if err = apiauth.CheckUser(ctx, c.authorizer, session, user, enum.PermissionUserEdit); err != nil { + return nil, fmt.Errorf("access check failed: %w", err) + } + + if err := in.Sanitize(); err != nil { + return nil, err + } + + key, err := c.publicKeyStore.FindByIdentifier(ctx, user.ID, identifier) + if err != nil { + return nil, fmt.Errorf("failed to find public key by identifier: %w", err) + } + + if key.RevocationReason != nil { + if in.ValidFrom != nil || in.ValidTo != nil { + return nil, errors.InvalidArgument("can't update the validity period of revoked keys") + } + + if *key.RevocationReason == enum.RevocationReasonCompromised { + return nil, errors.InvalidArgument("can't update the revocation reason of compromised keys") + } + } + + var ( + changedRevocationReason bool + changedValidityPeriod bool + ) + + if in.RevocationReason != nil && + (key.RevocationReason == nil || *key.RevocationReason != *in.RevocationReason) { + now := time.Now().UnixMilli() + key.RevocationReason = in.RevocationReason + if key.ValidTo == nil || *key.ValidTo > now { + key.ValidTo = &now + } + changedRevocationReason = true + } + + isTimestampChanged := func(ts1, ts2 *int64) bool { + return ts1 != nil && ts2 != nil && *ts1 != *ts2 || + ts1 == nil && ts2 != nil || + ts1 != nil && ts2 == nil + } + + if in.ValidFrom != nil { + if *in.ValidFrom == 0 { // zero means clear + in.ValidFrom = nil + } + + if isTimestampChanged(key.ValidFrom, in.ValidFrom) { + key.ValidFrom = in.ValidFrom + changedValidityPeriod = true + } + } + + if in.ValidTo != nil { + if *in.ValidTo == 0 { // zero means clear + in.ValidTo = nil + } + + if isTimestampChanged(key.ValidTo, in.ValidTo) { + key.ValidTo = in.ValidTo + changedValidityPeriod = true + } + } + + if !changedRevocationReason && !changedValidityPeriod { + return key, nil + } + + err = c.tx.WithTx(ctx, func(ctx context.Context) error { + err = c.publicKeyStore.Update(ctx, key) + if err != nil { + return fmt.Errorf("failed to update public key: %w", err) + } + + if changedRevocationReason && *key.RevocationReason == enum.RevocationReasonCompromised { + switch key.Scheme { + case enum.PublicKeySchemePGP: + subKeys, err := c.publicKeySubKeyStore.List(ctx, key.ID) + if err != nil { + return fmt.Errorf("failed to list subkeys: %w", err) + } + + err = c.gitSignatureResultStore.UpdateAll( + ctx, + enum.GitSignatureRevoked, + key.PrincipalID, + subKeys, + nil) + if err != nil { + return fmt.Errorf("failed to revoke all PGP signatures for keys %v: %w", subKeys, err) + } + + case enum.PublicKeySchemeSSH: + fingerprints := []string{key.Fingerprint} + err := c.gitSignatureResultStore.UpdateAll( + ctx, + enum.GitSignatureRevoked, + key.PrincipalID, + nil, + fingerprints) + if err != nil { + return fmt.Errorf("failed to revoke all SSH signatures for key %v: %w", fingerprints, err) + } + } + } + + return nil + }) + if err != nil { + return nil, err + } + + return key, nil +} diff --git a/app/api/controller/user/wire.go b/app/api/controller/user/wire.go index b9350430a..ae1888f3e 100644 --- a/app/api/controller/user/wire.go +++ b/app/api/controller/user/wire.go @@ -38,6 +38,7 @@ func ProvideController( membershipStore store.MembershipStore, publicKeyStore store.PublicKeyStore, publicKeySubKeyStore store.PublicKeySubKeyStore, + gitSignatureResultStore store.GitSignatureResultStore, eventReporter *userevents.Reporter, repoFinder refcache.RepoFinder, favoriteStore store.FavoriteStore, @@ -51,6 +52,7 @@ func ProvideController( membershipStore, publicKeyStore, publicKeySubKeyStore, + gitSignatureResultStore, eventReporter, repoFinder, favoriteStore) diff --git a/app/api/controller/util.go b/app/api/controller/util.go index b9ea20efa..e6ede6b65 100644 --- a/app/api/controller/util.go +++ b/app/api/controller/util.go @@ -118,6 +118,25 @@ func MapCommit(c *git.Commit) *types.Commit { } } +func MapCommitTag(t git.CommitTag) types.CommitTag { + var tagger *types.Signature + if t.Tagger != nil { + tagger = &types.Signature{} + *tagger = MapSignature(*t.Tagger) + } + + return types.CommitTag{ + Name: t.Name, + SHA: t.SHA, + IsAnnotated: t.IsAnnotated, + Title: t.Title, + Message: t.Message, + Tagger: tagger, + SignedData: (*types.SignedData)(t.SignedData), + Commit: MapCommit(t.Commit), + } +} + func mapStats(c *git.Commit) *types.CommitStats { if len(c.FileStats) == 0 { return nil diff --git a/app/api/handler/repo/list_commit_tags.go b/app/api/handler/repo/list_commit_tags.go index 91eb83796..166ee79ba 100644 --- a/app/api/handler/repo/list_commit_tags.go +++ b/app/api/handler/repo/list_commit_tags.go @@ -22,9 +22,6 @@ import ( "github.com/harness/gitness/app/api/request" ) -/* - * Writes json-encoded commit tag information to the http response body. - */ func HandleListCommitTags(repoCtrl *repo.Controller) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/app/api/handler/user/publickey_edit.go b/app/api/handler/user/publickey_edit.go new file mode 100644 index 000000000..3c8f68f19 --- /dev/null +++ b/app/api/handler/user/publickey_edit.go @@ -0,0 +1,53 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package user + +import ( + "encoding/json" + "net/http" + + "github.com/harness/gitness/app/api/controller/user" + "github.com/harness/gitness/app/api/render" + "github.com/harness/gitness/app/api/request" +) + +func HandleUpdatePublicKey(userCtrl *user.Controller) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + session, _ := request.AuthSessionFrom(ctx) + userUID := session.Principal.UID + + id, err := request.GetPublicKeyIdentifierFromPath(r) + if err != nil { + render.BadRequest(ctx, w) + return + } + + in := new(user.UpdatePublicKeyInput) + err = json.NewDecoder(r.Body).Decode(in) + if err != nil { + render.BadRequestf(ctx, w, "Invalid Request Body: %s.", err) + return + } + + key, err := userCtrl.UpdatePublicKey(ctx, session, userUID, id, in) + if err != nil { + render.TranslatedUserError(ctx, w, err) + return + } + + render.JSON(w, http.StatusOK, key) + } +} diff --git a/app/api/openapi/repo.go b/app/api/openapi/repo.go index 9c8ae6eb1..aa12f0bb8 100644 --- a/app/api/openapi/repo.go +++ b/app/api/openapi/repo.go @@ -1123,7 +1123,7 @@ func repoOperations(reflector *openapi3.Reflector) { queryParameterQueryTags, queryParameterOrder, queryParameterSortTags, QueryParameterPage, QueryParameterLimit) _ = reflector.SetRequest(&opListTags, new(listTagsRequest), http.MethodGet) - _ = reflector.SetJSONResponse(&opListTags, []repo.CommitTag{}, http.StatusOK) + _ = reflector.SetJSONResponse(&opListTags, []*types.CommitTag{}, http.StatusOK) _ = reflector.SetJSONResponse(&opListTags, new(usererror.Error), http.StatusInternalServerError) _ = reflector.SetJSONResponse(&opListTags, new(usererror.Error), http.StatusUnauthorized) _ = reflector.SetJSONResponse(&opListTags, new(usererror.Error), http.StatusForbidden) @@ -1134,7 +1134,7 @@ func repoOperations(reflector *openapi3.Reflector) { opCreateTag.WithTags("repository") opCreateTag.WithMapOfAnything(map[string]interface{}{"operationId": "createTag"}) _ = reflector.SetRequest(&opCreateTag, new(createTagRequest), http.MethodPost) - _ = reflector.SetJSONResponse(&opCreateTag, new(repo.CommitTag), http.StatusCreated) + _ = reflector.SetJSONResponse(&opCreateTag, new(types.CommitTag), http.StatusCreated) _ = reflector.SetJSONResponse(&opCreateTag, new(usererror.Error), http.StatusBadRequest) _ = reflector.SetJSONResponse(&opCreateTag, new(usererror.Error), http.StatusInternalServerError) _ = reflector.SetJSONResponse(&opCreateTag, new(usererror.Error), http.StatusUnauthorized) diff --git a/app/api/openapi/user.go b/app/api/openapi/user.go index 5f4eced7f..99accde7e 100644 --- a/app/api/openapi/user.go +++ b/app/api/openapi/user.go @@ -182,6 +182,18 @@ func buildUser(reflector *openapi3.Reflector) { _ = reflector.SetJSONResponse(&opKeyDelete, new(usererror.Error), http.StatusInternalServerError) _ = reflector.Spec.AddOperation(http.MethodDelete, "/user/keys/{public_key_identifier}", opKeyDelete) + opKeyUpdate := openapi3.Operation{} + opKeyUpdate.WithTags("user") + opKeyUpdate.WithMapOfAnything(map[string]interface{}{"operationId": "updatePublicKey"}) + _ = reflector.SetRequest(&opKeyUpdate, struct { + ID string `path:"public_key_identifier"` + }{}, http.MethodPatch) + _ = reflector.SetJSONResponse(&opKeyUpdate, &types.PublicKey{}, http.StatusOK) + _ = reflector.SetJSONResponse(&opKeyUpdate, new(usererror.Error), http.StatusBadRequest) + _ = reflector.SetJSONResponse(&opKeyUpdate, new(usererror.Error), http.StatusNotFound) + _ = reflector.SetJSONResponse(&opKeyUpdate, new(usererror.Error), http.StatusInternalServerError) + _ = reflector.Spec.AddOperation(http.MethodPatch, "/user/keys/{public_key_identifier}", opKeyUpdate) + opKeyList := openapi3.Operation{} opKeyList.WithTags("user") opKeyList.WithMapOfAnything(map[string]interface{}{"operationId": "listPublicKey"}) diff --git a/app/router/api.go b/app/router/api.go index 8f75220bb..10e3f68c5 100644 --- a/app/router/api.go +++ b/app/router/api.go @@ -806,6 +806,8 @@ func setupUser(r chi.Router, userCtrl *user.Controller) { r.Post("/", handleruser.HandleCreatePublicKey(userCtrl)) r.Delete(fmt.Sprintf("/{%s}", request.PathParamPublicKeyIdentifier), handleruser.HandleDeletePublicKey(userCtrl)) + r.Patch(fmt.Sprintf("/{%s}", request.PathParamPublicKeyIdentifier), + handleruser.HandleUpdatePublicKey(userCtrl)) }) // Favorites diff --git a/app/services/publickey/key_pgp.go b/app/services/publickey/keypgp/parse_pgp.go similarity index 75% rename from app/services/publickey/key_pgp.go rename to app/services/publickey/keypgp/parse_pgp.go index 9ce8703dc..7ce33ee9a 100644 --- a/app/services/publickey/key_pgp.go +++ b/app/services/publickey/keypgp/parse_pgp.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package publickey +package keypgp import ( "bytes" @@ -31,7 +31,7 @@ import ( "golang.org/x/exp/slices" ) -type PGPKeyMetadata struct { +type KeyMetadata struct { // ID of the key. ID string `json:"id"` @@ -54,43 +54,43 @@ type PGPKeyMetadata struct { BitLength uint16 `json:"bit_length"` } -type PGPEntityMetadata struct { +type EntityMetadata struct { PrimaryIdentity *types.Identity `json:"primary_identity,omitempty"` Identities []types.Identity `json:"identities,omitempty"` - PrimaryKey PGPKeyMetadata `json:"primary_key"` - SubKeys []PGPKeyMetadata `json:"sub_keys,omitempty"` + PrimaryKey KeyMetadata `json:"primary_key"` + SubKeys []KeyMetadata `json:"sub_keys,omitempty"` } -func parsePGP(r io.Reader) (PGPKeyInfo, error) { +func Parse(r io.Reader, principal *types.Principal) (KeyInfo, error) { keyRing, err := openpgp.ReadArmoredKeyRing(r) if err != nil { - return PGPKeyInfo{}, errors.InvalidArgument("failed to read PGP key ring: %s", err.Error()) + return KeyInfo{}, errors.InvalidArgument("failed to read PGP key ring: %s", err.Error()) } if len(keyRing) == 0 { - return PGPKeyInfo{}, errors.InvalidArgument("PGP key ring contains no keys") + return KeyInfo{}, errors.InvalidArgument("PGP key ring contains no keys") } if len(keyRing) > 1 { - return PGPKeyInfo{}, errors.InvalidArgument("can't accept a PGP key ring with multiple primary keys") + return KeyInfo{}, errors.InvalidArgument("can't accept a PGP key ring with multiple primary keys") } keyEntity := keyRing[0] if keyEntity == nil || keyEntity.PrimaryKey == nil { // Should not happen. - return PGPKeyInfo{}, errors.InvalidArgument("PGP key ring entity is nil") + return KeyInfo{}, errors.InvalidArgument("PGP key ring entity is nil") } if keyEntity.PrivateKey != nil { - return PGPKeyInfo{}, errors.InvalidArgument("refusing to accept private key: please upload a public key") + return KeyInfo{}, errors.InvalidArgument("refusing to accept private key: please upload a public key") } primarySignature, primaryIdentity := keyEntity.PrimarySelfSignature() if primarySignature == nil { // Should not happen. - return PGPKeyInfo{}, errors.InvalidArgument("PGP key entity is missing primary signature") + return KeyInfo{}, errors.InvalidArgument("PGP key entity is missing primary signature") } // Extract the validity period from the key's primary signature. @@ -100,6 +100,13 @@ func parsePGP(r io.Reader) (PGPKeyInfo, error) { var identity *types.Identity var comment string + foundPrincipal := false + if principal == nil { + // If principal is nil, it means that no particular principal is needed. + // By `foundPrincipal = true` we declare that we have "found" it. + foundPrincipal = true + } + // Process the primary identity (name and email address for the key) if it exists. // The identity can also have revocations. We ignore the revocation reason, but honor // the validity period. The final validity period for the key is intersection between @@ -114,6 +121,8 @@ func parsePGP(r io.Reader) (PGPKeyInfo, error) { Email: primaryIdentity.UserId.Email, } + foundPrincipal = foundPrincipal || strings.EqualFold(identity.Email, principal.Email) + comment = primaryIdentity.UserId.Comment } @@ -123,12 +132,20 @@ func parsePGP(r io.Reader) (PGPKeyInfo, error) { Name: ident.UserId.Name, Email: ident.UserId.Email, }) + + foundPrincipal = foundPrincipal || strings.EqualFold(ident.UserId.Email, principal.Email) } - var subKeys []PGPKeyMetadata + // PGP keys can have multiple identities and one of those must match the current user's. + // The email address must match, the name can be different. + if !foundPrincipal { + return KeyInfo{}, errors.InvalidArgument("key identities don't contain the user's email address") + } + + var subKeys []KeyMetadata for _, subKey := range keyEntity.Subkeys { if subKey.PublicKey == nil || subKey.Sig == nil { - return PGPKeyInfo{}, errors.InvalidArgument("found a subkey without public key") + return KeyInfo{}, errors.InvalidArgument("found a subkey without public key") } // We'll only consider keys than can be used for signing @@ -142,7 +159,7 @@ func parsePGP(r io.Reader) (PGPKeyInfo, error) { subKeyValidFrom, subKeyValidTo := validitySubkey.Milliseconds() bits, _ := subKey.PublicKey.BitLength() - subKeys = append(subKeys, PGPKeyMetadata{ + subKeys = append(subKeys, KeyMetadata{ ID: subKey.PublicKey.KeyIdString(), Fingerprint: fmt.Sprintf("%X", subKey.PublicKey.Fingerprint), RevocationReason: getRevocationReason(subKey.Revocations), @@ -155,10 +172,10 @@ func parsePGP(r io.Reader) (PGPKeyInfo, error) { keyValidFrom, keyValidTo := validityKey.Milliseconds() bits, _ := keyEntity.PrimaryKey.BitLength() - metadata := PGPEntityMetadata{ + metadata := EntityMetadata{ PrimaryIdentity: identity, Identities: identities, - PrimaryKey: PGPKeyMetadata{ + PrimaryKey: KeyMetadata{ ID: keyEntity.PrimaryKey.KeyIdString(), Fingerprint: fmt.Sprintf("%X", keyEntity.PrimaryKey.Fingerprint), RevocationReason: getRevocationReason(keyEntity.Revocations), @@ -170,7 +187,7 @@ func parsePGP(r io.Reader) (PGPKeyInfo, error) { SubKeys: subKeys, } - keyInfo := PGPKeyInfo{ + keyInfo := KeyInfo{ entity: keyEntity, metadata: metadata, validFrom: keyValidFrom, @@ -181,20 +198,20 @@ func parsePGP(r io.Reader) (PGPKeyInfo, error) { return keyInfo, nil } -type PGPKeyInfo struct { +type KeyInfo struct { // entity holds the original PGP key entity *openpgp.Entity // metadata holds additional key info - metadata PGPEntityMetadata + metadata EntityMetadata validFrom int64 validTo *int64 comment string } -func (key PGPKeyInfo) Matches(s string) bool { - otherKey, err := parsePGP(strings.NewReader(s)) +func (key KeyInfo) Matches(s string) bool { + otherKey, err := Parse(strings.NewReader(s), nil) if err != nil { return false } @@ -212,45 +229,46 @@ func (key PGPKeyInfo) Matches(s string) bool { return slices.Equal(buf1.Bytes(), buf2.Bytes()) } -func (key PGPKeyInfo) Fingerprint() string { +func (key KeyInfo) Fingerprint() string { return key.metadata.PrimaryKey.Fingerprint } -func (key PGPKeyInfo) Type() string { +func (key KeyInfo) Type() string { return pgpAlgo(key.entity.PrimaryKey.PubKeyAlgo) } -func (key PGPKeyInfo) Scheme() enum.PublicKeyScheme { +func (key KeyInfo) Scheme() enum.PublicKeyScheme { return enum.PublicKeySchemePGP } -func (key PGPKeyInfo) Comment() string { +func (key KeyInfo) Comment() string { return key.comment } -func (key PGPKeyInfo) ValidFrom() *int64 { +func (key KeyInfo) ValidFrom() *int64 { return &key.validFrom } -func (key PGPKeyInfo) ValidTo() *int64 { +func (key KeyInfo) ValidTo() *int64 { return key.validTo } -func (key PGPKeyInfo) Identities() []types.Identity { +func (key KeyInfo) Identities() []types.Identity { return key.metadata.Identities } -func (key PGPKeyInfo) RevocationReason() *enum.RevocationReason { +func (key KeyInfo) RevocationReason() *enum.RevocationReason { return key.metadata.PrimaryKey.RevocationReason } -func (key PGPKeyInfo) Metadata() json.RawMessage { +func (key KeyInfo) Metadata() json.RawMessage { data, _ := json.Marshal(key.metadata) return data } -func (key PGPKeyInfo) SubKeyIDs() []string { +func (key KeyInfo) SubKeyIDs() []string { subKeyIDs := make([]string, 0) + subKeyIDs = append(subKeyIDs, key.entity.PrimaryKey.KeyIdString()) for i := range key.entity.Subkeys { if key.entity.Subkeys[i].PublicKey.CanSign() { subKeyIDs = append(subKeyIDs, key.entity.Subkeys[i].PublicKey.KeyIdString()) diff --git a/app/services/publickey/keypgp/verify_pgp.go b/app/services/publickey/keypgp/verify_pgp.go new file mode 100644 index 000000000..b5a1ca153 --- /dev/null +++ b/app/services/publickey/keypgp/verify_pgp.go @@ -0,0 +1,229 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package keypgp + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/harness/gitness/app/store" + "github.com/harness/gitness/git/sha" + "github.com/harness/gitness/types" + "github.com/harness/gitness/types/enum" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors" + "github.com/ProtonMail/go-crypto/openpgp/packet" + "github.com/rs/zerolog/log" +) + +const ( + SignatureType = "PGP SIGNATURE" +) + +type Verify struct { + signature []byte + keyID string + keyFingerprint string +} + +func (v *Verify) Parse( + ctx context.Context, + signature []byte, + objectSHA sha.SHA, +) enum.GitSignatureResult { + block, err := armor.Decode(bytes.NewReader(signature)) + if err != nil || block == nil { + log.Ctx(ctx).Warn(). + Err(err). + Str("object_sha", objectSHA.String()). + Msg("failed to decode signature") + return enum.GitSignatureInvalid + } + if block.Type != openpgp.SignatureType { + log.Ctx(ctx).Warn(). + Str("signature_type", block.Type). + Str("object_sha", objectSHA.String()). + Msg("unexpected PGP signature block type") + return enum.GitSignatureInvalid + } + + reader := packet.NewReader(block.Body) + sig, err := reader.Next() + if err != nil { + log.Ctx(ctx).Warn(). + Err(err). + Str("object_sha", objectSHA.String()). + Msg("failed to read PGP signature") + return enum.GitSignatureInvalid + } + + p, ok := sig.(*packet.Signature) + if !ok { + log.Ctx(ctx).Warn(). + Str("signature_type", fmt.Sprintf("%T", sig)). + Str("object_sha", objectSHA.String()). + Msg("signature type mismatch") + return enum.GitSignatureInvalid + } + + if p.IssuerKeyId == nil { + log.Ctx(ctx).Warn(). + Str("object_sha", objectSHA.String()). + Msg("no public key ID in PGP signature") + return enum.GitSignatureInvalid + } + + v.signature = signature + v.keyID = fmt.Sprintf("%016X", *p.IssuerKeyId) + v.keyFingerprint = fmt.Sprintf("%X", p.IssuerFingerprint) + + return "" +} + +func (v *Verify) Key( + ctx context.Context, + publicKeyStore store.PublicKeyStore, + principalID int64, +) (*types.PublicKey, error) { + schemes := []enum.PublicKeyScheme{enum.PublicKeySchemePGP} + usages := []enum.PublicKeyUsage{enum.PublicKeyUsageSign} + keys, err := publicKeyStore.ListBySubKeyID(ctx, v.KeyID(), &principalID, usages, schemes) + if err != nil { + return nil, fmt.Errorf("failed to list PGP public keys by subkey ID: %w", err) + } + + if len(keys) == 0 { + //nolint:nilnil + return nil, nil // No key is available and there is no error. + } + + return &keys[0], nil +} + +func (v *Verify) Verify( + ctx context.Context, + armoredPublicKey []byte, + signedContent []byte, + objectSHA sha.SHA, + committer types.Signature, +) enum.GitSignatureResult { + keyRingReader := bytes.NewReader(armoredPublicKey) + keyRing, err := openpgp.ReadArmoredKeyRing(keyRingReader) + if err != nil { + log.Ctx(ctx).Warn(). + Err(err). + Str("object_sha", objectSHA.String()). + Msg("failed to read key ring") + return enum.GitSignatureUnverified + } + + // CheckArmoredDetachedSignature returns an error if: + // - The signature (or one of the binding signatures mentioned below) + // has a unknown critical notation data subpacket + // - The primary key of the signing entity is revoked + // - The primary identity is revoked + // - The signature is expired + // - The primary key of the signing entity is expired according to the + // primary identity binding signature + // + // ... or, if the signature was signed by a subkey and: + // - The signing subkey is revoked + // - The signing subkey is expired according to the subkey binding signature + // - The signing subkey binding signature is expired + // - The signing subkey cross-signature is expired + // + // NOTE: The order of these checks is important, as the caller may choose to + // ignore ErrSignatureExpired or ErrKeyExpired errors, but should never + // ignore any other errors. + // NOTE 2: The comment above is copied from the openpgp library. + signer, err := openpgp.CheckArmoredDetachedSignature( + keyRing, + bytes.NewReader(signedContent), + bytes.NewReader(v.signature), + &packet.Config{ + Time: func() time.Time { + return committer.When + }, + }, + ) + // If error happened, try to convert it to one of the enum values. + //nolint:nestif + if err != nil { + var errUnsupported pgperrors.UnsupportedError + if errors.As(err, &errUnsupported); errUnsupported != "" { + return enum.GitSignatureUnsupported + } + + if errors.Is(err, pgperrors.ErrKeyRevoked) { + return enum.GitSignatureRevoked + } + + if errors.Is(err, pgperrors.ErrUnknownIssuer) { + // This shouldn't happen because we fetched the key by ID, + // so we are using the correct key with the correct identity. + return enum.GitSignatureBad + } + + if errors.Is(err, pgperrors.ErrKeyExpired) { + return enum.GitSignatureKeyExpired + } + + if errors.Is(err, pgperrors.ErrSignatureExpired) { + return enum.GitSignatureBad + } + + log.Ctx(ctx).Warn(). + Err(err). + Str("error_type", fmt.Sprintf("%T", err)). + Str("object_sha", objectSHA.String()). + Msg("unrecognized error") + + return enum.GitSignatureInvalid + } + + var signatureIdentity *openpgp.Identity + for _, identity := range signer.Identities { + if strings.EqualFold(committer.Identity.Email, identity.UserId.Email) { + signatureIdentity = identity + } + } + if signatureIdentity == nil { + return enum.GitSignatureBad + } + + if signatureIdentity.Revoked(committer.When) { + return enum.GitSignatureRevoked + } + + return enum.GitSignatureGood +} + +func (v *Verify) KeyScheme() enum.PublicKeyScheme { + return enum.PublicKeySchemePGP +} + +func (v *Verify) KeyID() string { + return v.keyID +} + +func (v *Verify) KeyFingerprint() string { + return v.keyFingerprint +} diff --git a/app/services/publickey/key_ssh.go b/app/services/publickey/keyssh/parse_ssh.go similarity index 67% rename from app/services/publickey/key_ssh.go rename to app/services/publickey/keyssh/parse_ssh.go index 4ec5ff923..b1c628212 100644 --- a/app/services/publickey/key_ssh.go +++ b/app/services/publickey/keyssh/parse_ssh.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package publickey +package keyssh import ( "crypto/sha256" @@ -29,31 +29,31 @@ import ( ) func FromSSH(key gossh.PublicKey) KeyInfo { - return SSHKeyInfo{ + return KeyInfo{ PublicKey: key, KeyComment: "", } } -func parseSSH(keyData []byte) (SSHKeyInfo, error) { +func Parse(keyData []byte) (KeyInfo, error) { publicKey, comment, _, _, err := gossh.ParseAuthorizedKey(keyData) if err != nil { - return SSHKeyInfo{}, errors.InvalidArgument("invalid SSH key data: %s" + err.Error()) + return KeyInfo{}, errors.InvalidArgument("invalid SSH key data: %s" + err.Error()) } keyType := publicKey.Type() // explicitly disallowed if slices.Contains(DisallowedTypes, keyType) { - return SSHKeyInfo{}, errors.InvalidArgument("keys of type %s are not allowed", keyType) + return KeyInfo{}, errors.InvalidArgument("keys of type %s are not allowed", keyType) } // only allowed if !slices.Contains(AllowedTypes, keyType) { - return SSHKeyInfo{}, errors.InvalidArgument("allowed key types are %v", AllowedTypes) + return KeyInfo{}, errors.InvalidArgument("allowed key types are %v", AllowedTypes) } - return SSHKeyInfo{ + return KeyInfo{ PublicKey: publicKey, KeyComment: comment, }, nil @@ -73,12 +73,12 @@ var DisallowedTypes = []string{ gossh.KeyAlgoDSA, } -type SSHKeyInfo struct { +type KeyInfo struct { PublicKey gossh.PublicKey KeyComment string } -func (key SSHKeyInfo) Matches(s string) bool { +func (key KeyInfo) Matches(s string) bool { otherKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(s)) if err != nil { return false @@ -87,48 +87,48 @@ func (key SSHKeyInfo) Matches(s string) bool { return key.matchesKey(otherKey) } -func (key SSHKeyInfo) matchesKey(otherKey gossh.PublicKey) bool { +func (key KeyInfo) matchesKey(otherKey gossh.PublicKey) bool { return ssh.KeysEqual(key.PublicKey, otherKey) } -func (key SSHKeyInfo) Fingerprint() string { +func (key KeyInfo) Fingerprint() string { sum := sha256.New() sum.Write(key.PublicKey.Marshal()) return "SHA256:" + base64.RawStdEncoding.EncodeToString(sum.Sum(nil)) } -func (key SSHKeyInfo) Type() string { +func (key KeyInfo) Type() string { return key.PublicKey.Type() } -func (key SSHKeyInfo) Scheme() enum.PublicKeyScheme { +func (key KeyInfo) Scheme() enum.PublicKeyScheme { return enum.PublicKeySchemeSSH } -func (key SSHKeyInfo) Comment() string { +func (key KeyInfo) Comment() string { return key.KeyComment } -func (key SSHKeyInfo) ValidFrom() *int64 { +func (key KeyInfo) ValidFrom() *int64 { return nil // SSH keys do not have validity period } -func (key SSHKeyInfo) ValidTo() *int64 { +func (key KeyInfo) ValidTo() *int64 { return nil // SSH keys do not have validity period } -func (key SSHKeyInfo) Identities() []types.Identity { +func (key KeyInfo) Identities() []types.Identity { return nil // SSH keys do not have identities } -func (key SSHKeyInfo) RevocationReason() *enum.RevocationReason { - return nil +func (key KeyInfo) RevocationReason() *enum.RevocationReason { + return nil // SSH keys do not have revocations } -func (key SSHKeyInfo) Metadata() json.RawMessage { +func (key KeyInfo) Metadata() json.RawMessage { return json.RawMessage("{}") } -func (key SSHKeyInfo) SubKeyIDs() []string { - return nil +func (key KeyInfo) SubKeyIDs() []string { + return nil // SSH keys do not have subkeys } diff --git a/app/services/publickey/keyssh/verify_ssh.go b/app/services/publickey/keyssh/verify_ssh.go new file mode 100644 index 000000000..ca8a215c5 --- /dev/null +++ b/app/services/publickey/keyssh/verify_ssh.go @@ -0,0 +1,256 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package keyssh + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/sha512" + "encoding/pem" + "fmt" + "hash" + + "github.com/harness/gitness/app/store" + "github.com/harness/gitness/git/sha" + "github.com/harness/gitness/types" + "github.com/harness/gitness/types/enum" + + "github.com/rs/zerolog/log" + "golang.org/x/crypto/ssh" +) + +const SignatureType = "SSH SIGNATURE" + +// signatureBlob describes a lightweight SSH signature format. +// https://github.com/openssh/openssh-portable/blob/V_9_9_P2/PROTOCOL.sshsig#L34 +type signatureBlob struct { + MagicPreamble [6]byte + Version uint32 + PublicKey []byte + Namespace string + Reserved string + HashAlgorithm string + Signature []byte +} + +// messageWrapper represents SSH signed data. +// https://github.com/openssh/openssh-portable/blob/V_9_9_P2/PROTOCOL.sshsig#L81 +type messageWrapper struct { + Namespace string + Reserved string + HashAlgorithm string + Hash []byte +} + +// hashFunc returns hash function used for SSH signature verification. +// Data to be signed is first hashed with the specified hash_algorithm. +// This is done to limit the amount of data presented to the signature +// operation, which may be of concern if the signing key is held in limited +// or slow hardware or on a remote ssh-agent. The supported hash algorithms +// are "sha256" and "sha512". +// https://github.com/openssh/openssh-portable/blob/V_9_9_P2/PROTOCOL.sshsig#L63 +func hashFunc(hashAlgorithm string) hash.Hash { + switch hashAlgorithm { + case "sha256": + return sha256.New() + case "sha512": + return sha512.New() + } + + return nil +} + +const ( + sshMagicPreamble = "SSHSIG" + sshNamespace = "git" +) + +type Verify struct { + hashAlgorithm string + signatureBytes []byte + publicKey []byte + keyFingerprint string +} + +// Parse parses the provided ASCII-armored signature and returns fingerprint of the key used to sign it. +// Also, it updates the internal object fields required for key validation. +func (v *Verify) Parse( + ctx context.Context, + signature []byte, + objectSHA sha.SHA, +) enum.GitSignatureResult { + block, _ := pem.Decode(signature) + if block == nil { + log.Ctx(ctx).Warn(). + Str("object_sha", objectSHA.String()). + Msg("failed to decode signature") + return enum.GitSignatureInvalid + } + if block.Type != SignatureType { + log.Ctx(ctx).Warn(). + Str("signature_type", block.Type). + Str("object_sha", objectSHA.String()). + Msg("unexpected SSH signature block type") + return enum.GitSignatureInvalid + } + + var blob signatureBlob + if err := ssh.Unmarshal(block.Bytes, &blob); err != nil { + log.Ctx(ctx).Warn(). + Err(err). + Str("object_sha", objectSHA.String()). + Msg("failed to unmarshal SSH signature") + return enum.GitSignatureInvalid + } + + // The preamble is the six-byte sequence "SSHSIG". It is included to + // ensure that manual signatures can never be confused with any message + // signed during SSH user or host authentication. + // https://github.com/openssh/openssh-portable/blob/V_9_9_P2/PROTOCOL.sshsig#L89 + if !bytes.Equal(blob.MagicPreamble[:], []byte(sshMagicPreamble)) { + log.Ctx(ctx).Warn(). + Str("object_sha", objectSHA.String()). + Msg("invalid SSH signature magic preamble") + return enum.GitSignatureInvalid + } + + // Verifiers MUST reject signatures with versions greater than those they support. + // https://github.com/openssh/openssh-portable/blob/V_9_9_P2/PROTOCOL.sshsig#L50 + if blob.Version > 1 { + return enum.GitSignatureUnsupported + } + + // The purpose of the namespace value is to specify a unambiguous + // interpretation domain for the signature, e.g. file signing. + // This prevents cross-protocol attacks caused by signatures + // intended for one intended domain being accepted in another. + // https://github.com/openssh/openssh-portable/blob/V_9_9_P2/PROTOCOL.sshsig#L53 + if blob.Namespace != sshNamespace { + log.Ctx(ctx).Warn(). + Str("namespace", blob.Namespace). + Str("object_sha", objectSHA.String()). + Msg("SSH signature namespace mismatch") + return enum.GitSignatureInvalid + } + + publicKey, err := ssh.ParsePublicKey(blob.PublicKey) + if err != nil { + log.Ctx(ctx).Warn(). + Err(err). + Str("object_sha", objectSHA.String()). + Msg("invalid SSH signature public key") + return enum.GitSignatureInvalid + } + + v.hashAlgorithm = blob.HashAlgorithm + v.signatureBytes = blob.Signature + v.publicKey = blob.PublicKey + v.keyFingerprint = ssh.FingerprintSHA256(publicKey) + + return "" +} + +func (v *Verify) Key( + ctx context.Context, + publicKeyStore store.PublicKeyStore, + principalID int64, +) (*types.PublicKey, error) { + schemes := []enum.PublicKeyScheme{enum.PublicKeySchemeSSH} + usages := []enum.PublicKeyUsage{enum.PublicKeyUsageSign} + keys, err := publicKeyStore.ListByFingerprint(ctx, v.KeyFingerprint(), &principalID, usages, schemes) + if err != nil { + return nil, fmt.Errorf("failed to list SSH public keys by fingerprint: %w", err) + } + + if len(keys) == 0 { + //nolint:nilnil + return nil, nil // No key is available and there is no error. + } + + return &keys[0], nil +} + +func (v *Verify) Verify( + ctx context.Context, + publicKeyRaw []byte, + signedContent []byte, + objectSHA sha.SHA, + _ types.Signature, +) enum.GitSignatureResult { + publicKey, _, _, _, err := ssh.ParseAuthorizedKey(publicKeyRaw) + if err != nil { + log.Ctx(ctx).Warn(). + Err(err). + Str("object_sha", objectSHA.String()). + Msg("failed to parse SSH key") + return enum.GitSignatureUnverified + } + + hashAlgorithm := v.hashAlgorithm + signatureBytes := v.signatureBytes + + h := hashFunc(hashAlgorithm) + if h == nil { + log.Ctx(ctx).Warn(). + Str("hash_algorithm", v.hashAlgorithm). + Str("object_sha", objectSHA.String()). + Msg("unrecognized SSH signature algorithm") + return enum.GitSignatureInvalid + } + h.Write(signedContent) + + hashSum := h.Sum(nil) + + sig := ssh.Signature{} + if err := ssh.Unmarshal(signatureBytes, &sig); err != nil { + log.Ctx(ctx).Warn(). + Err(err). + Str("object_sha", objectSHA.String()). + Msg("failed to unmarshal SSH signature") + return enum.GitSignatureInvalid + } + + signedMessage := ssh.Marshal(messageWrapper{ + Namespace: sshNamespace, + HashAlgorithm: hashAlgorithm, + Hash: hashSum, + }) + buf := bytes.NewBuffer(nil) + _, _ = buf.WriteString(sshMagicPreamble) + _, _ = buf.Write(signedMessage) + err = publicKey.Verify(buf.Bytes(), &sig) + if err != nil { + return enum.GitSignatureBad + } + + return enum.GitSignatureGood +} + +func (v *Verify) KeyScheme() enum.PublicKeyScheme { + return enum.PublicKeySchemeSSH +} + +func (v *Verify) KeyID() string { + return "" +} + +func (v *Verify) KeyFingerprint() string { + return v.keyFingerprint +} + +func (v *Verify) SignaturePublicKey() []byte { + return v.publicKey +} diff --git a/app/services/publickey/parse.go b/app/services/publickey/parse.go index 262a4979a..b092bd9f9 100644 --- a/app/services/publickey/parse.go +++ b/app/services/publickey/parse.go @@ -19,6 +19,8 @@ import ( "fmt" "strings" + "github.com/harness/gitness/app/services/publickey/keypgp" + "github.com/harness/gitness/app/services/publickey/keyssh" "github.com/harness/gitness/errors" "github.com/harness/gitness/types" "github.com/harness/gitness/types/enum" @@ -42,7 +44,7 @@ type KeyInfo interface { SubKeyIDs() []string } -func ParseString(keyData string) (KeyInfo, error) { +func ParseString(keyData string, principal *types.Principal) (KeyInfo, error) { if len(keyData) == 0 { return nil, errors.InvalidArgument("empty key") } @@ -51,7 +53,7 @@ func ParseString(keyData string) (KeyInfo, error) { const pgpFooter = "-----END PGP PUBLIC KEY BLOCK-----" if strings.HasPrefix(keyData, pgpHeader) && strings.HasSuffix(keyData, pgpFooter) { - key, err := parsePGP(strings.NewReader(keyData)) + key, err := keypgp.Parse(strings.NewReader(keyData), principal) if err != nil { return nil, fmt.Errorf("failed to parse PGP key: %w", err) } @@ -59,7 +61,7 @@ func ParseString(keyData string) (KeyInfo, error) { return key, nil } - key, err := parseSSH([]byte(keyData)) + key, err := keyssh.Parse([]byte(keyData)) if err != nil { return nil, fmt.Errorf("failed to parse SSH key: %w", err) } diff --git a/app/services/publickey/service_ssh.go b/app/services/publickey/service_ssh.go index c546fff2a..5aa1d986d 100644 --- a/app/services/publickey/service_ssh.go +++ b/app/services/publickey/service_ssh.go @@ -19,6 +19,7 @@ import ( "fmt" "time" + "github.com/harness/gitness/app/services/publickey/keyssh" "github.com/harness/gitness/app/store" "github.com/harness/gitness/errors" "github.com/harness/gitness/types" @@ -56,7 +57,7 @@ func (s sshAuthService) ValidateKey( _ string, publicKey ssh.PublicKey, ) (*types.PrincipalInfo, error) { - key := FromSSH(publicKey) + key := keyssh.FromSSH(publicKey) fingerprint := key.Fingerprint() existingKeys, err := s.publicKeyStore.ListByFingerprint( diff --git a/app/services/publickey/service_verify.go b/app/services/publickey/service_verify.go new file mode 100644 index 000000000..57bff88ca --- /dev/null +++ b/app/services/publickey/service_verify.go @@ -0,0 +1,407 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package publickey + +import ( + "context" + "errors" + "fmt" + "maps" + "slices" + "time" + + "github.com/harness/gitness/app/services/publickey/keypgp" + "github.com/harness/gitness/app/services/publickey/keyssh" + "github.com/harness/gitness/app/store" + "github.com/harness/gitness/git/sha" + gitness_store "github.com/harness/gitness/store" + "github.com/harness/gitness/types" + "github.com/harness/gitness/types/enum" + + "github.com/rs/zerolog/log" +) + +type SignatureVerifyService struct { + principalStore store.PrincipalStore + publicKeyStore store.PublicKeyStore + publicKeySubKeyStore store.PublicKeySubKeyStore + gitSignatureResultStore store.GitSignatureResultStore +} + +func NewSignatureVerifyService( + principalStore store.PrincipalStore, + publicKeyStore store.PublicKeyStore, + publicKeySubKeyStore store.PublicKeySubKeyStore, + gitSignatureResultStore store.GitSignatureResultStore, +) SignatureVerifyService { + return SignatureVerifyService{ + principalStore: principalStore, + publicKeyStore: publicKeyStore, + publicKeySubKeyStore: publicKeySubKeyStore, + gitSignatureResultStore: gitSignatureResultStore, + } +} + +// NewVerifySession creates a new session for git object signature verification. +// The session holds a small cache for users and signing keys. +func (s SignatureVerifyService) NewVerifySession(repoID int64) *VerifySession { + return &VerifySession{ + SignatureVerifyService: s, + repoID: repoID, + principalIDCache: make(map[string]int64), + keyCache: make(map[personalKey]*types.PublicKey), + } +} + +func (s SignatureVerifyService) VerifyCommitTags(ctx context.Context, repoID int64, tags []*types.CommitTag) error { + session := s.NewVerifySession(repoID) + if err := verifyObjects(ctx, session, tags); err != nil { + return err + } + + session.StoreSignatures(ctx) + + return nil +} + +func (s SignatureVerifyService) VerifyCommits(ctx context.Context, repoID int64, commits []*types.Commit) error { + session := s.NewVerifySession(repoID) + if err := verifyObjects(ctx, session, commits); err != nil { + return err + } + + session.StoreSignatures(ctx) + + return nil +} + +func (s *VerifySession) VerifyCommitTags(ctx context.Context, tags []*types.CommitTag) error { + return verifyObjects(ctx, s, tags) +} + +func (s *VerifySession) VerifyCommits(ctx context.Context, commits []*types.Commit) error { + return verifyObjects(ctx, s, commits) +} + +// VerifySession holds short time caches for a single iteration of verifyObject function. +type VerifySession struct { + SignatureVerifyService + repoID int64 + + // principalIDCache is cache of principal IDs. The key is email address. + principalIDCache map[string]int64 + + // keyCache is cache of personal keys. The map key holds principalID, key ID and fingerprint. + keyCache map[personalKey]*types.PublicKey + + // sigResults are git signature verification results that should be stored to the database. + sigResults []*types.GitSignatureResult +} + +// personalKey is cache key for the cache of public keys. +type personalKey struct { + principalID int64 + keyID string + keyFingerprint string +} + +func (s *VerifySession) principalByEmail(ctx context.Context, email string) (int64, error) { + if principalID, ok := s.principalIDCache[email]; ok { + return principalID, nil + } + + principal, err := s.principalStore.FindByEmail(ctx, email) + if err != nil { + if errors.Is(err, gitness_store.ErrResourceNotFound) { + s.principalIDCache[email] = 0 + return 0, nil + } + + return 0, err + } + + s.principalIDCache[email] = principal.ID + + return principal.ID, nil +} + +func (s *VerifySession) fetchKey( + ctx context.Context, + v verifier, + k personalKey, + principalID int64, +) (*types.PublicKey, error) { + key, ok := s.keyCache[k] + if ok { + return key, nil + } + + key, err := v.Key(ctx, s.publicKeyStore, principalID) + if err != nil { + return nil, fmt.Errorf("failed to get public key from verifier: %w", err) + } + + s.keyCache[k] = key // We also store nils here to avoid searching for a non-existing key multiple times. + + return key, nil +} + +func (s *VerifySession) StoreSignatures(ctx context.Context) { + err := s.gitSignatureResultStore.TryCreateAll(ctx, s.sigResults) + if err != nil { + log.Ctx(ctx).Warn().Err(err). + Msg("failed to create git signature results") + } +} + +func verifyObjects[T signedObject](ctx context.Context, session *VerifySession, objects []T) error { + // Fill objects' signature data from the DB, + // and get a map of objects without signature data in the DB. + objectMap, err := fillSignatureFromDB(ctx, &session.SignatureVerifyService, session.repoID, objects) + if err != nil { + return fmt.Errorf("failed to backfill object signature info from the DB: %w", err) + } + + for _, object := range objectMap { + sigResult, err := verifyGitObjectSignature(ctx, session, object) + if err != nil { + return fmt.Errorf("failed to verify object signature: %w", err) + } + + object.SetSignature(sigResult) + + // These we don't store to the database: Invalid, Unsupported and Unverified. + // An invalid signature can mean not just that the signature contains garbage data, but also that + // we failed to verify it because of a bug. So, we deliberately don't store them to the DB. + if result := sigResult.Result; result == enum.GitSignatureInvalid || + result == enum.GitSignatureUnsupported || + result == enum.GitSignatureUnverified { + continue + } + + session.sigResults = append(session.sigResults, sigResult) + } + + return nil +} + +// fillSignatureFromDB reads git object signatures from the DB, +// updates the elements of the provided slice, +// and return a map of objects that do not yet have a signature in the DB. +func fillSignatureFromDB[T signedObject]( + ctx context.Context, + s *SignatureVerifyService, + repoID int64, + objects []T, +) (map[sha.SHA]T, error) { + objectMap := make(map[sha.SHA]T) + for i := range objects { + if objects[i].GetSignedData() != nil { + objectMap[objects[i].GetSHA()] = objects[i] + } + } + + if len(objectMap) == 0 { + return objectMap, nil + } + + // Get slice of SHAs from the map. + objectSHAs := slices.AppendSeq[[]sha.SHA](make([]sha.SHA, 0, len(objectMap)), maps.Keys(objectMap)) + + // Read signature data from the tags from the DB. + objectSignatureMap, err := s.gitSignatureResultStore.Map(ctx, repoID, objectSHAs) + if err != nil { + return nil, fmt.Errorf("failed to read commit signatures: %w", err) + } + + // Update the objects found in the database and remove them from the map. + for objectSHA, objectSignature := range objectSignatureMap { + object := objectMap[objectSHA] + object.SetSignature(&objectSignature) + + delete(objectMap, objectSHA) + } + + return objectMap, nil +} + +func verifyGitObjectSignature[T signedObject]( + ctx context.Context, + s *VerifySession, + object T, +) (*types.GitSignatureResult, error) { + signedData := object.GetSignedData() + if signedData == nil { + return &sigVerUnverified, nil + } + + var v verifier + + switch signedData.Type { + case keyssh.SignatureType: + v = &keyssh.Verify{} + case keypgp.SignatureType: + v = &keypgp.Verify{} + default: + return &sigVerUnsupported, nil // We mark unsupported signature types as unsupported. + } + + // Get the object's signer email address - the committer for commits, the tagger for annotated tags. + + signer := object.GetSigner() + if signer == nil { + return &sigVerUnverified, nil + } + + objectTime := signer.When.UnixMilli() + email := signer.Identity.Email + + // Find the principal by the signer's email address. + // If principal is not found the signature is unverified. + + principalID, err := s.principalByEmail(ctx, email) + if err != nil { + return nil, fmt.Errorf("failed to find principal by email: %w", err) + } + + if principalID == 0 { + return &sigVerUnverified, nil + } + + // Find the key info from the signature. + + if result := v.Parse(ctx, object.GetSignedData().Signature, object.GetSHA()); result != "" { + //nolint:exhaustive + switch result { + case enum.GitSignatureInvalid: + return &sigVerInvalid, nil + case enum.GitSignatureUnsupported: + return &sigVerUnsupported, nil + default: + // Should not happen. + return nil, fmt.Errorf("unexpected signature verification result=%q after signature parsing", result) + } + } + + // Fetch the key from the DB. If it's not there, the signature is unverified. + + key, err := s.fetchKey(ctx, v, personalKey{ + principalID: principalID, + keyID: v.KeyID(), + keyFingerprint: v.KeyFingerprint(), + }, principalID) + if err != nil { + return nil, fmt.Errorf("failed to get public key: %w", err) + } + if key == nil { + return &sigVerUnverified, nil + } + + now := time.Now().UnixMilli() + sigResult := &types.GitSignatureResult{ + RepoID: s.repoID, + ObjectSHA: object.GetSHA(), + ObjectTime: objectTime, + Created: now, + Updated: now, + Result: "", // the result will be set later + PrincipalID: principalID, + KeyScheme: v.KeyScheme(), + KeyID: v.KeyID(), + KeyFingerprint: v.KeyFingerprint(), + } + + // Using the key's properties, if possible override the verification result. + + switch { + case key.RevocationReason != nil: + sigResult.Result = enum.GitSignatureRevoked + return sigResult, nil + case key.ValidFrom != nil && objectTime < *key.ValidFrom: + sigResult.Result = enum.GitSignatureKeyExpired + return sigResult, nil + case key.ValidTo != nil && objectTime > *key.ValidTo: + sigResult.Result = enum.GitSignatureKeyExpired + return sigResult, nil + } + + // Verify the git object signature using the key from the database. + + sigResult.Result = v.Verify( + ctx, + []byte(key.Content), + object.GetSignedData().SignedContent, + object.GetSHA(), + *signer) + + return sigResult, nil +} + +// verifier is interface to verify a git object signature. +// It's implemented by keypgp.Verify and keyssh.Verify. +type verifier interface { + // Parse parses the provided signature and extracts info about the signing key (ID/Fingerprint). + Parse( + ctx context.Context, + signature []byte, + objectSHA sha.SHA, + ) enum.GitSignatureResult + + // Key fetches the key from the DB. + Key( + ctx context.Context, + publicKeyStore store.PublicKeyStore, + principalID int64, + ) (*types.PublicKey, error) + + // Verify checks if the signed content matches signature. + Verify( + ctx context.Context, + key []byte, + signedContent []byte, + objectSHA sha.SHA, + committer types.Signature, + ) enum.GitSignatureResult + + // KeyScheme returns the signing key's scheme. + KeyScheme() enum.PublicKeyScheme + + // KeyID returns the signing key ID. Use after a call to the Parse method + KeyID() string + + // KeyFingerprint returns the signing key fingerprint. Use after a call to the Parse method + KeyFingerprint() string +} + +// signedObject is interface used to verify signature. +// It's implemented by types.Commit and types.CommitTag. +type signedObject interface { + GetSHA() sha.SHA + SetSignature(sig *types.GitSignatureResult) + GetSigner() *types.Signature + GetSignedData() *types.SignedData +} + +var sigVerUnverified = types.GitSignatureResult{ + Result: enum.GitSignatureUnverified, +} + +var sigVerUnsupported = types.GitSignatureResult{ + Result: enum.GitSignatureUnsupported, +} + +var sigVerInvalid = types.GitSignatureResult{ + Result: enum.GitSignatureInvalid, +} diff --git a/app/services/publickey/verify_ssh.go b/app/services/publickey/verify_ssh.go deleted file mode 100644 index 5b57c9aaa..000000000 --- a/app/services/publickey/verify_ssh.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2023 Harness, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package publickey - -import ( - "crypto/sha256" - "crypto/sha512" - "fmt" - "hash" -) - -// SSHSignatureBlob describes a lightweight SSH signature format. -// https://github.com/openssh/openssh-portable/blob/V_9_9_P2/PROTOCOL.sshsig#L34 -type SSHSignatureBlob struct { - MagicPreamble [6]byte - Version uint32 - PublicKey []byte - Namespace string - Reserved string - HashAlgorithm string - Signature []byte -} - -// SSHMessageWrapper represents SSH signed data. -// https://github.com/openssh/openssh-portable/blob/V_9_9_P2/PROTOCOL.sshsig#L81 -type SSHMessageWrapper struct { - Namespace string - Reserved string - HashAlgorithm string - Hash []byte -} - -func SSHHash(hashAlgorithm string) (hash.Hash, error) { - switch hashAlgorithm { - case "sha256": - return sha256.New(), nil - case "sha512": - return sha512.New(), nil - default: - return nil, fmt.Errorf("unsupported hash algorithm: %s", hashAlgorithm) - } -} diff --git a/app/services/publickey/wire.go b/app/services/publickey/wire.go index 371c6f178..98dca108c 100644 --- a/app/services/publickey/wire.go +++ b/app/services/publickey/wire.go @@ -22,6 +22,7 @@ import ( var WireSet = wire.NewSet( ProvideSSHAuthService, + ProvideSignatureVerifyService, ) func ProvideSSHAuthService( @@ -30,3 +31,16 @@ func ProvideSSHAuthService( ) SSHAuthService { return NewSSHAuthService(publicKeyStore, pCache) } + +func ProvideSignatureVerifyService( + principalStore store.PrincipalStore, + publicKeyStore store.PublicKeyStore, + publicKeySubKeyStore store.PublicKeySubKeyStore, + gitSignatureResultStore store.GitSignatureResultStore, +) SignatureVerifyService { + return NewSignatureVerifyService( + principalStore, + publicKeyStore, + publicKeySubKeyStore, + gitSignatureResultStore) +} diff --git a/app/store/database.go b/app/store/database.go index 749b5e3be..0b919f60b 100644 --- a/app/store/database.go +++ b/app/store/database.go @@ -1141,6 +1141,9 @@ type ( // Create creates a new public key. Create(ctx context.Context, publicKey *types.PublicKey) error + // Update updates a public key. + Update(ctx context.Context, publicKey *types.PublicKey) error + // DeleteByIdentifier deletes a public key. DeleteByIdentifier(ctx context.Context, principalID int64, identifier string) error @@ -1174,6 +1177,25 @@ type ( PublicKeySubKeyStore interface { Create(ctx context.Context, publicKeyID int64, subKeyIDs []string) error + List(ctx context.Context, publicKeyID int64) ([]string, error) + } + + GitSignatureResultStore interface { + Map( + ctx context.Context, + repoID int64, + objectSHAs []sha.SHA, + ) (map[sha.SHA]types.GitSignatureResult, error) + + Create(ctx context.Context, sigResult types.GitSignatureResult) error + TryCreateAll(ctx context.Context, sigResult []*types.GitSignatureResult) error + + UpdateAll( + ctx context.Context, + result enum.GitSignatureResult, + principalID int64, + keyIDs, keyFingerprints []string, + ) error } GitspaceEventStore interface { diff --git a/app/store/database/git_signature_result.go b/app/store/database/git_signature_result.go new file mode 100644 index 000000000..0534d29e5 --- /dev/null +++ b/app/store/database/git_signature_result.go @@ -0,0 +1,239 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package database + +import ( + "context" + "fmt" + "time" + + "github.com/harness/gitness/app/store" + "github.com/harness/gitness/git/sha" + "github.com/harness/gitness/store/database" + "github.com/harness/gitness/store/database/dbtx" + "github.com/harness/gitness/types" + "github.com/harness/gitness/types/enum" + + "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" +) + +var _ store.GitSignatureResultStore = GitSignatureResultStore{} + +// NewGitSignatureResultStore returns a new GitSignatureResultStore. +func NewGitSignatureResultStore(db *sqlx.DB) GitSignatureResultStore { + return GitSignatureResultStore{ + db: db, + } +} + +// GitSignatureResultStore implements a store.GitSignatureResultStore backed by a relational database. +type GitSignatureResultStore struct { + db *sqlx.DB +} + +const ( + gitSignatureResultColumns = ` + git_signature_result_repo_id + ,git_signature_result_object_sha + ,git_signature_result_object_time + ,git_signature_result_created + ,git_signature_result_updated + ,git_signature_result_result + ,git_signature_result_principal_id + ,git_signature_result_key_scheme + ,git_signature_result_key_id + ,git_signature_result_key_fingerprint` + + gitSignatureResultInsertQuery = ` + INSERT INTO git_signature_results (` + gitSignatureResultColumns + ` + ) values ( + :git_signature_result_repo_id + ,:git_signature_result_object_sha + ,:git_signature_result_object_time + ,:git_signature_result_created + ,:git_signature_result_updated + ,:git_signature_result_result + ,:git_signature_result_principal_id + ,:git_signature_result_key_scheme + ,:git_signature_result_key_id + ,:git_signature_result_key_fingerprint + )` +) + +type gitSignatureResult struct { + RepoID int64 `db:"git_signature_result_repo_id"` + ObjectSHA string `db:"git_signature_result_object_sha"` + ObjectTime int64 `db:"git_signature_result_object_time"` + Created int64 `db:"git_signature_result_created"` + Updated int64 `db:"git_signature_result_updated"` + Result string `db:"git_signature_result_result"` + PrincipalID int64 `db:"git_signature_result_principal_id"` + KeyScheme string `db:"git_signature_result_key_scheme"` + KeyID string `db:"git_signature_result_key_id"` + KeyFingerprint string `db:"git_signature_result_key_fingerprint"` +} + +func (s GitSignatureResultStore) Map( + ctx context.Context, + repoID int64, + objectSHAs []sha.SHA, +) (map[sha.SHA]types.GitSignatureResult, error) { + stmt := database.Builder. + Select(gitSignatureResultColumns). + From("git_signature_results"). + Where("git_signature_result_repo_id = ?", repoID). + Where(squirrel.Eq{"git_signature_result_object_sha": objectSHAs}) + + sql, args, err := stmt.ToSql() + if err != nil { + return nil, fmt.Errorf("failed to convert query to sql: %w", err) + } + + db := dbtx.GetAccessor(ctx, s.db) + + sigVers := make([]gitSignatureResult, 0) + if err = db.SelectContext(ctx, &sigVers, sql, args...); err != nil { + return nil, database.ProcessSQLErrorf(ctx, err, + "failed to execute list git signature verification results query") + } + + sigVerMap := map[sha.SHA]types.GitSignatureResult{} + for _, sigVer := range sigVers { + o := mapToGitSignatureResult(sigVer) + sigVerMap[o.ObjectSHA] = o + } + + return sigVerMap, nil +} + +func (s GitSignatureResultStore) Create( + ctx context.Context, + sigResult types.GitSignatureResult, +) error { + db := dbtx.GetAccessor(ctx, s.db) + + sigResultInternal := mapToInternalGitSignatureResult(sigResult) + + query, arg, err := db.BindNamed(gitSignatureResultInsertQuery, &sigResultInternal) + if err != nil { + return database.ProcessSQLErrorf(ctx, err, + "Failed to bind git signature verification result object") + } + + if _, err = db.ExecContext(ctx, query, arg...); err != nil { + return database.ProcessSQLErrorf(ctx, err, + "Insert git signature verification result query failed") + } + + return nil +} + +func (s GitSignatureResultStore) TryCreateAll( + ctx context.Context, + sigResults []*types.GitSignatureResult, +) error { + if len(sigResults) == 0 { + return nil + } + + db := dbtx.GetAccessor(ctx, s.db) + + const sql = gitSignatureResultInsertQuery + ` + ON CONFLICT DO NOTHING` + + stmt, err := db.PrepareNamedContext(ctx, sql) + if err != nil { + return database.ProcessSQLErrorf(ctx, err, + "Failed to prepare git signature verification result statement") + } + + defer stmt.Close() + + for _, sigResult := range sigResults { + _, err = stmt.Exec(mapToInternalGitSignatureResult(*sigResult)) + if err != nil { + return database.ProcessSQLErrorf(ctx, err, + "Failed to insert git signature verification result") + } + } + + return nil +} + +func (s GitSignatureResultStore) UpdateAll( + ctx context.Context, + result enum.GitSignatureResult, + principalID int64, + keyIDs, keyFingerprints []string, +) error { + query := database.Builder. + Update("git_signature_results"). + Set("git_signature_result_result", result). + Set("git_signature_result_updated", time.Now().UnixMilli()). + Where("git_signature_result_principal_id = ?", principalID) + + if len(keyIDs) > 0 { + query = query.Where(squirrel.Eq{"git_signature_result_key_id": keyIDs}) + } + if len(keyFingerprints) > 0 { + query = query.Where(squirrel.Eq{"git_signature_result_key_fingerprint": keyFingerprints}) + } + + sql, args, err := query.ToSql() + if err != nil { + return fmt.Errorf("failed to convert query to sql: %w", err) + } + + db := dbtx.GetAccessor(ctx, s.db) + + _, err = db.ExecContext(ctx, sql, args...) + if err != nil { + return database.ProcessSQLErrorf(ctx, err, "failed to update git signatures") + } + + return nil +} + +func mapToInternalGitSignatureResult(sigVer types.GitSignatureResult) gitSignatureResult { + return gitSignatureResult{ + RepoID: sigVer.RepoID, + ObjectSHA: sigVer.ObjectSHA.String(), + ObjectTime: sigVer.ObjectTime, + Created: sigVer.Created, + Updated: sigVer.Updated, + Result: string(sigVer.Result), + PrincipalID: sigVer.PrincipalID, + KeyScheme: string(sigVer.KeyScheme), + KeyID: sigVer.KeyID, + KeyFingerprint: sigVer.KeyFingerprint, + } +} + +func mapToGitSignatureResult(sigVer gitSignatureResult) types.GitSignatureResult { + objectSHA, _ := sha.New(sigVer.ObjectSHA) + return types.GitSignatureResult{ + RepoID: sigVer.RepoID, + ObjectSHA: objectSHA, + ObjectTime: sigVer.ObjectTime, + Created: sigVer.Created, + Updated: sigVer.Updated, + Result: enum.GitSignatureResult(sigVer.Result), + PrincipalID: sigVer.PrincipalID, + KeyScheme: enum.PublicKeyScheme(sigVer.KeyScheme), + KeyID: sigVer.KeyID, + KeyFingerprint: sigVer.KeyFingerprint, + } +} diff --git a/app/store/database/membership.go b/app/store/database/membership.go index c876e84b0..d90b533db 100644 --- a/app/store/database/membership.go +++ b/app/store/database/membership.go @@ -474,7 +474,7 @@ func (s *MembershipStore) mapToMembershipSpaces(ctx context.Context, res[i].Membership = mapToMembership(&m.membership) space, err := mapToSpace(ctx, s.db, s.spacePathStore, &m.space) if err != nil { - return nil, fmt.Errorf("faild to map space %d: %w", m.space.ID, err) + return nil, fmt.Errorf("failed to map space %d: %w", m.space.ID, err) } res[i].Space = *space if addedBy, ok := infoMap[m.membership.CreatedBy]; ok { diff --git a/app/store/database/migrate/postgres/0122_create_table_git_signature_results.down.sql b/app/store/database/migrate/postgres/0122_create_table_git_signature_results.down.sql new file mode 100644 index 000000000..3dae8c3d0 --- /dev/null +++ b/app/store/database/migrate/postgres/0122_create_table_git_signature_results.down.sql @@ -0,0 +1,2 @@ +DROP INDEX idx_git_signature_results_principal_id; +DROP TABLE git_signature_results; diff --git a/app/store/database/migrate/postgres/0122_create_table_git_signature_results.up.sql b/app/store/database/migrate/postgres/0122_create_table_git_signature_results.up.sql new file mode 100644 index 000000000..ebf25f809 --- /dev/null +++ b/app/store/database/migrate/postgres/0122_create_table_git_signature_results.up.sql @@ -0,0 +1,27 @@ +CREATE TABLE git_signature_results ( + git_signature_result_repo_id INTEGER NOT NULL, + git_signature_result_object_sha TEXT NOT NULL, + git_signature_result_object_time BIGINT NOT NULL, + git_signature_result_created BIGINT NOT NULL, + git_signature_result_updated BIGINT NOT NULL, + git_signature_result_result TEXT NOT NULL, + git_signature_result_principal_id INTEGER NOT NULL, + git_signature_result_key_scheme TEXT NOT NULL, + git_signature_result_key_id TEXT NOT NULL, + git_signature_result_key_fingerprint TEXT NOT NULL, + + CONSTRAINT pk_git_signature_results PRIMARY KEY (git_signature_result_repo_id, git_signature_result_object_sha), + + CONSTRAINT fk_git_signature_results_repo_id FOREIGN KEY (git_signature_result_repo_id) + REFERENCES repositories (repo_id) + ON UPDATE NO ACTION + ON DELETE CASCADE, + + CONSTRAINT fk_git_signature_result_principal_id FOREIGN KEY (git_signature_result_principal_id) + REFERENCES principals (principal_id) + ON UPDATE NO ACTION + ON DELETE SET NULL +); + +CREATE INDEX idx_git_signature_results_principal_id + ON git_signature_results (git_signature_result_principal_id); diff --git a/app/store/database/migrate/sqlite/0122_create_table_git_signature_results.down.sql b/app/store/database/migrate/sqlite/0122_create_table_git_signature_results.down.sql new file mode 100644 index 000000000..3dae8c3d0 --- /dev/null +++ b/app/store/database/migrate/sqlite/0122_create_table_git_signature_results.down.sql @@ -0,0 +1,2 @@ +DROP INDEX idx_git_signature_results_principal_id; +DROP TABLE git_signature_results; diff --git a/app/store/database/migrate/sqlite/0122_create_table_git_signature_results.up.sql b/app/store/database/migrate/sqlite/0122_create_table_git_signature_results.up.sql new file mode 100644 index 000000000..ebf25f809 --- /dev/null +++ b/app/store/database/migrate/sqlite/0122_create_table_git_signature_results.up.sql @@ -0,0 +1,27 @@ +CREATE TABLE git_signature_results ( + git_signature_result_repo_id INTEGER NOT NULL, + git_signature_result_object_sha TEXT NOT NULL, + git_signature_result_object_time BIGINT NOT NULL, + git_signature_result_created BIGINT NOT NULL, + git_signature_result_updated BIGINT NOT NULL, + git_signature_result_result TEXT NOT NULL, + git_signature_result_principal_id INTEGER NOT NULL, + git_signature_result_key_scheme TEXT NOT NULL, + git_signature_result_key_id TEXT NOT NULL, + git_signature_result_key_fingerprint TEXT NOT NULL, + + CONSTRAINT pk_git_signature_results PRIMARY KEY (git_signature_result_repo_id, git_signature_result_object_sha), + + CONSTRAINT fk_git_signature_results_repo_id FOREIGN KEY (git_signature_result_repo_id) + REFERENCES repositories (repo_id) + ON UPDATE NO ACTION + ON DELETE CASCADE, + + CONSTRAINT fk_git_signature_result_principal_id FOREIGN KEY (git_signature_result_principal_id) + REFERENCES principals (principal_id) + ON UPDATE NO ACTION + ON DELETE SET NULL +); + +CREATE INDEX idx_git_signature_results_principal_id + ON git_signature_results (git_signature_result_principal_id); diff --git a/app/store/database/publickey.go b/app/store/database/publickey.go index f6050b543..7cb51ee67 100644 --- a/app/store/database/publickey.go +++ b/app/store/database/publickey.go @@ -186,6 +186,32 @@ func (s PublicKeyStore) Create(ctx context.Context, key *types.PublicKey) error return nil } +func (s PublicKeyStore) Update(ctx context.Context, publicKey *types.PublicKey) error { + const sqlQuery = ` + UPDATE public_keys + SET + public_key_valid_from = :public_key_valid_from + ,public_key_valid_to = :public_key_valid_to + ,public_key_revocation_reason = :public_key_revocation_reason + WHERE public_key_id = :public_key_id` + + dbPublicKey := mapToInternalPublicKey(publicKey) + + db := dbtx.GetAccessor(ctx, s.db) + + query, arg, err := db.BindNamed(sqlQuery, dbPublicKey) + if err != nil { + return database.ProcessSQLErrorf(ctx, err, "Failed to bind public key object") + } + + _, err = db.ExecContext(ctx, query, arg...) + if err != nil { + return database.ProcessSQLErrorf(ctx, err, "Failed to update public key") + } + + return nil +} + // DeleteByIdentifier deletes a public key. func (s PublicKeyStore) DeleteByIdentifier(ctx context.Context, principalID int64, identifier string) error { const sqlQuery = `DELETE FROM public_keys WHERE public_key_principal_id = $1 and LOWER(public_key_identifier) = $2` diff --git a/app/store/database/publickey_subkey.go b/app/store/database/publickey_subkey.go index c0ea73897..ca48cb945 100644 --- a/app/store/database/publickey_subkey.go +++ b/app/store/database/publickey_subkey.go @@ -38,7 +38,7 @@ type PublicKeySubKeyStore struct { db *sqlx.DB } -// Create creates a new public key. +// Create creates subkeys for the provided public key. func (s PublicKeySubKeyStore) Create(ctx context.Context, publicKeyID int64, pgpKeyIDs []string) error { if len(pgpKeyIDs) == 0 { return nil @@ -66,3 +66,37 @@ func (s PublicKeySubKeyStore) Create(ctx context.Context, publicKeyID int64, pgp return nil } + +// List return all sub keys from a public key. +func (s PublicKeySubKeyStore) List(ctx context.Context, publicKeyID int64) ([]string, error) { + const sqlQuery = ` + SELECT public_key_sub_key_id + FROM public_key_sub_keys + WHERE public_key_sub_key_public_key_id = $1` + + db := dbtx.GetAccessor(ctx, s.db) + + rows, err := db.QueryContext(ctx, sqlQuery, publicKeyID) + if err != nil { + return nil, database.ProcessSQLErrorf(ctx, err, "Failed to query for public key subkeys") + } + defer rows.Close() + + var result []string + + for rows.Next() { + var subKeyID string + if err := rows.Scan(&subKeyID); err != nil { + return nil, database.ProcessSQLErrorf(ctx, err, "Failed to scan subkey ID") + } + + result = append(result, subKeyID) + } + + err = rows.Err() + if err != nil { + return nil, database.ProcessSQLErrorf(ctx, err, "failed to list subkeys") + } + + return result, nil +} diff --git a/app/store/database/wire.go b/app/store/database/wire.go index 62cd8ef3d..79a1ec781 100644 --- a/app/store/database/wire.go +++ b/app/store/database/wire.go @@ -64,6 +64,7 @@ var WireSet = wire.NewSet( ProvidePluginStore, ProvidePublicKeyStore, ProvidePublicKeySubKeyStore, + ProvideGitSignatureResultStore, ProvideInfraProviderConfigStore, ProvideInfraProviderResourceStore, ProvideGitspaceConfigStore, @@ -327,6 +328,10 @@ func ProvidePublicKeySubKeyStore(db *sqlx.DB) store.PublicKeySubKeyStore { return NewPublicKeySubKeyStore(db) } +func ProvideGitSignatureResultStore(db *sqlx.DB) store.GitSignatureResultStore { + return NewGitSignatureResultStore(db) +} + // ProvideBranchStore provides a branch store. func ProvideBranchStore(db *sqlx.DB) store.BranchStore { return NewBranchStore(db) diff --git a/cmd/gitness/wire_gen.go b/cmd/gitness/wire_gen.go index 8490a3eb2..047ce4f8c 100644 --- a/cmd/gitness/wire_gen.go +++ b/cmd/gitness/wire_gen.go @@ -204,6 +204,7 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro tokenStore := database.ProvideTokenStore(db) publicKeyStore := database.ProvidePublicKeyStore(db) publicKeySubKeyStore := database.ProvidePublicKeySubKeyStore(db) + gitSignatureResultStore := database.ProvideGitSignatureResultStore(db) eventsConfig := server.ProvideEventsConfig(config) eventsSystem, err := events.ProvideSystem(eventsConfig, universalClient) if err != nil { @@ -214,7 +215,7 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro return nil, err } favoriteStore := database.ProvideFavoriteStore(db) - controller := user.ProvideController(transactor, principalUID, authorizer, principalStore, tokenStore, membershipStore, publicKeyStore, publicKeySubKeyStore, reporter, repoFinder, favoriteStore) + controller := user.ProvideController(transactor, principalUID, authorizer, principalStore, tokenStore, membershipStore, publicKeyStore, publicKeySubKeyStore, gitSignatureResultStore, reporter, repoFinder, favoriteStore) serviceController := service.NewController(principalUID, authorizer, principalStore) bootstrapBootstrap := bootstrap.ProvideBootstrap(config, controller, serviceController) authenticator := authn.ProvideAuthenticator(config, principalStore, tokenStore) @@ -307,7 +308,8 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro } remoteauthService := remoteauth.ProvideRemoteAuth(tokenStore, principalStore) lfsController := lfs.ProvideController(authorizer, repoFinder, repoStore, principalStore, lfsObjectStore, blobStore, remoteauthService, provider, settingsService) - repoController := repo.ProvideController(config, transactor, provider, authorizer, repoStore, spaceStore, pipelineStore, principalStore, executionStore, ruleStore, checkStore, pullReqStore, settingsService, principalInfoCache, protectionManager, gitInterface, spaceFinder, repoFinder, repository, codeownersService, eventsReporter, indexer, resourceLimiter, lockerLocker, auditService, mutexManager, repoIdentifier, repoCheck, publicaccessService, labelService, instrumentService, userGroupStore, usergroupService, rulesService, streamer, lfsController, favoriteStore) + signatureVerifyService := publickey.ProvideSignatureVerifyService(principalStore, publicKeyStore, publicKeySubKeyStore, gitSignatureResultStore) + repoController := repo.ProvideController(config, transactor, provider, authorizer, repoStore, spaceStore, pipelineStore, principalStore, executionStore, ruleStore, checkStore, pullReqStore, settingsService, principalInfoCache, protectionManager, gitInterface, spaceFinder, repoFinder, repository, codeownersService, eventsReporter, indexer, resourceLimiter, lockerLocker, auditService, mutexManager, repoIdentifier, repoCheck, publicaccessService, labelService, instrumentService, userGroupStore, usergroupService, rulesService, streamer, lfsController, favoriteStore, signatureVerifyService) reposettingsController := reposettings.ProvideController(authorizer, repoFinder, settingsService, auditService) stageStore := database.ProvideStageStore(db) schedulerScheduler, err := scheduler.ProvideScheduler(stageStore, mutexManager) diff --git a/git/api/cat-file.go b/git/api/cat-file.go index 534a4be73..d82a9b74e 100644 --- a/git/api/cat-file.go +++ b/git/api/cat-file.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "io" + "iter" "strconv" "strings" @@ -30,7 +31,6 @@ import ( "github.com/djherbis/buffer" "github.com/djherbis/nio/v3" - "iter" ) // WriteCloserError wraps an io.WriteCloser with an additional CloseWithError function. @@ -168,11 +168,11 @@ func catFileObjects( for objectName := range iter { if _, err := writer.Write([]byte(objectName)); err != nil { - return fmt.Errorf("faild to write object sha to cat-file stdin: %w", err) + return fmt.Errorf("failed to write object sha to cat-file stdin: %w", err) } if _, err := writer.Write([]byte{'\n'}); err != nil { - return fmt.Errorf("faild to write EOL to cat-file stdin: %w", err) + return fmt.Errorf("failed to write EOL to cat-file stdin: %w", err) } output, err := ReadBatchHeaderLine(reader) @@ -180,14 +180,14 @@ func catFileObjects( if errors.Is(err, io.EOF) || errors.IsNotFound(err) { return fmt.Errorf("object %s does not exist", objectName) } - return fmt.Errorf("faild to read cat-file object header line: %w", err) + return fmt.Errorf("failed to read cat-file object header line: %w", err) } buf.Reset() _, err = io.CopyN(buf, reader, output.Size) if err != nil { - return fmt.Errorf("faild to read object raw data: %w", err) + return fmt.Errorf("failed to read object raw data: %w", err) } _, err = reader.Discard(1) diff --git a/git/parser/raw_object_test.go b/git/parser/raw_object_test.go index 3056fca2f..58d087e56 100644 --- a/git/parser/raw_object_test.go +++ b/git/parser/raw_object_test.go @@ -15,12 +15,15 @@ package parser import ( - "bytes" - "encoding/pem" + "context" "strings" "testing" + "time" - "github.com/harness/gitness/app/services/publickey" + "github.com/harness/gitness/app/services/publickey/keyssh" + "github.com/harness/gitness/git/sha" + "github.com/harness/gitness/types" + "github.com/harness/gitness/types/enum" "github.com/google/go-cmp/cmp" "golang.org/x/crypto/ssh" @@ -270,6 +273,12 @@ Rv18ZouJpO2LRIXdZpxAE= }, } + objectSHA := sha.Must("123456789") + person := types.Signature{ + Identity: types.Identity{Name: "Michelangelo", Email: "michelangelo@harness.io"}, + When: time.Now(), + } + for _, test := range tests { t.Run(test.name, func(t *testing.T) { object, err := Object([]byte(test.data)) @@ -282,55 +291,27 @@ Rv18ZouJpO2LRIXdZpxAE= t.Errorf("failed:\n%s\n", diff) } - if len(object.Signature) == 0 || object.SignatureType != "SSH SIGNATURE" { + if len(object.Signature) == 0 { // skip testing signed content because the data doesn't contain a signature return } - // If git object from the test contains a signature, - // we verify if object.SignedContent is correct - // (if it's possible to verify the signature from the content). + ctx := context.Background() + var verify keyssh.Verify - block, rest := pem.Decode(object.Signature) - if block == nil || len(rest) > 0 || block.Type != object.SignatureType { - t.Errorf("failed to decode signature") - return + signature := object.Signature + content := object.SignedContent + + if status := verify.Parse(ctx, signature, objectSHA); status != "" { + t.Errorf("failed to extract key from the signature: %s", status) } - var signature publickey.SSHSignatureBlob - if err := ssh.Unmarshal(block.Bytes, &signature); err != nil { - t.Errorf("failed to parse signature: %s", err.Error()) - return - } + // we use the public key directly from the signature + publicKey, _ := ssh.ParsePublicKey(verify.SignaturePublicKey()) + pk := ssh.MarshalAuthorizedKey(publicKey) - sshSig := ssh.Signature{} - if err := ssh.Unmarshal(signature.Signature, &sshSig); err != nil { - t.Errorf("failed to unmarshal ssh signature: %s", err.Error()) - return - } - - h, _ := publickey.SSHHash(signature.HashAlgorithm) - h.Write(object.SignedContent) - - key, err := ssh.ParsePublicKey(signature.PublicKey) // we get the public key directly from the signature - if err != nil { - t.Errorf("failed to parse signature key: %s", err.Error()) - return - } - - signedMessage := ssh.Marshal(publickey.SSHMessageWrapper{ - Namespace: signature.Namespace, - HashAlgorithm: signature.HashAlgorithm, - Hash: h.Sum(nil), - }) - buf := bytes.NewBuffer(nil) - buf.Write(signature.MagicPreamble[:]) - buf.Write(signedMessage) - - err = key.Verify(buf.Bytes(), &sshSig) - if err != nil { - t.Errorf("failed to verify signature: %s", err.Error()) - return + if status := verify.Verify(ctx, pk, content, objectSHA, person); status != enum.GitSignatureGood { + t.Errorf("failed to verify the signature: %s", status) } }) } diff --git a/git/sha/sha.go b/git/sha/sha.go index 448aaa1de..aff7f7f77 100644 --- a/git/sha/sha.go +++ b/git/sha/sha.go @@ -16,6 +16,7 @@ package sha import ( "bytes" + "database/sql/driver" "encoding/gob" "encoding/json" "fmt" @@ -141,3 +142,7 @@ func (s SHA) JSONSchema() (jsonschema.Schema, error) { return schema, nil } + +func (s SHA) Value() (driver.Value, error) { + return s.str, nil +} diff --git a/git/sharedrepo/sharedrepo.go b/git/sharedrepo/sharedrepo.go index 8de327a76..30bb392fe 100644 --- a/git/sharedrepo/sharedrepo.go +++ b/git/sharedrepo/sharedrepo.go @@ -396,7 +396,7 @@ func (r *SharedRepo) CommitTree( // Signed-off-by _, _ = messageBytes.WriteString("\n") _, _ = messageBytes.WriteString("Signed-off-by: ") - _, _ = messageBytes.WriteString(fmt.Sprintf("%s <%s>", committer.Identity.Name, committer.Identity.Email)) + _, _ = messageBytes.WriteString(fmt.Sprintf("%s <%s>\n", committer.Identity.Name, committer.Identity.Email)) } stdout := bytes.NewBuffer(nil) diff --git a/ssh/server.go b/ssh/server.go index dff94b011..0ce4bebc1 100644 --- a/ssh/server.go +++ b/ssh/server.go @@ -36,6 +36,7 @@ import ( "github.com/harness/gitness/app/api/request" "github.com/harness/gitness/app/auth" "github.com/harness/gitness/app/services/publickey" + "github.com/harness/gitness/app/services/publickey/keyssh" "github.com/harness/gitness/errors" "github.com/harness/gitness/git/api" "github.com/harness/gitness/types" @@ -377,7 +378,7 @@ func (s *Server) publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool { log := getLoggerWithRequestID(ctx.SessionID()) request.WithRequestIDSSH(ctx, getRequestID(ctx.SessionID())) - if slices.Contains(publickey.DisallowedTypes, key.Type()) { + if slices.Contains(keyssh.DisallowedTypes, key.Type()) { log.Warn().Msgf("public key type not supported: %s", key.Type()) return false } diff --git a/types/commit.go b/types/commit.go index 3b52cfcee..2bfeb80ea 100644 --- a/types/commit.go +++ b/types/commit.go @@ -14,7 +14,9 @@ package types -import "github.com/harness/gitness/git/sha" +import ( + "github.com/harness/gitness/git/sha" +) // CommitFilesResponse holds commit id. type CommitFilesResponse struct { @@ -27,3 +29,8 @@ type FileReference struct { Path string `json:"path"` SHA sha.SHA `json:"blob_sha"` } + +type PathDetails struct { + Path string `json:"path"` + LastCommit *Commit `json:"last_commit,omitempty"` +} diff --git a/types/enum/public_key.go b/types/enum/public_key.go index 762f61d46..ea71763ad 100644 --- a/types/enum/public_key.go +++ b/types/enum/public_key.go @@ -99,3 +99,48 @@ func (s RevocationReason) Sanitize() (RevocationReason, bool) { func GetAllRevocationReasons() ([]RevocationReason, RevocationReason) { return revocationReasons, "" } + +// GitSignatureResult is outcome of a git object's signature verification. +type GitSignatureResult string + +const ( + // GitSignatureInvalid is used when the signature itself is malformed. + // Shouldn't be stored to the DB. + GitSignatureInvalid GitSignatureResult = "invalid" + + // GitSignatureUnsupported is used when the system is unable to verify the signature + // because the signature version is not supported by the system. + // Shouldn't be stored to the DB. + GitSignatureUnsupported GitSignatureResult = "unsupported" + + // GitSignatureUnverified is used when the signer is not in the DB or + // when the key to verify the signature is missing. + // Shouldn't be stored to the DB. + GitSignatureUnverified GitSignatureResult = "unverified" + + // GitSignatureGood is used when the signature is cryptographically valid for the signed object. + GitSignatureGood GitSignatureResult = "good" + + // GitSignatureBad is used when the signature is bad (doesn’t match the object contents). + GitSignatureBad GitSignatureResult = "bad" + + // GitSignatureKeyExpired is used when the content's timestamp is not within the key's validity period. + GitSignatureKeyExpired GitSignatureResult = "key_expired" + + // GitSignatureRevoked is used when the signature is valid, but is signed with a revoked key. + GitSignatureRevoked GitSignatureResult = "revoked" +) + +var gitSignatureResults = sortEnum([]GitSignatureResult{ + GitSignatureInvalid, + GitSignatureUnsupported, + GitSignatureUnverified, + GitSignatureGood, + GitSignatureBad, + GitSignatureKeyExpired, + GitSignatureRevoked, +}) + +func (GitSignatureResult) Enum() []interface{} { + return toInterfaceSlice(gitSignatureResults) +} diff --git a/types/git.go b/types/git.go index 3e2b9a9e5..e3328d44a 100644 --- a/types/git.go +++ b/types/git.go @@ -99,8 +99,33 @@ type Commit struct { Committer Signature `json:"committer"` SignedData *SignedData `json:"-"` Stats *CommitStats `json:"stats,omitempty"` + + Signature *GitSignatureResult `json:"signature"` } +func (c *Commit) GetSHA() sha.SHA { return c.SHA } +func (c *Commit) SetSignature(sig *GitSignatureResult) { c.Signature = sig } +func (c *Commit) GetSigner() *Signature { return &c.Committer } +func (c *Commit) GetSignedData() *SignedData { return c.SignedData } + +type CommitTag struct { + Name string `json:"name"` + SHA sha.SHA `json:"sha"` + IsAnnotated bool `json:"is_annotated"` + Title string `json:"title,omitempty"` + Message string `json:"message,omitempty"` + Tagger *Signature `json:"tagger,omitempty"` + SignedData *SignedData `json:"-"` + Commit *Commit `json:"commit,omitempty"` + + Signature *GitSignatureResult `json:"signature"` +} + +func (t *CommitTag) GetSHA() sha.SHA { return t.SHA } +func (t *CommitTag) SetSignature(sig *GitSignatureResult) { t.Signature = sig } +func (t *CommitTag) GetSigner() *Signature { return t.Tagger } +func (t *CommitTag) GetSignedData() *SignedData { return t.SignedData } + type Signature struct { Identity Identity `json:"identity"` When time.Time `json:"when"` @@ -125,7 +150,33 @@ type RenameDetails struct { } type ListCommitResponse struct { - Commits []Commit `json:"commits"` + Commits []*Commit `json:"commits"` RenameDetails []RenameDetails `json:"rename_details"` TotalCommits int `json:"total_commits,omitempty"` } + +type GitSignatureResult struct { + RepoID int64 `json:"-"` + ObjectSHA sha.SHA `json:"-"` + ObjectTime int64 `json:"-"` + + // Created is the timestamp when the signature was first verified. + Created int64 `json:"created,omitempty"` + + // Updated is the timestamp when result has been updated (i.e. because of key revocation). + Updated int64 `json:"updated,omitempty"` + + // Result is the result of the signature verification. + Result enum.GitSignatureResult `json:"result"` + + // PrincipalID is owner of the key with which signature has been checked. + PrincipalID int64 `json:"-"` + + KeyScheme enum.PublicKeyScheme `json:"key_scheme,omitempty"` + + // KeyID is the ID of the key with which signature has been checked. + KeyID string `json:"key_id,omitempty"` + + // KeyFingerprint is the fingerprint of the key with which signature has been checked. + KeyFingerprint string `json:"key_fingerprint,omitempty"` +}