From d699de32f2f6fd2216c8d620c8f53011e511b56b Mon Sep 17 00:00:00 2001 From: Antoine GIRARD Date: Sun, 14 Apr 2019 18:43:56 +0200 Subject: [PATCH] add .gpg url (match github behaviour) (#6610) * add .gpg url (match github behaviour) * wildcard * test to export maximum data * working POC * add comment for old imported keys * cleaning * Update routers/user/profile.go Co-Authored-By: sapk * add migration script * add integration tests --- integrations/user_test.go | 87 +++++++++++++++++++++++++++ models/error.go | 15 +++++ models/fixtures/gpg_key_import.yml | 1 + models/gpg_key.go | 97 ++++++++++++++++++++++++------ models/migrations/migrations.go | 2 + models/migrations/v84.go | 18 ++++++ models/models.go | 1 + models/user.go | 2 +- routers/user/home.go | 41 +++++++++++++ routers/user/profile.go | 15 ++++- 10 files changed, 259 insertions(+), 20 deletions(-) create mode 100644 models/fixtures/gpg_key_import.yml create mode 100644 models/migrations/v84.go diff --git a/integrations/user_test.go b/integrations/user_test.go index a6ad164d6..fd25f1c57 100644 --- a/integrations/user_test.go +++ b/integrations/user_test.go @@ -101,3 +101,90 @@ func TestRenameReservedUsername(t *testing.T) { models.AssertNotExistsBean(t, &models.User{Name: reservedUsername}) } } + +func TestExportUserGPGKeys(t *testing.T) { + prepareTestEnv(t) + //Export empty key list + testExportUserGPGKeys(t, "user1", `-----BEGIN PGP PUBLIC KEY BLOCK----- + + +=twTO +-----END PGP PUBLIC KEY BLOCK----- +`) + //Import key + //User1 + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session) + testCreateGPGKey(t, session.MakeRequest, token, http.StatusCreated, `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBFyy/VUBCADJ7zbM20Z1RWmFoVgp5WkQfI2rU1Vj9cQHes9i42wVLLtcbPeo +QzubgzvMPITDy7nfWxgSf83E23DoHQ1ACFbQh/6eFSRrjsusp3YQ/08NSfPPbcu8 +0M5G+VGwSfzS5uEcwBVQmHyKdcOZIERTNMtYZx1C3bjLD1XVJHvWz9D72Uq4qeO3 +8SR+lzp5n6ppUakcmRnxt3nGRBj1+hEGkdgzyPo93iy+WioegY2lwCA9xMEo5dah +BmYxWx51zyiXYlReTaxlyb3/nuSUt8IcW3Q8zjdtJj4Nu8U1SpV8EdaA1I9IPbHW +510OSLmD3XhqHH5m6mIxL1YoWxk3V7gpDROtABEBAAG0GVVzZXIxIDx1c2VyMUBl +eGFtcGxlLmNvbT6JAU4EEwEIADgWIQTQEbrYxmXsp1z3j7z9+v0I6RSEHwUCXLL9 +VQIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRD9+v0I6RSEH22YCACFqL5+ +6M0m18AMC/pumcpnnmvAS1GrrKTF8nOROA1augZwp1WCNuKw2R6uOJIHANrYECSn +u7+j6GBP2gbIW8mSAzS6HWCs7GGiPpVtT4wcu8wljUI6BxjpyZtoEkriyBjt6HfK +rkegbkuySoJvjq4IcO5D1LB1JWgsUjMYQJj/ZpBIzVtjG9QtFSOiT1Hct4PoZHdC +nsdSgyCkwRZXG+u3kT/wP9F663ba4o16vYlz3dCGo66lF2tyoG3qcyZ1OUzUrnuv +96ytAzT6XIhrE0nVoBprMxFF5zExotJD3bHjcGBFNLf944bhjKee3U6t9+OsfJVC +l7N5xxIawCuTQdbfuQENBFyy/VUBCADe61yGEoTwKfsOKIhxLaNoRmD883O0tiWt +soO/HPj9dPQLTOiwXgSgSCd8C+LNxGKct87wgFozpah4tDLC6c0nALuHJ0SLbkfz +55aRhLeOOcrAydatDp72GroXzqpZ0xZBk5wjIWdgEol2GmVRM8QGbeuakU/HVz5y +lPzxUUocgdbSi3GE3zbzijQzVJdyL/kw/KP7pKT/PPKKJ2C5NQDLy0XGKEHddXGR +EWKkVlRalxq/TjfaMR0bi3MpezBsQmp99ATPO/d7trayZUxQHRtXzGFiOXfDHATr +qN730sODjqvU+mpc/SHCRwh9qWDjZRHSuKU5YDBjb5jIQJivZsQ/ABEBAAGJATYE +GAEIACAWIQTQEbrYxmXsp1z3j7z9+v0I6RSEHwUCXLL9VQIbDAAKCRD9+v0I6RSE +H7WoB/4tXl+97rQ6owPCGSVp1Xbwt2521V7COgsOFRVTRTryEWxRW8mm0S7wQvax +C0TLXKur6NVYQMn01iyL+FZzRpEWNuYF3f9QeeLJ/+l2DafESNhNTy17+RPmacK6 +21dccpqchByVw/UMDeHSyjQLiG2lxzt8Gfx2gHmSbrq3aWovTGyz6JTffZvfy/n2 +0Hm437OBPazO0gZyXhdV2PE5RSUfvAgm44235tcV5EV0d32TJDfv61+Vr2GUbah6 +7XhJ1v6JYuh8kaYaEz8OpZDeh7f6Ho6PzJrsy/TKTKhGgZNINj1iaPFyOkQgKR5M +GrE0MHOxUbc9tbtyk0F1SuzREUBH +=DDXw +-----END PGP PUBLIC KEY BLOCK----- +`) + //Export new key + testExportUserGPGKeys(t, "user1", `-----BEGIN PGP PUBLIC KEY BLOCK----- + +xsBNBFyy/VUBCADJ7zbM20Z1RWmFoVgp5WkQfI2rU1Vj9cQHes9i42wVLLtcbPeo +QzubgzvMPITDy7nfWxgSf83E23DoHQ1ACFbQh/6eFSRrjsusp3YQ/08NSfPPbcu8 +0M5G+VGwSfzS5uEcwBVQmHyKdcOZIERTNMtYZx1C3bjLD1XVJHvWz9D72Uq4qeO3 +8SR+lzp5n6ppUakcmRnxt3nGRBj1+hEGkdgzyPo93iy+WioegY2lwCA9xMEo5dah +BmYxWx51zyiXYlReTaxlyb3/nuSUt8IcW3Q8zjdtJj4Nu8U1SpV8EdaA1I9IPbHW +510OSLmD3XhqHH5m6mIxL1YoWxk3V7gpDROtABEBAAHNGVVzZXIxIDx1c2VyMUBl +eGFtcGxlLmNvbT7CwI4EEwEIADgWIQTQEbrYxmXsp1z3j7z9+v0I6RSEHwUCXLL9 +VQIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRD9+v0I6RSEH22YCACFqL5+ +6M0m18AMC/pumcpnnmvAS1GrrKTF8nOROA1augZwp1WCNuKw2R6uOJIHANrYECSn +u7+j6GBP2gbIW8mSAzS6HWCs7GGiPpVtT4wcu8wljUI6BxjpyZtoEkriyBjt6HfK +rkegbkuySoJvjq4IcO5D1LB1JWgsUjMYQJj/ZpBIzVtjG9QtFSOiT1Hct4PoZHdC +nsdSgyCkwRZXG+u3kT/wP9F663ba4o16vYlz3dCGo66lF2tyoG3qcyZ1OUzUrnuv +96ytAzT6XIhrE0nVoBprMxFF5zExotJD3bHjcGBFNLf944bhjKee3U6t9+OsfJVC +l7N5xxIawCuTQdbfzsBNBFyy/VUBCADe61yGEoTwKfsOKIhxLaNoRmD883O0tiWt +soO/HPj9dPQLTOiwXgSgSCd8C+LNxGKct87wgFozpah4tDLC6c0nALuHJ0SLbkfz +55aRhLeOOcrAydatDp72GroXzqpZ0xZBk5wjIWdgEol2GmVRM8QGbeuakU/HVz5y +lPzxUUocgdbSi3GE3zbzijQzVJdyL/kw/KP7pKT/PPKKJ2C5NQDLy0XGKEHddXGR +EWKkVlRalxq/TjfaMR0bi3MpezBsQmp99ATPO/d7trayZUxQHRtXzGFiOXfDHATr +qN730sODjqvU+mpc/SHCRwh9qWDjZRHSuKU5YDBjb5jIQJivZsQ/ABEBAAHCwHYE +GAEIACAWIQTQEbrYxmXsp1z3j7z9+v0I6RSEHwUCXLL9VQIbDAAKCRD9+v0I6RSE +H7WoB/4tXl+97rQ6owPCGSVp1Xbwt2521V7COgsOFRVTRTryEWxRW8mm0S7wQvax +C0TLXKur6NVYQMn01iyL+FZzRpEWNuYF3f9QeeLJ/+l2DafESNhNTy17+RPmacK6 +21dccpqchByVw/UMDeHSyjQLiG2lxzt8Gfx2gHmSbrq3aWovTGyz6JTffZvfy/n2 +0Hm437OBPazO0gZyXhdV2PE5RSUfvAgm44235tcV5EV0d32TJDfv61+Vr2GUbah6 +7XhJ1v6JYuh8kaYaEz8OpZDeh7f6Ho6PzJrsy/TKTKhGgZNINj1iaPFyOkQgKR5M +GrE0MHOxUbc9tbtyk0F1SuzREUBH +=WFf5 +-----END PGP PUBLIC KEY BLOCK----- +`) +} + +func testExportUserGPGKeys(t *testing.T, user, expected string) { + session := loginUser(t, user) + t.Logf("Testing username %s export gpg keys", user) + req := NewRequest(t, "GET", "/"+user+".gpg") + resp := session.MakeRequest(t, req, http.StatusOK) + //t.Log(resp.Body.String()) + assert.Equal(t, expected, resp.Body.String()) +} diff --git a/models/error.go b/models/error.go index 6a135bda1..f079af4e1 100644 --- a/models/error.go +++ b/models/error.go @@ -379,6 +379,21 @@ func (err ErrGPGKeyNotExist) Error() string { return fmt.Sprintf("public gpg key does not exist [id: %d]", err.ID) } +// ErrGPGKeyImportNotExist represents a "GPGKeyImportNotExist" kind of error. +type ErrGPGKeyImportNotExist struct { + ID string +} + +// IsErrGPGKeyImportNotExist checks if an error is a ErrGPGKeyImportNotExist. +func IsErrGPGKeyImportNotExist(err error) bool { + _, ok := err.(ErrGPGKeyImportNotExist) + return ok +} + +func (err ErrGPGKeyImportNotExist) Error() string { + return fmt.Sprintf("public gpg key import does not exist [id: %s]", err.ID) +} + // ErrGPGKeyIDAlreadyUsed represents a "GPGKeyIDAlreadyUsed" kind of error. type ErrGPGKeyIDAlreadyUsed struct { KeyID string diff --git a/models/fixtures/gpg_key_import.yml b/models/fixtures/gpg_key_import.yml new file mode 100644 index 000000000..ca780a73a --- /dev/null +++ b/models/fixtures/gpg_key_import.yml @@ -0,0 +1 @@ +[] # empty diff --git a/models/gpg_key.go b/models/gpg_key.go index 0352456e5..2e10fd782 100644 --- a/models/gpg_key.go +++ b/models/gpg_key.go @@ -43,6 +43,12 @@ type GPGKey struct { CanCertify bool } +//GPGKeyImport the original import of key +type GPGKeyImport struct { + KeyID string `xorm:"pk CHAR(16) NOT NULL"` + Content string `xorm:"TEXT NOT NULL"` +} + // BeforeInsert will be invoked by XORM before inserting a record func (key *GPGKey) BeforeInsert() { key.AddedUnix = util.TimeStampNow() @@ -74,6 +80,18 @@ func GetGPGKeyByID(keyID int64) (*GPGKey, error) { return key, nil } +// GetGPGImportByKeyID returns the import public armored key by given KeyID. +func GetGPGImportByKeyID(keyID string) (*GPGKeyImport, error) { + key := new(GPGKeyImport) + has, err := x.ID(keyID).Get(key) + if err != nil { + return nil, err + } else if !has { + return nil, ErrGPGKeyImportNotExist{keyID} + } + return key, nil +} + // checkArmoredGPGKeyString checks if the given key string is a valid GPG armored key. // The function returns the actual public key on success func checkArmoredGPGKeyString(content string) (*openpgp.Entity, error) { @@ -84,15 +102,37 @@ func checkArmoredGPGKeyString(content string) (*openpgp.Entity, error) { return list[0], nil } -//addGPGKey add key and subkeys to database -func addGPGKey(e Engine, key *GPGKey) (err error) { +//addGPGKey add key, import and subkeys to database +func addGPGKey(e Engine, key *GPGKey, content string) (err error) { + //Add GPGKeyImport + if _, err = e.Insert(GPGKeyImport{ + KeyID: key.KeyID, + Content: content, + }); err != nil { + return err + } // Save GPG primary key. if _, err = e.Insert(key); err != nil { return err } // Save GPG subs key. for _, subkey := range key.SubsKey { - if err := addGPGKey(e, subkey); err != nil { + if err := addGPGSubKey(e, subkey); err != nil { + return err + } + } + return nil +} + +//addGPGSubKey add subkeys to database +func addGPGSubKey(e Engine, key *GPGKey) (err error) { + // Save GPG primary key. + if _, err = e.Insert(key); err != nil { + return err + } + // Save GPG subs key. + for _, subkey := range key.SubsKey { + if err := addGPGSubKey(e, subkey); err != nil { return err } } @@ -127,14 +167,14 @@ func AddGPGKey(ownerID int64, content string) (*GPGKey, error) { return nil, err } - if err = addGPGKey(sess, key); err != nil { + if err = addGPGKey(sess, key, content); err != nil { return nil, err } return key, sess.Commit() } -//base64EncPubKey encode public kay content to base 64 +//base64EncPubKey encode public key content to base 64 func base64EncPubKey(pubkey *packet.PublicKey) (string, error) { var w bytes.Buffer err := pubkey.Serialize(&w) @@ -144,6 +184,34 @@ func base64EncPubKey(pubkey *packet.PublicKey) (string, error) { return base64.StdEncoding.EncodeToString(w.Bytes()), nil } +//base64DecPubKey decode public key content from base 64 +func base64DecPubKey(content string) (*packet.PublicKey, error) { + b, err := readerFromBase64(content) + if err != nil { + return nil, err + } + //Read key + p, err := packet.Read(b) + if err != nil { + return nil, err + } + //Check type + pkey, ok := p.(*packet.PublicKey) + if !ok { + return nil, fmt.Errorf("key is not a public key") + } + return pkey, nil +} + +//GPGKeyToEntity retrieve the imported key and the traducted entity +func GPGKeyToEntity(k *GPGKey) (*openpgp.Entity, error) { + impKey, err := GetGPGImportByKeyID(k.KeyID) + if err != nil { + return nil, err + } + return checkArmoredGPGKeyString(impKey.Content) +} + //parseSubGPGKey parse a sub Key func parseSubGPGKey(ownerID int64, primaryID string, pubkey *packet.PublicKey, expiry time.Time) (*GPGKey, error) { content, err := base64EncPubKey(pubkey) @@ -244,6 +312,11 @@ func deleteGPGKey(e *xorm.Session, keyID string) (int64, error) { if keyID == "" { return 0, fmt.Errorf("empty KeyId forbidden") //Should never happen but just to be sure } + //Delete imported key + n, err := e.Where("key_id=?", keyID).Delete(new(GPGKeyImport)) + if err != nil { + return n, err + } return e.Where("key_id=?", keyID).Or("primary_key_id=?", keyID).Delete(new(GPGKey)) } @@ -339,22 +412,10 @@ func verifySign(s *packet.Signature, h hash.Hash, k *GPGKey) error { return fmt.Errorf("key can not sign") } //Decode key - b, err := readerFromBase64(k.Content) + pkey, err := base64DecPubKey(k.Content) if err != nil { return err } - //Read key - p, err := packet.Read(b) - if err != nil { - return err - } - - //Check type - pkey, ok := p.(*packet.PublicKey) - if !ok { - return fmt.Errorf("key is not a public key") - } - return pkey.VerifySignature(h, s) } diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index baedcbb71..62c41fb90 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -221,6 +221,8 @@ var migrations = []Migration{ NewMigration("hot fix for wrong release sha1 on release table", fixReleaseSha1OnReleaseTable), // v83 -> v84 NewMigration("add uploader id for table attachment", addUploaderIDForAttachment), + // v84 -> v85 + NewMigration("add table to store original imported gpg keys", addGPGKeyImport), } // Migrate database to current version diff --git a/models/migrations/v84.go b/models/migrations/v84.go new file mode 100644 index 000000000..4acb94b9c --- /dev/null +++ b/models/migrations/v84.go @@ -0,0 +1,18 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "github.com/go-xorm/xorm" +) + +func addGPGKeyImport(x *xorm.Engine) error { + type GPGKeyImport struct { + KeyID string `xorm:"pk CHAR(16) NOT NULL"` + Content string `xorm:"TEXT NOT NULL"` + } + + return x.Sync2(new(GPGKeyImport)) +} diff --git a/models/models.go b/models/models.go index e7ecc67fc..352c07e0c 100644 --- a/models/models.go +++ b/models/models.go @@ -108,6 +108,7 @@ func init() { new(LFSMetaObject), new(TwoFactor), new(GPGKey), + new(GPGKeyImport), new(RepoUnit), new(RepoRedirect), new(ExternalLoginUser), diff --git a/models/user.go b/models/user.go index 723892b0b..93fdc6f4a 100644 --- a/models/user.go +++ b/models/user.go @@ -747,7 +747,7 @@ var ( ".", "..", } - reservedUserPatterns = []string{"*.keys"} + reservedUserPatterns = []string{"*.keys", "*.gpg"} ) // isUsableName checks if name is reserved or pattern of name is not allowed diff --git a/routers/user/home.go b/routers/user/home.go index 740a9edc4..8eedeb70b 100644 --- a/routers/user/home.go +++ b/routers/user/home.go @@ -19,6 +19,8 @@ import ( "github.com/Unknwon/com" "github.com/Unknwon/paginater" + "github.com/keybase/go-crypto/openpgp" + "github.com/keybase/go-crypto/openpgp/armor" ) const ( @@ -384,6 +386,45 @@ func ShowSSHKeys(ctx *context.Context, uid int64) { ctx.PlainText(200, buf.Bytes()) } +// ShowGPGKeys output all the public GPG keys of user by uid +func ShowGPGKeys(ctx *context.Context, uid int64) { + keys, err := models.ListGPGKeys(uid) + if err != nil { + ctx.ServerError("ListGPGKeys", err) + return + } + entities := make([]*openpgp.Entity, 0) + failedEntitiesID := make([]string, 0) + for _, k := range keys { + e, err := models.GPGKeyToEntity(k) + if err != nil { + if models.IsErrGPGKeyImportNotExist(err) { + failedEntitiesID = append(failedEntitiesID, k.KeyID) + continue //Skip previous import without backup of imported armored key + } + ctx.ServerError("ShowGPGKeys", err) + return + } + entities = append(entities, e) + } + var buf bytes.Buffer + + headers := make(map[string]string) + if len(failedEntitiesID) > 0 { //If some key need re-import to be exported + headers["Note"] = fmt.Sprintf("The keys with the following IDs couldn't be exported and need to be reuploaded %s", strings.Join(failedEntitiesID, ", ")) + } + writer, _ := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", headers) + for _, e := range entities { + err = e.Serialize(writer) //TODO find why key are exported with a different cipherTypeByte as original (should not be blocking but strange) + if err != nil { + ctx.ServerError("ShowGPGKeys", err) + return + } + } + writer.Close() + ctx.PlainText(200, buf.Bytes()) +} + func showOrgProfile(ctx *context.Context) { ctx.SetParams(":org", ctx.Params(":username")) context.HandleOrgAssignment(ctx) diff --git a/routers/user/profile.go b/routers/user/profile.go index 03f88e256..675c1dc3f 100644 --- a/routers/user/profile.go +++ b/routers/user/profile.go @@ -59,9 +59,16 @@ func Profile(ctx *context.Context) { isShowKeys := false if strings.HasSuffix(uname, ".keys") { isShowKeys = true + uname = strings.TrimSuffix(uname, ".keys") } - ctxUser := GetUserByName(ctx, strings.TrimSuffix(uname, ".keys")) + isShowGPG := false + if strings.HasSuffix(uname, ".gpg") { + isShowGPG = true + uname = strings.TrimSuffix(uname, ".gpg") + } + + ctxUser := GetUserByName(ctx, uname) if ctx.Written() { return } @@ -72,6 +79,12 @@ func Profile(ctx *context.Context) { return } + // Show GPG keys. + if isShowGPG { + ShowGPGKeys(ctx, ctxUser.ID) + return + } + if ctxUser.IsOrganization() { showOrgProfile(ctx) return