diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /models/asymkey | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'models/asymkey')
23 files changed, 4506 insertions, 0 deletions
diff --git a/models/asymkey/error.go b/models/asymkey/error.go new file mode 100644 index 0000000..03bc823 --- /dev/null +++ b/models/asymkey/error.go @@ -0,0 +1,318 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "fmt" + + "code.gitea.io/gitea/modules/util" +) + +// ErrKeyUnableVerify represents a "KeyUnableVerify" kind of error. +type ErrKeyUnableVerify struct { + Result string +} + +// IsErrKeyUnableVerify checks if an error is a ErrKeyUnableVerify. +func IsErrKeyUnableVerify(err error) bool { + _, ok := err.(ErrKeyUnableVerify) + return ok +} + +func (err ErrKeyUnableVerify) Error() string { + return fmt.Sprintf("Unable to verify key content [result: %s]", err.Result) +} + +// ErrKeyIsPrivate is returned when the provided key is a private key not a public key +var ErrKeyIsPrivate = util.NewSilentWrapErrorf(util.ErrInvalidArgument, "the provided key is a private key") + +// ErrKeyNotExist represents a "KeyNotExist" kind of error. +type ErrKeyNotExist struct { + ID int64 +} + +// IsErrKeyNotExist checks if an error is a ErrKeyNotExist. +func IsErrKeyNotExist(err error) bool { + _, ok := err.(ErrKeyNotExist) + return ok +} + +func (err ErrKeyNotExist) Error() string { + return fmt.Sprintf("public key does not exist [id: %d]", err.ID) +} + +func (err ErrKeyNotExist) Unwrap() error { + return util.ErrNotExist +} + +// ErrKeyAlreadyExist represents a "KeyAlreadyExist" kind of error. +type ErrKeyAlreadyExist struct { + OwnerID int64 + Fingerprint string + Content string +} + +// IsErrKeyAlreadyExist checks if an error is a ErrKeyAlreadyExist. +func IsErrKeyAlreadyExist(err error) bool { + _, ok := err.(ErrKeyAlreadyExist) + return ok +} + +func (err ErrKeyAlreadyExist) Error() string { + return fmt.Sprintf("public key already exists [owner_id: %d, finger_print: %s, content: %s]", + err.OwnerID, err.Fingerprint, err.Content) +} + +func (err ErrKeyAlreadyExist) Unwrap() error { + return util.ErrAlreadyExist +} + +// ErrKeyNameAlreadyUsed represents a "KeyNameAlreadyUsed" kind of error. +type ErrKeyNameAlreadyUsed struct { + OwnerID int64 + Name string +} + +// IsErrKeyNameAlreadyUsed checks if an error is a ErrKeyNameAlreadyUsed. +func IsErrKeyNameAlreadyUsed(err error) bool { + _, ok := err.(ErrKeyNameAlreadyUsed) + return ok +} + +func (err ErrKeyNameAlreadyUsed) Error() string { + return fmt.Sprintf("public key already exists [owner_id: %d, name: %s]", err.OwnerID, err.Name) +} + +func (err ErrKeyNameAlreadyUsed) Unwrap() error { + return util.ErrAlreadyExist +} + +// ErrGPGNoEmailFound represents a "ErrGPGNoEmailFound" kind of error. +type ErrGPGNoEmailFound struct { + FailedEmails []string + ID string +} + +// IsErrGPGNoEmailFound checks if an error is a ErrGPGNoEmailFound. +func IsErrGPGNoEmailFound(err error) bool { + _, ok := err.(ErrGPGNoEmailFound) + return ok +} + +func (err ErrGPGNoEmailFound) Error() string { + return fmt.Sprintf("none of the emails attached to the GPG key could be found: %v", err.FailedEmails) +} + +// ErrGPGInvalidTokenSignature represents a "ErrGPGInvalidTokenSignature" kind of error. +type ErrGPGInvalidTokenSignature struct { + Wrapped error + ID string +} + +// IsErrGPGInvalidTokenSignature checks if an error is a ErrGPGInvalidTokenSignature. +func IsErrGPGInvalidTokenSignature(err error) bool { + _, ok := err.(ErrGPGInvalidTokenSignature) + return ok +} + +func (err ErrGPGInvalidTokenSignature) Error() string { + return "the provided signature does not sign the token with the provided key" +} + +// ErrGPGKeyParsing represents a "ErrGPGKeyParsing" kind of error. +type ErrGPGKeyParsing struct { + ParseError error +} + +// IsErrGPGKeyParsing checks if an error is a ErrGPGKeyParsing. +func IsErrGPGKeyParsing(err error) bool { + _, ok := err.(ErrGPGKeyParsing) + return ok +} + +func (err ErrGPGKeyParsing) Error() string { + return fmt.Sprintf("failed to parse gpg key %s", err.ParseError.Error()) +} + +// ErrGPGKeyNotExist represents a "GPGKeyNotExist" kind of error. +type ErrGPGKeyNotExist struct { + ID int64 +} + +// IsErrGPGKeyNotExist checks if an error is a ErrGPGKeyNotExist. +func IsErrGPGKeyNotExist(err error) bool { + _, ok := err.(ErrGPGKeyNotExist) + return ok +} + +func (err ErrGPGKeyNotExist) Error() string { + return fmt.Sprintf("public gpg key does not exist [id: %d]", err.ID) +} + +func (err ErrGPGKeyNotExist) Unwrap() error { + return util.ErrNotExist +} + +// 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) +} + +func (err ErrGPGKeyImportNotExist) Unwrap() error { + return util.ErrNotExist +} + +// ErrGPGKeyIDAlreadyUsed represents a "GPGKeyIDAlreadyUsed" kind of error. +type ErrGPGKeyIDAlreadyUsed struct { + KeyID string +} + +// IsErrGPGKeyIDAlreadyUsed checks if an error is a ErrKeyNameAlreadyUsed. +func IsErrGPGKeyIDAlreadyUsed(err error) bool { + _, ok := err.(ErrGPGKeyIDAlreadyUsed) + return ok +} + +func (err ErrGPGKeyIDAlreadyUsed) Error() string { + return fmt.Sprintf("public key already exists [key_id: %s]", err.KeyID) +} + +func (err ErrGPGKeyIDAlreadyUsed) Unwrap() error { + return util.ErrAlreadyExist +} + +// ErrGPGKeyAccessDenied represents a "GPGKeyAccessDenied" kind of Error. +type ErrGPGKeyAccessDenied struct { + UserID int64 + KeyID int64 +} + +// IsErrGPGKeyAccessDenied checks if an error is a ErrGPGKeyAccessDenied. +func IsErrGPGKeyAccessDenied(err error) bool { + _, ok := err.(ErrGPGKeyAccessDenied) + return ok +} + +// Error pretty-prints an error of type ErrGPGKeyAccessDenied. +func (err ErrGPGKeyAccessDenied) Error() string { + return fmt.Sprintf("user does not have access to the key [user_id: %d, key_id: %d]", + err.UserID, err.KeyID) +} + +func (err ErrGPGKeyAccessDenied) Unwrap() error { + return util.ErrPermissionDenied +} + +// ErrKeyAccessDenied represents a "KeyAccessDenied" kind of error. +type ErrKeyAccessDenied struct { + UserID int64 + KeyID int64 + Note string +} + +// IsErrKeyAccessDenied checks if an error is a ErrKeyAccessDenied. +func IsErrKeyAccessDenied(err error) bool { + _, ok := err.(ErrKeyAccessDenied) + return ok +} + +func (err ErrKeyAccessDenied) Error() string { + return fmt.Sprintf("user does not have access to the key [user_id: %d, key_id: %d, note: %s]", + err.UserID, err.KeyID, err.Note) +} + +func (err ErrKeyAccessDenied) Unwrap() error { + return util.ErrPermissionDenied +} + +// ErrDeployKeyNotExist represents a "DeployKeyNotExist" kind of error. +type ErrDeployKeyNotExist struct { + ID int64 + KeyID int64 + RepoID int64 +} + +// IsErrDeployKeyNotExist checks if an error is a ErrDeployKeyNotExist. +func IsErrDeployKeyNotExist(err error) bool { + _, ok := err.(ErrDeployKeyNotExist) + return ok +} + +func (err ErrDeployKeyNotExist) Error() string { + return fmt.Sprintf("Deploy key does not exist [id: %d, key_id: %d, repo_id: %d]", err.ID, err.KeyID, err.RepoID) +} + +func (err ErrDeployKeyNotExist) Unwrap() error { + return util.ErrNotExist +} + +// ErrDeployKeyAlreadyExist represents a "DeployKeyAlreadyExist" kind of error. +type ErrDeployKeyAlreadyExist struct { + KeyID int64 + RepoID int64 +} + +// IsErrDeployKeyAlreadyExist checks if an error is a ErrDeployKeyAlreadyExist. +func IsErrDeployKeyAlreadyExist(err error) bool { + _, ok := err.(ErrDeployKeyAlreadyExist) + return ok +} + +func (err ErrDeployKeyAlreadyExist) Error() string { + return fmt.Sprintf("public key already exists [key_id: %d, repo_id: %d]", err.KeyID, err.RepoID) +} + +func (err ErrDeployKeyAlreadyExist) Unwrap() error { + return util.ErrAlreadyExist +} + +// ErrDeployKeyNameAlreadyUsed represents a "DeployKeyNameAlreadyUsed" kind of error. +type ErrDeployKeyNameAlreadyUsed struct { + RepoID int64 + Name string +} + +// IsErrDeployKeyNameAlreadyUsed checks if an error is a ErrDeployKeyNameAlreadyUsed. +func IsErrDeployKeyNameAlreadyUsed(err error) bool { + _, ok := err.(ErrDeployKeyNameAlreadyUsed) + return ok +} + +func (err ErrDeployKeyNameAlreadyUsed) Error() string { + return fmt.Sprintf("public key with name already exists [repo_id: %d, name: %s]", err.RepoID, err.Name) +} + +func (err ErrDeployKeyNameAlreadyUsed) Unwrap() error { + return util.ErrNotExist +} + +// ErrSSHInvalidTokenSignature represents a "ErrSSHInvalidTokenSignature" kind of error. +type ErrSSHInvalidTokenSignature struct { + Wrapped error + Fingerprint string +} + +// IsErrSSHInvalidTokenSignature checks if an error is a ErrSSHInvalidTokenSignature. +func IsErrSSHInvalidTokenSignature(err error) bool { + _, ok := err.(ErrSSHInvalidTokenSignature) + return ok +} + +func (err ErrSSHInvalidTokenSignature) Error() string { + return "the provided signature does not sign the token with the provided key" +} + +func (err ErrSSHInvalidTokenSignature) Unwrap() error { + return util.ErrInvalidArgument +} diff --git a/models/asymkey/gpg_key.go b/models/asymkey/gpg_key.go new file mode 100644 index 0000000..6e2914e --- /dev/null +++ b/models/asymkey/gpg_key.go @@ -0,0 +1,273 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + "fmt" + "strings" + "time" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/packet" + "xorm.io/builder" +) + +// GPGKey represents a GPG key. +type GPGKey struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"INDEX NOT NULL"` + KeyID string `xorm:"INDEX CHAR(16) NOT NULL"` + PrimaryKeyID string `xorm:"CHAR(16)"` + Content string `xorm:"MEDIUMTEXT NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + ExpiredUnix timeutil.TimeStamp + AddedUnix timeutil.TimeStamp + SubsKey []*GPGKey `xorm:"-"` + Emails []*user_model.EmailAddress + Verified bool `xorm:"NOT NULL DEFAULT false"` + CanSign bool + CanEncryptComms bool + CanEncryptStorage bool + CanCertify bool +} + +func init() { + db.RegisterModel(new(GPGKey)) +} + +// BeforeInsert will be invoked by XORM before inserting a record +func (key *GPGKey) BeforeInsert() { + key.AddedUnix = timeutil.TimeStampNow() +} + +func (key *GPGKey) LoadSubKeys(ctx context.Context) error { + if err := db.GetEngine(ctx).Where("primary_key_id=?", key.KeyID).Find(&key.SubsKey); err != nil { + return fmt.Errorf("find Sub GPGkeys[%s]: %v", key.KeyID, err) + } + return nil +} + +// PaddedKeyID show KeyID padded to 16 characters +func (key *GPGKey) PaddedKeyID() string { + return PaddedKeyID(key.KeyID) +} + +// PaddedKeyID show KeyID padded to 16 characters +func PaddedKeyID(keyID string) string { + if len(keyID) > 15 { + return keyID + } + zeros := "0000000000000000" + return zeros[0:16-len(keyID)] + keyID +} + +type FindGPGKeyOptions struct { + db.ListOptions + OwnerID int64 + KeyID string + IncludeSubKeys bool +} + +func (opts FindGPGKeyOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if !opts.IncludeSubKeys { + cond = cond.And(builder.Eq{"primary_key_id": ""}) + } + + if opts.OwnerID > 0 { + cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) + } + if opts.KeyID != "" { + cond = cond.And(builder.Eq{"key_id": opts.KeyID}) + } + return cond +} + +func GetGPGKeyForUserByID(ctx context.Context, ownerID, keyID int64) (*GPGKey, error) { + key := new(GPGKey) + has, err := db.GetEngine(ctx).Where("id=? AND owner_id=?", keyID, ownerID).Get(key) + if err != nil { + return nil, err + } else if !has { + return nil, ErrGPGKeyNotExist{keyID} + } + return key, nil +} + +// GPGKeyToEntity retrieve the imported key and the traducted entity +func GPGKeyToEntity(ctx context.Context, k *GPGKey) (*openpgp.Entity, error) { + impKey, err := GetGPGImportByKeyID(ctx, k.KeyID) + if err != nil { + return nil, err + } + keys, err := checkArmoredGPGKeyString(impKey.Content) + if err != nil { + return nil, err + } + return keys[0], err +} + +// parseSubGPGKey parse a sub Key +func parseSubGPGKey(ownerID int64, primaryID string, pubkey *packet.PublicKey, expiry time.Time) (*GPGKey, error) { + content, err := base64EncPubKey(pubkey) + if err != nil { + return nil, err + } + return &GPGKey{ + OwnerID: ownerID, + KeyID: pubkey.KeyIdString(), + PrimaryKeyID: primaryID, + Content: content, + CreatedUnix: timeutil.TimeStamp(pubkey.CreationTime.Unix()), + ExpiredUnix: timeutil.TimeStamp(expiry.Unix()), + CanSign: pubkey.CanSign(), + CanEncryptComms: pubkey.PubKeyAlgo.CanEncrypt(), + CanEncryptStorage: pubkey.PubKeyAlgo.CanEncrypt(), + CanCertify: pubkey.PubKeyAlgo.CanSign(), + }, nil +} + +// parseGPGKey parse a PrimaryKey entity (primary key + subs keys + self-signature) +func parseGPGKey(ctx context.Context, ownerID int64, e *openpgp.Entity, verified bool) (*GPGKey, error) { + pubkey := e.PrimaryKey + expiry := getExpiryTime(e) + + // Parse Subkeys + subkeys := make([]*GPGKey, len(e.Subkeys)) + for i, k := range e.Subkeys { + subKeyExpiry := expiry + if k.Sig.KeyLifetimeSecs != nil { + subKeyExpiry = k.PublicKey.CreationTime.Add(time.Duration(*k.Sig.KeyLifetimeSecs) * time.Second) + } + + subs, err := parseSubGPGKey(ownerID, pubkey.KeyIdString(), k.PublicKey, subKeyExpiry) + if err != nil { + return nil, ErrGPGKeyParsing{ParseError: err} + } + subkeys[i] = subs + } + + // Check emails + userEmails, err := user_model.GetEmailAddresses(ctx, ownerID) + if err != nil { + return nil, err + } + + emails := make([]*user_model.EmailAddress, 0, len(e.Identities)) + for _, ident := range e.Identities { + // Check if the identity is revoked. + if ident.Revoked(time.Now()) { + continue + } + email := strings.ToLower(strings.TrimSpace(ident.UserId.Email)) + for _, e := range userEmails { + if e.IsActivated && e.LowerEmail == email { + emails = append(emails, e) + break + } + } + } + + if !verified { + // In the case no email as been found + if len(emails) == 0 { + failedEmails := make([]string, 0, len(e.Identities)) + for _, ident := range e.Identities { + failedEmails = append(failedEmails, ident.UserId.Email) + } + return nil, ErrGPGNoEmailFound{failedEmails, e.PrimaryKey.KeyIdString()} + } + } + + content, err := base64EncPubKey(pubkey) + if err != nil { + return nil, err + } + return &GPGKey{ + OwnerID: ownerID, + KeyID: pubkey.KeyIdString(), + PrimaryKeyID: "", + Content: content, + CreatedUnix: timeutil.TimeStamp(pubkey.CreationTime.Unix()), + ExpiredUnix: timeutil.TimeStamp(expiry.Unix()), + Emails: emails, + SubsKey: subkeys, + Verified: verified, + CanSign: pubkey.CanSign(), + CanEncryptComms: pubkey.PubKeyAlgo.CanEncrypt(), + CanEncryptStorage: pubkey.PubKeyAlgo.CanEncrypt(), + CanCertify: pubkey.PubKeyAlgo.CanSign(), + }, nil +} + +// deleteGPGKey does the actual key deletion +func deleteGPGKey(ctx context.Context, 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 := db.GetEngine(ctx).Where("key_id=?", keyID).Delete(new(GPGKeyImport)) + if err != nil { + return n, err + } + return db.GetEngine(ctx).Where("key_id=?", keyID).Or("primary_key_id=?", keyID).Delete(new(GPGKey)) +} + +// DeleteGPGKey deletes GPG key information in database. +func DeleteGPGKey(ctx context.Context, doer *user_model.User, id int64) (err error) { + key, err := GetGPGKeyForUserByID(ctx, doer.ID, id) + if err != nil { + if IsErrGPGKeyNotExist(err) { + return nil + } + return fmt.Errorf("GetPublicKeyByID: %w", err) + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + if _, err = deleteGPGKey(ctx, key.KeyID); err != nil { + return err + } + + return committer.Commit() +} + +func checkKeyEmails(ctx context.Context, email string, keys ...*GPGKey) (bool, string) { + uid := int64(0) + var userEmails []*user_model.EmailAddress + var user *user_model.User + for _, key := range keys { + for _, e := range key.Emails { + if e.IsActivated && (email == "" || strings.EqualFold(e.Email, email)) { + return true, e.Email + } + } + if key.Verified && key.OwnerID != 0 { + if uid != key.OwnerID { + userEmails, _ = user_model.GetEmailAddresses(ctx, key.OwnerID) + uid = key.OwnerID + user = &user_model.User{ID: uid} + _, _ = user_model.GetUser(ctx, user) + } + for _, e := range userEmails { + if e.IsActivated && (email == "" || strings.EqualFold(e.Email, email)) { + return true, e.Email + } + } + if user.KeepEmailPrivate && strings.EqualFold(email, user.GetEmail()) { + return true, user.GetEmail() + } + } + } + return false, email +} diff --git a/models/asymkey/gpg_key_add.go b/models/asymkey/gpg_key_add.go new file mode 100644 index 0000000..6c0f6e0 --- /dev/null +++ b/models/asymkey/gpg_key_add.go @@ -0,0 +1,167 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + + "github.com/ProtonMail/go-crypto/openpgp" +) + +// __________________ ________ ____ __. +// / _____/\______ \/ _____/ | |/ _|____ ___.__. +// / \ ___ | ___/ \ ___ | <_/ __ < | | +// \ \_\ \| | \ \_\ \ | | \ ___/\___ | +// \______ /|____| \______ / |____|__ \___ > ____| +// \/ \/ \/ \/\/ +// _____ .___ .___ +// / _ \ __| _/__| _/ +// / /_\ \ / __ |/ __ | +// / | \/ /_/ / /_/ | +// \____|__ /\____ \____ | +// \/ \/ \/ + +// This file contains functions relating to adding GPG Keys + +// addGPGKey add key, import and subkeys to database +func addGPGKey(ctx context.Context, key *GPGKey, content string) (err error) { + // Add GPGKeyImport + if err = db.Insert(ctx, &GPGKeyImport{ + KeyID: key.KeyID, + Content: content, + }); err != nil { + return err + } + // Save GPG primary key. + if err = db.Insert(ctx, key); err != nil { + return err + } + // Save GPG subs key. + for _, subkey := range key.SubsKey { + if err := addGPGSubKey(ctx, subkey); err != nil { + return err + } + } + return nil +} + +// addGPGSubKey add subkeys to database +func addGPGSubKey(ctx context.Context, key *GPGKey) (err error) { + // Save GPG primary key. + if err = db.Insert(ctx, key); err != nil { + return err + } + // Save GPG subs key. + for _, subkey := range key.SubsKey { + if err := addGPGSubKey(ctx, subkey); err != nil { + return err + } + } + return nil +} + +// AddGPGKey adds new public key to database. +func AddGPGKey(ctx context.Context, ownerID int64, content, token, signature string) ([]*GPGKey, error) { + ekeys, err := checkArmoredGPGKeyString(content) + if err != nil { + return nil, err + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return nil, err + } + defer committer.Close() + + keys := make([]*GPGKey, 0, len(ekeys)) + + verified := false + // Handle provided signature + if signature != "" { + signer, err := openpgp.CheckArmoredDetachedSignature(ekeys, strings.NewReader(token), strings.NewReader(signature), nil) + if err != nil { + signer, err = openpgp.CheckArmoredDetachedSignature(ekeys, strings.NewReader(token+"\n"), strings.NewReader(signature), nil) + } + if err != nil { + signer, err = openpgp.CheckArmoredDetachedSignature(ekeys, strings.NewReader(token+"\r\n"), strings.NewReader(signature), nil) + } + if err != nil { + log.Error("Unable to validate token signature. Error: %v", err) + return nil, ErrGPGInvalidTokenSignature{ + ID: ekeys[0].PrimaryKey.KeyIdString(), + Wrapped: err, + } + } + ekeys = []*openpgp.Entity{signer} + verified = true + } + + if len(ekeys) > 1 { + id2key := map[string]*openpgp.Entity{} + newEKeys := make([]*openpgp.Entity, 0, len(ekeys)) + for _, ekey := range ekeys { + id := ekey.PrimaryKey.KeyIdString() + if original, has := id2key[id]; has { + // Coalesce this with the other one + for _, subkey := range ekey.Subkeys { + if subkey.PublicKey == nil { + continue + } + found := false + + for _, originalSubkey := range original.Subkeys { + if originalSubkey.PublicKey == nil { + continue + } + if originalSubkey.PublicKey.KeyId == subkey.PublicKey.KeyId { + found = true + break + } + } + if !found { + original.Subkeys = append(original.Subkeys, subkey) + } + } + for name, identity := range ekey.Identities { + if _, has := original.Identities[name]; has { + continue + } + original.Identities[name] = identity + } + continue + } + id2key[id] = ekey + newEKeys = append(newEKeys, ekey) + } + ekeys = newEKeys + } + + for _, ekey := range ekeys { + // Key ID cannot be duplicated. + has, err := db.GetEngine(ctx).Where("key_id=?", ekey.PrimaryKey.KeyIdString()). + Get(new(GPGKey)) + if err != nil { + return nil, err + } else if has { + return nil, ErrGPGKeyIDAlreadyUsed{ekey.PrimaryKey.KeyIdString()} + } + + // Get DB session + + key, err := parseGPGKey(ctx, ownerID, ekey, verified) + if err != nil { + return nil, err + } + + if err = addGPGKey(ctx, key, content); err != nil { + return nil, err + } + keys = append(keys, key) + } + return keys, committer.Commit() +} diff --git a/models/asymkey/gpg_key_commit_verification.go b/models/asymkey/gpg_key_commit_verification.go new file mode 100644 index 0000000..9aa6064 --- /dev/null +++ b/models/asymkey/gpg_key_commit_verification.go @@ -0,0 +1,63 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" +) + +// __________________ ________ ____ __. +// / _____/\______ \/ _____/ | |/ _|____ ___.__. +// / \ ___ | ___/ \ ___ | <_/ __ < | | +// \ \_\ \| | \ \_\ \ | | \ ___/\___ | +// \______ /|____| \______ / |____|__ \___ > ____| +// \/ \/ \/ \/\/ +// _________ .__ __ +// \_ ___ \ ____ _____ _____ |__|/ |_ +// / \ \/ / _ \ / \ / \| \ __\ +// \ \___( <_> ) Y Y \ Y Y \ || | +// \______ /\____/|__|_| /__|_| /__||__| +// \/ \/ \/ +// ____ ____ .__ _____.__ __ .__ +// \ \ / /___________|__|/ ____\__| ____ _____ _/ |_|__| ____ ____ +// \ Y // __ \_ __ \ \ __\| |/ ___\\__ \\ __\ |/ _ \ / \ +// \ /\ ___/| | \/ || | | \ \___ / __ \| | | ( <_> ) | \ +// \___/ \___ >__| |__||__| |__|\___ >____ /__| |__|\____/|___| / +// \/ \/ \/ \/ + +// This file provides functions relating commit verification + +// SignCommit represents a commit with validation of signature. +type SignCommit struct { + Verification *ObjectVerification + *user_model.UserCommit +} + +// ParseCommitsWithSignature checks if signaute of commits are corresponding to users gpg keys. +func ParseCommitsWithSignature(ctx context.Context, oldCommits []*user_model.UserCommit, repoTrustModel repo_model.TrustModelType, isOwnerMemberCollaborator func(*user_model.User) (bool, error)) []*SignCommit { + newCommits := make([]*SignCommit, 0, len(oldCommits)) + keyMap := map[string]bool{} + + for _, c := range oldCommits { + o := commitToGitObject(c.Commit) + signCommit := &SignCommit{ + UserCommit: c, + Verification: ParseObjectWithSignature(ctx, &o), + } + + _ = CalculateTrustStatus(signCommit.Verification, repoTrustModel, isOwnerMemberCollaborator, &keyMap) + + newCommits = append(newCommits, signCommit) + } + return newCommits +} + +func ParseCommitWithSignature(ctx context.Context, c *git.Commit) *ObjectVerification { + o := commitToGitObject(c) + return ParseObjectWithSignature(ctx, &o) +} diff --git a/models/asymkey/gpg_key_common.go b/models/asymkey/gpg_key_common.go new file mode 100644 index 0000000..db1912c --- /dev/null +++ b/models/asymkey/gpg_key_common.go @@ -0,0 +1,146 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "bytes" + "crypto" + "encoding/base64" + "fmt" + "hash" + "io" + "strings" + "time" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp/packet" +) + +// __________________ ________ ____ __. +// / _____/\______ \/ _____/ | |/ _|____ ___.__. +// / \ ___ | ___/ \ ___ | <_/ __ < | | +// \ \_\ \| | \ \_\ \ | | \ ___/\___ | +// \______ /|____| \______ / |____|__ \___ > ____| +// \/ \/ \/ \/\/ +// _________ +// \_ ___ \ ____ _____ _____ ____ ____ +// / \ \/ / _ \ / \ / \ / _ \ / \ +// \ \___( <_> ) Y Y \ Y Y ( <_> ) | \ +// \______ /\____/|__|_| /__|_| /\____/|___| / +// \/ \/ \/ \/ + +// This file provides common functions relating to GPG Keys + +// 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.EntityList, error) { + list, err := openpgp.ReadArmoredKeyRing(strings.NewReader(content)) + if err != nil { + return nil, ErrGPGKeyParsing{err} + } + return list, nil +} + +// base64EncPubKey encode public key content to base 64 +func base64EncPubKey(pubkey *packet.PublicKey) (string, error) { + var w bytes.Buffer + err := pubkey.Serialize(&w) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(w.Bytes()), nil +} + +func readerFromBase64(s string) (io.Reader, error) { + bs, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return nil, err + } + return bytes.NewBuffer(bs), 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 +} + +// getExpiryTime extract the expire time of primary key based on sig +func getExpiryTime(e *openpgp.Entity) time.Time { + expiry := time.Time{} + // Extract self-sign for expire date based on : https://github.com/golang/crypto/blob/master/openpgp/keys.go#L165 + var selfSig *packet.Signature + for _, ident := range e.Identities { + if selfSig == nil { + selfSig = ident.SelfSignature + } else if ident.SelfSignature != nil && ident.SelfSignature.IsPrimaryId != nil && *ident.SelfSignature.IsPrimaryId { + selfSig = ident.SelfSignature + break + } + } + if selfSig.KeyLifetimeSecs != nil { + expiry = e.PrimaryKey.CreationTime.Add(time.Duration(*selfSig.KeyLifetimeSecs) * time.Second) + } + return expiry +} + +func populateHash(hashFunc crypto.Hash, msg []byte) (hash.Hash, error) { + h := hashFunc.New() + if _, err := h.Write(msg); err != nil { + return nil, err + } + return h, nil +} + +// readArmoredSign read an armored signature block with the given type. https://sourcegraph.com/github.com/golang/crypto/-/blob/openpgp/read.go#L24:6-24:17 +func readArmoredSign(r io.Reader) (body io.Reader, err error) { + block, err := armor.Decode(r) + if err != nil { + return nil, err + } + if block.Type != openpgp.SignatureType { + return nil, fmt.Errorf("expected %q, got: %s", openpgp.SignatureType, block.Type) + } + return block.Body, nil +} + +func extractSignature(s string) (*packet.Signature, error) { + r, err := readArmoredSign(strings.NewReader(s)) + if err != nil { + return nil, fmt.Errorf("Failed to read signature armor") + } + p, err := packet.Read(r) + if err != nil { + return nil, fmt.Errorf("Failed to read signature packet") + } + sig, ok := p.(*packet.Signature) + if !ok { + return nil, fmt.Errorf("Packet is not a signature") + } + return sig, nil +} + +func tryGetKeyIDFromSignature(sig *packet.Signature) string { + if sig.IssuerKeyId != nil && (*sig.IssuerKeyId) != 0 { + return fmt.Sprintf("%016X", *sig.IssuerKeyId) + } + if len(sig.IssuerFingerprint) > 0 { + return fmt.Sprintf("%016X", sig.IssuerFingerprint[12:20]) + } + return "" +} diff --git a/models/asymkey/gpg_key_import.go b/models/asymkey/gpg_key_import.go new file mode 100644 index 0000000..c9d46d2 --- /dev/null +++ b/models/asymkey/gpg_key_import.go @@ -0,0 +1,47 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + + "code.gitea.io/gitea/models/db" +) + +// __________________ ________ ____ __. +// / _____/\______ \/ _____/ | |/ _|____ ___.__. +// / \ ___ | ___/ \ ___ | <_/ __ < | | +// \ \_\ \| | \ \_\ \ | | \ ___/\___ | +// \______ /|____| \______ / |____|__ \___ > ____| +// \/ \/ \/ \/\/ +// .___ __ +// | | _____ ______ ____________/ |_ +// | |/ \\____ \ / _ \_ __ \ __\ +// | | Y Y \ |_> > <_> ) | \/| | +// |___|__|_| / __/ \____/|__| |__| +// \/|__| + +// This file contains functions related to the original import of a key + +// GPGKeyImport the original import of key +type GPGKeyImport struct { + KeyID string `xorm:"pk CHAR(16) NOT NULL"` + Content string `xorm:"MEDIUMTEXT NOT NULL"` +} + +func init() { + db.RegisterModel(new(GPGKeyImport)) +} + +// GetGPGImportByKeyID returns the import public armored key by given KeyID. +func GetGPGImportByKeyID(ctx context.Context, keyID string) (*GPGKeyImport, error) { + key := new(GPGKeyImport) + has, err := db.GetEngine(ctx).ID(keyID).Get(key) + if err != nil { + return nil, err + } else if !has { + return nil, ErrGPGKeyImportNotExist{keyID} + } + return key, nil +} diff --git a/models/asymkey/gpg_key_list.go b/models/asymkey/gpg_key_list.go new file mode 100644 index 0000000..89548e4 --- /dev/null +++ b/models/asymkey/gpg_key_list.go @@ -0,0 +1,38 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + + "code.gitea.io/gitea/models/db" +) + +type GPGKeyList []*GPGKey + +func (keys GPGKeyList) keyIDs() []string { + ids := make([]string, len(keys)) + for i, key := range keys { + ids[i] = key.KeyID + } + return ids +} + +func (keys GPGKeyList) LoadSubKeys(ctx context.Context) error { + subKeys := make([]*GPGKey, 0, len(keys)) + if err := db.GetEngine(ctx).In("primary_key_id", keys.keyIDs()).Find(&subKeys); err != nil { + return err + } + subKeysMap := make(map[string][]*GPGKey, len(subKeys)) + for _, key := range subKeys { + subKeysMap[key.PrimaryKeyID] = append(subKeysMap[key.PrimaryKeyID], key) + } + + for _, key := range keys { + if subKeys, ok := subKeysMap[key.KeyID]; ok { + key.SubsKey = subKeys + } + } + return nil +} diff --git a/models/asymkey/gpg_key_object_verification.go b/models/asymkey/gpg_key_object_verification.go new file mode 100644 index 0000000..24d72a5 --- /dev/null +++ b/models/asymkey/gpg_key_object_verification.go @@ -0,0 +1,520 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + "fmt" + "hash" + "strings" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + + "github.com/ProtonMail/go-crypto/openpgp/packet" +) + +// This file provides functions related to object (commit, tag) verification + +// ObjectVerification represents a commit validation of signature +type ObjectVerification struct { + Verified bool + Warning bool + Reason string + SigningUser *user_model.User + CommittingUser *user_model.User + SigningEmail string + SigningKey *GPGKey + SigningSSHKey *PublicKey + TrustStatus string +} + +const ( + // BadSignature is used as the reason when the signature has a KeyID that is in the db + // but no key that has that ID verifies the signature. This is a suspicious failure. + BadSignature = "gpg.error.probable_bad_signature" + // BadDefaultSignature is used as the reason when the signature has a KeyID that matches the + // default Key but is not verified by the default key. This is a suspicious failure. + BadDefaultSignature = "gpg.error.probable_bad_default_signature" + // NoKeyFound is used as the reason when no key can be found to verify the signature. + NoKeyFound = "gpg.error.no_gpg_keys_found" +) + +type GitObject struct { + ID git.ObjectID + Committer *git.Signature + Signature *git.ObjectSignature + Commit *git.Commit +} + +func commitToGitObject(c *git.Commit) GitObject { + return GitObject{ + ID: c.ID, + Committer: c.Committer, + Signature: c.Signature, + Commit: c, + } +} + +func tagToGitObject(t *git.Tag, gitRepo *git.Repository) GitObject { + commit, _ := t.Commit(gitRepo) + return GitObject{ + ID: t.ID, + Committer: t.Tagger, + Signature: t.Signature, + Commit: commit, + } +} + +// ParseObjectWithSignature check if signature is good against keystore. +func ParseObjectWithSignature(ctx context.Context, c *GitObject) *ObjectVerification { + var committer *user_model.User + if c.Committer != nil { + var err error + // Find Committer account + committer, err = user_model.GetUserByEmail(ctx, c.Committer.Email) // This finds the user by primary email or activated email so commit will not be valid if email is not + if err != nil { // Skipping not user for committer + committer = &user_model.User{ + Name: c.Committer.Name, + Email: c.Committer.Email, + } + // We can expect this to often be an ErrUserNotExist. in the case + // it is not, however, it is important to log it. + if !user_model.IsErrUserNotExist(err) { + log.Error("GetUserByEmail: %v", err) + return &ObjectVerification{ + CommittingUser: committer, + Verified: false, + Reason: "gpg.error.no_committer_account", + } + } + } + } + + // If no signature just report the committer + if c.Signature == nil { + return &ObjectVerification{ + CommittingUser: committer, + Verified: false, // Default value + Reason: "gpg.error.not_signed_commit", // Default value + } + } + + // If this a SSH signature handle it differently + if strings.HasPrefix(c.Signature.Signature, "-----BEGIN SSH SIGNATURE-----") { + return ParseObjectWithSSHSignature(ctx, c, committer) + } + + // Parsing signature + sig, err := extractSignature(c.Signature.Signature) + if err != nil { // Skipping failed to extract sign + log.Error("SignatureRead err: %v", err) + return &ObjectVerification{ + CommittingUser: committer, + Verified: false, + Reason: "gpg.error.extract_sign", + } + } + + keyID := tryGetKeyIDFromSignature(sig) + defaultReason := NoKeyFound + + // First check if the sig has a keyID and if so just look at that + if commitVerification := hashAndVerifyForKeyID( + ctx, + sig, + c.Signature.Payload, + committer, + keyID, + setting.AppName, + ""); commitVerification != nil { + if commitVerification.Reason == BadSignature { + defaultReason = BadSignature + } else { + return commitVerification + } + } + + // Now try to associate the signature with the committer, if present + if committer.ID != 0 { + keys, err := db.Find[GPGKey](ctx, FindGPGKeyOptions{ + OwnerID: committer.ID, + }) + if err != nil { // Skipping failed to get gpg keys of user + log.Error("ListGPGKeys: %v", err) + return &ObjectVerification{ + CommittingUser: committer, + Verified: false, + Reason: "gpg.error.failed_retrieval_gpg_keys", + } + } + + if err := GPGKeyList(keys).LoadSubKeys(ctx); err != nil { + log.Error("LoadSubKeys: %v", err) + return &ObjectVerification{ + CommittingUser: committer, + Verified: false, + Reason: "gpg.error.failed_retrieval_gpg_keys", + } + } + + committerEmailAddresses, _ := user_model.GetEmailAddresses(ctx, committer.ID) + activated := false + for _, e := range committerEmailAddresses { + if e.IsActivated && strings.EqualFold(e.Email, c.Committer.Email) { + activated = true + break + } + } + + for _, k := range keys { + // Pre-check (& optimization) that emails attached to key can be attached to the committer email and can validate + canValidate := false + email := "" + if k.Verified && activated { + canValidate = true + email = c.Committer.Email + } + if !canValidate { + for _, e := range k.Emails { + if e.IsActivated && strings.EqualFold(e.Email, c.Committer.Email) { + canValidate = true + email = e.Email + break + } + } + } + if !canValidate { + continue // Skip this key + } + + commitVerification := hashAndVerifyWithSubKeysObjectVerification(sig, c.Signature.Payload, k, committer, committer, email) + if commitVerification != nil { + return commitVerification + } + } + } + + if setting.Repository.Signing.SigningKey != "" && setting.Repository.Signing.SigningKey != "default" && setting.Repository.Signing.SigningKey != "none" { + // OK we should try the default key + gpgSettings := git.GPGSettings{ + Sign: true, + KeyID: setting.Repository.Signing.SigningKey, + Name: setting.Repository.Signing.SigningName, + Email: setting.Repository.Signing.SigningEmail, + } + if err := gpgSettings.LoadPublicKeyContent(); err != nil { + log.Error("Error getting default signing key: %s %v", gpgSettings.KeyID, err) + } else if commitVerification := verifyWithGPGSettings(ctx, &gpgSettings, sig, c.Signature.Payload, committer, keyID); commitVerification != nil { + if commitVerification.Reason == BadSignature { + defaultReason = BadSignature + } else { + return commitVerification + } + } + } + + defaultGPGSettings, err := c.Commit.GetRepositoryDefaultPublicGPGKey(false) + if err != nil { + log.Error("Error getting default public gpg key: %v", err) + } else if defaultGPGSettings == nil { + log.Warn("Unable to get defaultGPGSettings for unattached commit: %s", c.Commit.ID.String()) + } else if defaultGPGSettings.Sign { + if commitVerification := verifyWithGPGSettings(ctx, defaultGPGSettings, sig, c.Signature.Payload, committer, keyID); commitVerification != nil { + if commitVerification.Reason == BadSignature { + defaultReason = BadSignature + } else { + return commitVerification + } + } + } + + return &ObjectVerification{ // Default at this stage + CommittingUser: committer, + Verified: false, + Warning: defaultReason != NoKeyFound, + Reason: defaultReason, + SigningKey: &GPGKey{ + KeyID: keyID, + }, + } +} + +func verifyWithGPGSettings(ctx context.Context, gpgSettings *git.GPGSettings, sig *packet.Signature, payload string, committer *user_model.User, keyID string) *ObjectVerification { + // First try to find the key in the db + if commitVerification := hashAndVerifyForKeyID(ctx, sig, payload, committer, gpgSettings.KeyID, gpgSettings.Name, gpgSettings.Email); commitVerification != nil { + return commitVerification + } + + // Otherwise we have to parse the key + ekeys, err := checkArmoredGPGKeyString(gpgSettings.PublicKeyContent) + if err != nil { + log.Error("Unable to get default signing key: %v", err) + return &ObjectVerification{ + CommittingUser: committer, + Verified: false, + Reason: "gpg.error.generate_hash", + } + } + for _, ekey := range ekeys { + pubkey := ekey.PrimaryKey + content, err := base64EncPubKey(pubkey) + if err != nil { + return &ObjectVerification{ + CommittingUser: committer, + Verified: false, + Reason: "gpg.error.generate_hash", + } + } + k := &GPGKey{ + Content: content, + CanSign: pubkey.CanSign(), + KeyID: pubkey.KeyIdString(), + } + for _, subKey := range ekey.Subkeys { + content, err := base64EncPubKey(subKey.PublicKey) + if err != nil { + return &ObjectVerification{ + CommittingUser: committer, + Verified: false, + Reason: "gpg.error.generate_hash", + } + } + k.SubsKey = append(k.SubsKey, &GPGKey{ + Content: content, + CanSign: subKey.PublicKey.CanSign(), + KeyID: subKey.PublicKey.KeyIdString(), + }) + } + if commitVerification := hashAndVerifyWithSubKeysObjectVerification(sig, payload, k, committer, &user_model.User{ + Name: gpgSettings.Name, + Email: gpgSettings.Email, + }, gpgSettings.Email); commitVerification != nil { + return commitVerification + } + if keyID == k.KeyID { + // This is a bad situation ... We have a key id that matches our default key but the signature doesn't match. + return &ObjectVerification{ + CommittingUser: committer, + Verified: false, + Warning: true, + Reason: BadSignature, + } + } + } + return nil +} + +func verifySign(s *packet.Signature, h hash.Hash, k *GPGKey) error { + // Check if key can sign + if !k.CanSign { + return fmt.Errorf("key can not sign") + } + // Decode key + pkey, err := base64DecPubKey(k.Content) + if err != nil { + return err + } + return pkey.VerifySignature(h, s) +} + +func hashAndVerify(sig *packet.Signature, payload string, k *GPGKey) (*GPGKey, error) { + // Generating hash of commit + hash, err := populateHash(sig.Hash, []byte(payload)) + if err != nil { // Skipping as failed to generate hash + log.Error("PopulateHash: %v", err) + return nil, err + } + // We will ignore errors in verification as they don't need to be propagated up + err = verifySign(sig, hash, k) + if err != nil { + return nil, nil + } + return k, nil +} + +func hashAndVerifyWithSubKeys(sig *packet.Signature, payload string, k *GPGKey) (*GPGKey, error) { + verified, err := hashAndVerify(sig, payload, k) + if err != nil || verified != nil { + return verified, err + } + for _, sk := range k.SubsKey { + verified, err := hashAndVerify(sig, payload, sk) + if err != nil || verified != nil { + return verified, err + } + } + return nil, nil +} + +func hashAndVerifyWithSubKeysObjectVerification(sig *packet.Signature, payload string, k *GPGKey, committer, signer *user_model.User, email string) *ObjectVerification { + key, err := hashAndVerifyWithSubKeys(sig, payload, k) + if err != nil { // Skipping failed to generate hash + return &ObjectVerification{ + CommittingUser: committer, + Verified: false, + Reason: "gpg.error.generate_hash", + } + } + + if key != nil { + return &ObjectVerification{ // Everything is ok + CommittingUser: committer, + Verified: true, + Reason: fmt.Sprintf("%s / %s", signer.Name, key.KeyID), + SigningUser: signer, + SigningKey: key, + SigningEmail: email, + } + } + return nil +} + +func hashAndVerifyForKeyID(ctx context.Context, sig *packet.Signature, payload string, committer *user_model.User, keyID, name, email string) *ObjectVerification { + if keyID == "" { + return nil + } + keys, err := db.Find[GPGKey](ctx, FindGPGKeyOptions{ + KeyID: keyID, + IncludeSubKeys: true, + }) + if err != nil { + log.Error("GetGPGKeysByKeyID: %v", err) + return &ObjectVerification{ + CommittingUser: committer, + Verified: false, + Reason: "gpg.error.failed_retrieval_gpg_keys", + } + } + if len(keys) == 0 { + return nil + } + for _, key := range keys { + var primaryKeys []*GPGKey + if key.PrimaryKeyID != "" { + primaryKeys, err = db.Find[GPGKey](ctx, FindGPGKeyOptions{ + KeyID: key.PrimaryKeyID, + IncludeSubKeys: true, + }) + if err != nil { + log.Error("GetGPGKeysByKeyID: %v", err) + return &ObjectVerification{ + CommittingUser: committer, + Verified: false, + Reason: "gpg.error.failed_retrieval_gpg_keys", + } + } + } + + activated, email := checkKeyEmails(ctx, email, append([]*GPGKey{key}, primaryKeys...)...) + if !activated { + continue + } + + signer := &user_model.User{ + Name: name, + Email: email, + } + if key.OwnerID != 0 { + owner, err := user_model.GetUserByID(ctx, key.OwnerID) + if err == nil { + signer = owner + } else if !user_model.IsErrUserNotExist(err) { + log.Error("Failed to user_model.GetUserByID: %d for key ID: %d (%s) %v", key.OwnerID, key.ID, key.KeyID, err) + return &ObjectVerification{ + CommittingUser: committer, + Verified: false, + Reason: "gpg.error.no_committer_account", + } + } + } + commitVerification := hashAndVerifyWithSubKeysObjectVerification(sig, payload, key, committer, signer, email) + if commitVerification != nil { + return commitVerification + } + } + // This is a bad situation ... We have a key id that is in our database but the signature doesn't match. + return &ObjectVerification{ + CommittingUser: committer, + Verified: false, + Warning: true, + Reason: BadSignature, + } +} + +// CalculateTrustStatus will calculate the TrustStatus for a commit verification within a repository +// There are several trust models in Gitea +func CalculateTrustStatus(verification *ObjectVerification, repoTrustModel repo_model.TrustModelType, isOwnerMemberCollaborator func(*user_model.User) (bool, error), keyMap *map[string]bool) error { + if !verification.Verified { + return nil + } + + // In the Committer trust model a signature is trusted if it matches the committer + // - it doesn't matter if they're a collaborator, the owner, Gitea or Github + // NB: This model is commit verification only + if repoTrustModel == repo_model.CommitterTrustModel { + // default to "unmatched" + verification.TrustStatus = "unmatched" + + // We can only verify against users in our database but the default key will match + // against by email if it is not in the db. + if (verification.SigningUser.ID != 0 && + verification.CommittingUser.ID == verification.SigningUser.ID) || + (verification.SigningUser.ID == 0 && verification.CommittingUser.ID == 0 && + verification.SigningUser.Email == verification.CommittingUser.Email) { + verification.TrustStatus = "trusted" + } + return nil + } + + // Now we drop to the more nuanced trust models... + verification.TrustStatus = "trusted" + + if verification.SigningUser.ID == 0 { + // This commit is signed by the default key - but this key is not assigned to a user in the DB. + + // However in the repo_model.CollaboratorCommitterTrustModel we cannot mark this as trusted + // unless the default key matches the email of a non-user. + if repoTrustModel == repo_model.CollaboratorCommitterTrustModel && (verification.CommittingUser.ID != 0 || + verification.SigningUser.Email != verification.CommittingUser.Email) { + verification.TrustStatus = "untrusted" + } + return nil + } + + // Check we actually have a GPG SigningKey + var err error + if verification.SigningKey != nil { + var isMember bool + if keyMap != nil { + var has bool + isMember, has = (*keyMap)[verification.SigningKey.KeyID] + if !has { + isMember, err = isOwnerMemberCollaborator(verification.SigningUser) + (*keyMap)[verification.SigningKey.KeyID] = isMember + } + } else { + isMember, err = isOwnerMemberCollaborator(verification.SigningUser) + } + + if !isMember { + verification.TrustStatus = "untrusted" + if verification.CommittingUser.ID != verification.SigningUser.ID { + // The committing user and the signing user are not the same + // This should be marked as questionable unless the signing user is a collaborator/team member etc. + verification.TrustStatus = "unmatched" + } + } else if repoTrustModel == repo_model.CollaboratorCommitterTrustModel && verification.CommittingUser.ID != verification.SigningUser.ID { + // The committing user and the signing user are not the same and our trustmodel states that they must match + verification.TrustStatus = "unmatched" + } + } + + return err +} diff --git a/models/asymkey/gpg_key_tag_verification.go b/models/asymkey/gpg_key_tag_verification.go new file mode 100644 index 0000000..5fd3983 --- /dev/null +++ b/models/asymkey/gpg_key_tag_verification.go @@ -0,0 +1,15 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + + "code.gitea.io/gitea/modules/git" +) + +func ParseTagWithSignature(ctx context.Context, gitRepo *git.Repository, t *git.Tag) *ObjectVerification { + o := tagToGitObject(t, gitRepo) + return ParseObjectWithSignature(ctx, &o) +} diff --git a/models/asymkey/gpg_key_test.go b/models/asymkey/gpg_key_test.go new file mode 100644 index 0000000..e9aa9cf --- /dev/null +++ b/models/asymkey/gpg_key_test.go @@ -0,0 +1,466 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "testing" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "github.com/ProtonMail/go-crypto/openpgp/packet" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckArmoredGPGKeyString(t *testing.T) { + testGPGArmor := `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBFh91QoBCADciaDd7aqegYkn4ZIG7J0p1CRwpqMGjxFroJEMg6M1ZiuEVTRv +z49P4kcr1+98NvFmcNc+x5uJgvPCwr/N8ZW5nqBUs2yrklbFF4MeQomyZJJegP8m +/dsRT3BwIT8YMUtJuCj0iqD9vuKYfjrztcMgC1sYwcE9E9OlA0pWBvUdU2i0TIB1 +vOq6slWGvHHa5l5gPfm09idlVxfH5+I+L1uIMx5ovbiVVU5x2f1AR1T18f0t2TVN +0agFTyuoYE1ATmvJHmMcsfgM1Gpd9hIlr9vlupT2kKTPoNzVzsJsOU6Ku/Lf/bac +mF+TfSbRCtmG7dkYZ4metLj7zG/WkW8IvJARABEBAAG0HUFudG9pbmUgR0lSQVJE +IDxzYXBrQHNhcGsuZnI+iQFUBBMBCAA+FiEEEIOwJg/1vpF1itJ4roJVuKDYKOQF +Alh91QoCGwMFCQPCZwAFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AACgkQroJVuKDY +KORreggAlIkC2QjHP5tb7b0+LksB2JMXdY+UzZBcJxtNmvA7gNQaGvWRrhrbePpa +MKDP+3A4BPDBsWFbbB7N56vQ5tROpmWbNKuFOVER4S1bj0JZV0E+xkDLqt9QwQtQ +ojd7oIZJwDUwdud1PvCza2mjgBqqiFE+twbc3i9xjciCGspMniUul1eQYLxRJ0w+ +sbvSOUnujnq5ByMSz9ij00O6aiPfNQS5oB5AALfpjYZDvWAAljLVrtmlQJWZ6dZo +T/YNwsW2dECPuti8+Nmu5FxPGDTXxdbnRaeJTQ3T6q1oUVAv7yTXBx5NXfXkMa5i +iEayQIH8Joq5Ev5ja/lRGQQhArMQ2bkBDQRYfdUKAQgAv7B3coLSrOQbuTZSlgWE +QeT+7DWbmqE1LAQA1pQPcUPXLBUVd60amZJxF9nzUYcY83ylDi0gUNJS+DJGOXpT +pzX2IOuOMGbtUSeKwg5s9O4SUO7f2yCc3RGaegER5zgESxelmOXG+b/hoNt7JbdU +JtxcnLr91Jw2PBO/Xf0ZKJ01CQG2Yzdrrj6jnrHyx94seHy0i6xH1o0OuvfVMLfN +/Vbb/ZHh6ym2wHNqRX62b0VAbchcJXX/MEehXGknKTkO6dDUd+mhRgWMf9ZGRFWx +ag4qALimkf1FXtAyD0vxFYeyoWUQzrOvUsm2BxIN/986R08fhkBQnp5nz07mrU02 +cQARAQABiQE8BBgBCAAmFiEEEIOwJg/1vpF1itJ4roJVuKDYKOQFAlh91QoCGwwF +CQPCZwAACgkQroJVuKDYKOT32wf/UZqMdPn5OhyhffFzjQx7wolrf92WkF2JkxtH +6c3Htjlt/p5RhtKEeErSrNAxB4pqB7dznHaJXiOdWEZtRVXXjlNHjrokGTesqtKk +lHWtK62/MuyLdr+FdCl68F3ewuT2iu/MDv+D4HPqA47zma9xVgZ9ZNwJOpv3fCOo +RfY66UjGEnfgYifgtI5S84/mp2jaSc9UNvlZB6RSf8cfbJUL74kS2lq+xzSlf0yP +Av844q/BfRuVsJsK1NDNG09LC30B0l3LKBqlrRmRTUMHtgchdX2dY+p7GPOoSzlR +MkM/fdpyc2hY7Dl/+qFmN5MG5yGmMpQcX+RNNR222ibNC1D3wg== +=i9b7 +-----END PGP PUBLIC KEY BLOCK-----` + + key, err := checkArmoredGPGKeyString(testGPGArmor) + require.NoError(t, err, "Could not parse a valid GPG public armored rsa key", key) + // TODO verify value of key +} + +func TestCheckArmoredbrainpoolP256r1GPGKeyString(t *testing.T) { + testGPGArmor := `-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: GnuPG v2 + +mFMEV6HwkhMJKyQDAwIIAQEHAgMEUsvJO/j5dFMRRj67qeZC9fSKBsGZdOHRj2+6 +8wssmbUuLTfT/ZjIbExETyY8hFnURRGpD2Ifyz0cKjXcbXfJtrQTRm9vYmFyIDxm +b29AYmFyLmRlPoh/BBMTCAAnBQJZOsDIAhsDBQkJZgGABQsJCAcCBhUICQoLAgQW +AgMBAh4BAheAAAoJEGuJTd/DBMzmNVQA/2beUrv1yU4gyvCiPDEm3pK42cSfaL5D +muCtPCUg9hlWAP4yq6M78NW8STfsXgn6oeziMYiHSTmV14nOamLuwwDWM7hXBFeh +8JISCSskAwMCCAEBBwIDBG3A+XfINAZp1CTse2mRNgeUE5DbUtEpO8ALXKA1UQsQ +DLKq27b7zTgawgXIGUGP6mWsJ5oH7MNAJ/uKTsYmX40DAQgHiGcEGBMIAA8FAleh +8JICGwwFCQlmAYAACgkQa4lN38MEzOZwKAD/QKyerAgcvzzLaqvtap3XvpYcw9tc +OyjLLnFQiVmq7kEA/0z0CQe3ZQiQIq5zrs7Nh1XRkFAo8GlU/SGC9XFFi722 +=ZiSe +-----END PGP PUBLIC KEY BLOCK-----` + + key, err := checkArmoredGPGKeyString(testGPGArmor) + require.NoError(t, err, "Could not parse a valid GPG public armored brainpoolP256r1 key", key) + // TODO verify value of key +} + +func TestExtractSignature(t *testing.T) { + testGPGArmor := `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBFh91QoBCADciaDd7aqegYkn4ZIG7J0p1CRwpqMGjxFroJEMg6M1ZiuEVTRv +z49P4kcr1+98NvFmcNc+x5uJgvPCwr/N8ZW5nqBUs2yrklbFF4MeQomyZJJegP8m +/dsRT3BwIT8YMUtJuCj0iqD9vuKYfjrztcMgC1sYwcE9E9OlA0pWBvUdU2i0TIB1 +vOq6slWGvHHa5l5gPfm09idlVxfH5+I+L1uIMx5ovbiVVU5x2f1AR1T18f0t2TVN +0agFTyuoYE1ATmvJHmMcsfgM1Gpd9hIlr9vlupT2kKTPoNzVzsJsOU6Ku/Lf/bac +mF+TfSbRCtmG7dkYZ4metLj7zG/WkW8IvJARABEBAAG0HUFudG9pbmUgR0lSQVJE +IDxzYXBrQHNhcGsuZnI+iQFUBBMBCAA+FiEEEIOwJg/1vpF1itJ4roJVuKDYKOQF +Alh91QoCGwMFCQPCZwAFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AACgkQroJVuKDY +KORreggAlIkC2QjHP5tb7b0+LksB2JMXdY+UzZBcJxtNmvA7gNQaGvWRrhrbePpa +MKDP+3A4BPDBsWFbbB7N56vQ5tROpmWbNKuFOVER4S1bj0JZV0E+xkDLqt9QwQtQ +ojd7oIZJwDUwdud1PvCza2mjgBqqiFE+twbc3i9xjciCGspMniUul1eQYLxRJ0w+ +sbvSOUnujnq5ByMSz9ij00O6aiPfNQS5oB5AALfpjYZDvWAAljLVrtmlQJWZ6dZo +T/YNwsW2dECPuti8+Nmu5FxPGDTXxdbnRaeJTQ3T6q1oUVAv7yTXBx5NXfXkMa5i +iEayQIH8Joq5Ev5ja/lRGQQhArMQ2bkBDQRYfdUKAQgAv7B3coLSrOQbuTZSlgWE +QeT+7DWbmqE1LAQA1pQPcUPXLBUVd60amZJxF9nzUYcY83ylDi0gUNJS+DJGOXpT +pzX2IOuOMGbtUSeKwg5s9O4SUO7f2yCc3RGaegER5zgESxelmOXG+b/hoNt7JbdU +JtxcnLr91Jw2PBO/Xf0ZKJ01CQG2Yzdrrj6jnrHyx94seHy0i6xH1o0OuvfVMLfN +/Vbb/ZHh6ym2wHNqRX62b0VAbchcJXX/MEehXGknKTkO6dDUd+mhRgWMf9ZGRFWx +ag4qALimkf1FXtAyD0vxFYeyoWUQzrOvUsm2BxIN/986R08fhkBQnp5nz07mrU02 +cQARAQABiQE8BBgBCAAmFiEEEIOwJg/1vpF1itJ4roJVuKDYKOQFAlh91QoCGwwF +CQPCZwAACgkQroJVuKDYKOT32wf/UZqMdPn5OhyhffFzjQx7wolrf92WkF2JkxtH +6c3Htjlt/p5RhtKEeErSrNAxB4pqB7dznHaJXiOdWEZtRVXXjlNHjrokGTesqtKk +lHWtK62/MuyLdr+FdCl68F3ewuT2iu/MDv+D4HPqA47zma9xVgZ9ZNwJOpv3fCOo +RfY66UjGEnfgYifgtI5S84/mp2jaSc9UNvlZB6RSf8cfbJUL74kS2lq+xzSlf0yP +Av844q/BfRuVsJsK1NDNG09LC30B0l3LKBqlrRmRTUMHtgchdX2dY+p7GPOoSzlR +MkM/fdpyc2hY7Dl/+qFmN5MG5yGmMpQcX+RNNR222ibNC1D3wg== +=i9b7 +-----END PGP PUBLIC KEY BLOCK-----` + keys, err := checkArmoredGPGKeyString(testGPGArmor) + if !assert.NotEmpty(t, keys) { + return + } + ekey := keys[0] + require.NoError(t, err, "Could not parse a valid GPG armored key", ekey) + + pubkey := ekey.PrimaryKey + content, err := base64EncPubKey(pubkey) + require.NoError(t, err, "Could not base64 encode a valid PublicKey content", ekey) + + key := &GPGKey{ + KeyID: pubkey.KeyIdString(), + Content: content, + CreatedUnix: timeutil.TimeStamp(pubkey.CreationTime.Unix()), + CanSign: pubkey.CanSign(), + CanEncryptComms: pubkey.PubKeyAlgo.CanEncrypt(), + CanEncryptStorage: pubkey.PubKeyAlgo.CanEncrypt(), + CanCertify: pubkey.PubKeyAlgo.CanSign(), + } + + cannotsignkey := &GPGKey{ + KeyID: pubkey.KeyIdString(), + Content: content, + CreatedUnix: timeutil.TimeStamp(pubkey.CreationTime.Unix()), + CanSign: false, + CanEncryptComms: false, + CanEncryptStorage: false, + CanCertify: false, + } + + testGoodSigArmor := `-----BEGIN PGP SIGNATURE----- + +iQEzBAABCAAdFiEEEIOwJg/1vpF1itJ4roJVuKDYKOQFAljAiQIACgkQroJVuKDY +KORvCgf6A/Ehh0r7QbO2tFEghT+/Ab+bN7jRN3zP9ed6/q/ophYmkrU0NibtbJH9 +AwFVdHxCmj78SdiRjaTKyevklXw34nvMftmvnOI4lBNUdw6KWl25/n/7wN0l2oZW +rW3UawYpZgodXiLTYarfEimkDQmT67ArScjRA6lLbkEYKO0VdwDu+Z6yBUH3GWtm +45RkXpnsF6AXUfuD7YxnfyyDE1A7g7zj4vVYUAfWukJjqow/LsCUgETETJOqj9q3 +52/oQDs04fVkIEtCDulcY+K/fKlukBPJf9WceNDEqiENUzN/Z1y0E+tJ07cSy4bk +yIJb+d0OAaG8bxloO7nJq4Res1Qa8Q== +=puvG +-----END PGP SIGNATURE-----` + testGoodPayload := `tree 56ae8d2799882b20381fc11659db06c16c68c61a +parent c7870c39e4e6b247235ca005797703ec4254613f +author Antoine GIRARD <sapk@sapk.fr> 1489012989 +0100 +committer Antoine GIRARD <sapk@sapk.fr> 1489012989 +0100 + +Goog GPG +` + + testBadSigArmor := `-----BEGIN PGP SIGNATURE----- + +iQEzBAABCAAdFiEE5yr4rn9ulbdMxJFiPYI/ySNrtNkFAljAiYkACgkQPYI/ySNr +tNmDdQf+NXhVRiOGt0GucpjJCGrOnK/qqVUmQyRUfrqzVUdb/1/Ws84V5/wE547I +6z3oxeBKFsJa1CtIlxYaUyVhYnDzQtphJzub+Aw3UG0E2ywiE+N7RCa1Ufl7pPxJ +U0SD6gvNaeTDQV/Wctu8v8DkCtEd3N8cMCDWhvy/FQEDztVtzm8hMe0Vdm0ozEH6 +P0W93sDNkLC5/qpWDN44sFlYDstW5VhMrnF0r/ohfaK2kpYHhkPk7WtOoHSUwQSg +c4gfhjvXIQrWFnII1Kr5jFGlmgNSR02qpb31VGkMzSnBhWVf2OaHS/kI49QHJakq +AhVDEnoYLCgoDGg9c3p1Ll2452/c6Q== +=uoGV +-----END PGP SIGNATURE-----` + testBadPayload := `tree 3074ff04951956a974e8b02d57733b0766f7cf6c +parent fd3577542f7ad1554c7c7c0eb86bb57a1324ad91 +author Antoine GIRARD <sapk@sapk.fr> 1489013107 +0100 +committer Antoine GIRARD <sapk@sapk.fr> 1489013107 +0100 + +Unknown GPG key with good email +` + // Reading Sign + goodSig, err := extractSignature(testGoodSigArmor) + require.NoError(t, err, "Could not parse a valid GPG armored signature", testGoodSigArmor) + badSig, err := extractSignature(testBadSigArmor) + require.NoError(t, err, "Could not parse a valid GPG armored signature", testBadSigArmor) + + // Generating hash of commit + goodHash, err := populateHash(goodSig.Hash, []byte(testGoodPayload)) + require.NoError(t, err, "Could not generate a valid hash of payload", testGoodPayload) + badHash, err := populateHash(badSig.Hash, []byte(testBadPayload)) + require.NoError(t, err, "Could not generate a valid hash of payload", testBadPayload) + + // Verify + err = verifySign(goodSig, goodHash, key) + require.NoError(t, err, "Could not validate a good signature") + err = verifySign(badSig, badHash, key) + require.Error(t, err, "Validate a bad signature") + err = verifySign(goodSig, goodHash, cannotsignkey) + require.Error(t, err, "Validate a bad signature with a kay that can not sign") +} + +func TestCheckGPGUserEmail(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + _ = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + testEmailWithUpperCaseLetters := `-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: GnuPG v1 + +mQENBFlEBvMBCADe+EQcfv/aKbMFy7YB8e/DE+hY39sfjvdvSgeXtNhfmYvIOUjT +ORMCvce2Oxzb3HTI0rjYsJpzo9jEQ53dB3vdr0ne5Juby6N7QPjof3NR+ko50Ki2 +0ilOjYuA0v6VHLIn70UBa9NEf+XDuE7P+Lbtl2L9B9OMXtcTAZoA3cJySgtNFNIG +AVefPi8LeOcekL39wxJEA8OzdCyO5oENEwAG1tzjy9DDNJf74/dBBh2NiXeSeMxZ +RYeYzqEa2UTDP1fkUl7d2/hV36cKZWZr+l4SQ5bM7HeLj2SsfabLfqKoVWgkfAzQ +VwtkbRpzMiDLMte2ZAyTJUc+77YbFoyAmOcjABEBAAG0HFVzZXIgT25lIDxVc2Vy +MUBFeGFtcGxlLmNvbT6JATgEEwECACIFAllEBvMCGwMGCwkIBwMCBhUIAgkKCwQW +AgMBAh4BAheAAAoJEFMOzOY274DFw5EIAKc4jiYaMb1HDKrSv0tphgNxPFEY83/J +9CZggO7BINxlb7z/lH1i0U2h2Ha9E3VJTJQF80zBCaIvtU2UNrgVmSKoc0BdE/2S +rS9MAl29sXxf1BfvXHu12Suvo8O/ZFP45Vm/3kkHuasHyOV1GwUWnynt1qo0zUEn +WMIcB8USlmMT1TnSb10YKBd/BpGF3crFDJLfAHRumZUk4knDDWUOWy5RCOG8cedc +VTAhfdoKRRO3PchOfz6Rls/hew12mRNayqxuLQl2+BX+BWu+25dR3qyiS+twLbk6 +Rjpb0S+RQTkYIUoI0SEZpxcTZso11xF5KNpKZ9aAoiLJqkNF5h4oPSe5AQ0EWUQG +8wEIALiMMqh3NF3ON/z7hQfeU24bCl/WdfJwCR9CWU/jx4X4gZq2C2aGtytGN5g/ +qoYQ3poTOPzh/4Dvs+r6CtHqi0CvPiEOfSxzmaK+F+vA0GMn2i3Sx5gq/VB0mr+j +RIYMCjf68Tifo2RAT0VDzn6t304l5+VPr4OgbobMRH+wDe7Hhd2pZXl7ty8DooBn +vqaqoKgdiccUXGBKe4Oihl/oZ4qrYH6K4ACP1Sco1rs4mNeKDAW8k/Y7zLjg6d59 +g0YQ1YI+CX/bKB7/cpMHLupyMLqvCcqIpjBXRJNMdjuMHgKckjr89DwnqXqgXz7W +u0B39MZQn9nn6vq8BdkoDFgrTQ8AEQEAAYkBHwQYAQIACQUCWUQG8wIbDAAKCRBT +DszmNu+Axf4IB/0S9NTc6kpwW+ZPZQNTWR5oKDEaXVCRLccOlkt33txMvk/z2jNM +trEke99ss5L1bRyWB5fRA+XVsPmW9kIk8pmGFmxqp2nSxr9m9rlL5oTYH8u6dfSm +zwGhqkfITjPI7hyNN52PLANwoS0o4dLzIE65ewigx6cnRlrT2IENObxG/tlxaYg1 +NHahJX0uFlVk0W0bLBrs3fTDw1lS/N8HpyQb+5ryQmiIb2a48aygCS/h2qeRlX1d +Q0KHb+QcycSgbDx0ZAvdIacuKvBBcbxrsmFUI4LR+oIup0G9gUc0roPvr014jYQL +7f8r/8fpcN8t+I/41QHCs6L/BEIdTHW3rTQ6 +=zHo9 +-----END PGP PUBLIC KEY BLOCK-----` + + keys, err := AddGPGKey(db.DefaultContext, 1, testEmailWithUpperCaseLetters, "", "") + require.NoError(t, err) + if assert.NotEmpty(t, keys) { + key := keys[0] + if assert.Len(t, key.Emails, 1) { + assert.Equal(t, "user1@example.com", key.Emails[0].Email) + } + } +} + +func TestCheckGPGRevokedIdentity(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + require.NoError(t, db.Insert(db.DefaultContext, &user_model.EmailAddress{UID: 1, Email: "no-reply@golang.com", IsActivated: true})) + require.NoError(t, db.Insert(db.DefaultContext, &user_model.EmailAddress{UID: 1, Email: "revoked@golang.com", IsActivated: true})) + _ = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + revokedUserKey := `-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +7qTZOahrETw= +=IKnw +-----END PGP PUBLIC KEY BLOCK----- +` + + keys, err := AddGPGKey(db.DefaultContext, 1, revokedUserKey, "", "") + require.NoError(t, err) + assert.Len(t, keys, 1) + assert.Len(t, keys[0].Emails, 1) + assert.EqualValues(t, "no-reply@golang.com", keys[0].Emails[0].Email) + + primaryKeyID := "D68172F48E9C5283" + // Assert primary key + unittest.AssertExistsAndLoadBean(t, &GPGKey{OwnerID: 1, KeyID: primaryKeyID, Content: "xsBNBFsgO5EBCADhREPmcjsPkXe1z7ctvyWL0S7oa9JaoGZ9oPDHFDlQxd0qlX2eDZJZDg0qYvVixmaULIulApq1puEsaJCn3lHUbHlb4PYKwLEywYXM28JN91KtLsz/uaEX2KC5WqeP40utmzkNLq+oRX/xnRMgwbO7yUNVG2UlEa6eI+xOXO3YtLdmJMBWClQ066ZnOIzEo1JxnIwha1CDBMWLLfOLrg6l8InUqaXbtEBbnaIYO6fXVXELUjkxnmk7t/QOk0tXCy8muH9UDqJkwDUESY2l79XwBAcx9riX8vY7vwC34pm22fAUVLCJx1SJx0J8bkeNp38jKM2Zd9SUQqSbfBopQ4pPABEBAAE="}) + // Assert subkey + unittest.AssertExistsAndLoadBean(t, &GPGKey{OwnerID: 1, KeyID: "2C56900BE5486AF8", PrimaryKeyID: primaryKeyID, Content: "zsBNBFsgO5EBCAC5INOERA2aNSYHWFeMfByShUuMQGFmyL2tWT6rwzZmUVG0GUdvoKSRhMJ+81aHxr5zmIhluegEuY99UhX+ZK6NftW2UOYjjjQZ4NPDjqOfP5dYUbHiCFRgeUxkmjwnQoSih63iSOoUt5kocR+oXXxbYmbgeOa8KGgKzDLGHI2nsy8Cni3N/enKVMMHGbJy1DXdV7uRFhBdjnRZGdmtamHcQbwGHUH+PtTa/jUSMdbtTUvXPI6dz7jDpK0BImzbXNb+r9CcudpiixuMu5gv3qyJL5EAWCXcT2j+y2VWj2HN/8bJHMoo6yf+bn6A/Cu9f0obbGVF0kJ/Y5UWmEdBG6IzABEBAAE="}) +} + +func TestCheckGParseGPGExpire(t *testing.T) { + testIssue6599 := `-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +epiDVQ== +=VSKJ +-----END PGP PUBLIC KEY BLOCK----- +` + keys, err := checkArmoredGPGKeyString(testIssue6599) + require.NoError(t, err) + if assert.NotEmpty(t, keys) { + ekey := keys[0] + expire := getExpiryTime(ekey) + assert.Equal(t, time.Unix(1586105389, 0), expire) + } +} + +func TestTryGetKeyIDFromSignature(t *testing.T) { + assert.Empty(t, tryGetKeyIDFromSignature(&packet.Signature{})) + assert.Equal(t, "038D1A3EADDBEA9C", tryGetKeyIDFromSignature(&packet.Signature{ + IssuerKeyId: util.ToPointer(uint64(0x38D1A3EADDBEA9C)), + })) + assert.Equal(t, "038D1A3EADDBEA9C", tryGetKeyIDFromSignature(&packet.Signature{ + IssuerFingerprint: []uint8{0xb, 0x23, 0x24, 0xc7, 0xe6, 0xfe, 0x4f, 0x3a, 0x6, 0x26, 0xc1, 0x21, 0x3, 0x8d, 0x1a, 0x3e, 0xad, 0xdb, 0xea, 0x9c}, + })) +} diff --git a/models/asymkey/gpg_key_verify.go b/models/asymkey/gpg_key_verify.go new file mode 100644 index 0000000..01812a2 --- /dev/null +++ b/models/asymkey/gpg_key_verify.go @@ -0,0 +1,119 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + "strconv" + "time" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" +) + +// __________________ ________ ____ __. +// / _____/\______ \/ _____/ | |/ _|____ ___.__. +// / \ ___ | ___/ \ ___ | <_/ __ < | | +// \ \_\ \| | \ \_\ \ | | \ ___/\___ | +// \______ /|____| \______ / |____|__ \___ > ____| +// \/ \/ \/ \/\/ +// ____ ____ .__ _____ +// \ \ / /___________|__|/ ____\__.__. +// \ Y // __ \_ __ \ \ __< | | +// \ /\ ___/| | \/ || | \___ | +// \___/ \___ >__| |__||__| / ____| +// \/ \/ + +// This file provides functions relating verifying gpg keys + +// VerifyGPGKey marks a GPG key as verified +func VerifyGPGKey(ctx context.Context, ownerID int64, keyID, token, signature string) (string, error) { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return "", err + } + defer committer.Close() + + key := new(GPGKey) + + has, err := db.GetEngine(ctx).Where("owner_id = ? AND key_id = ?", ownerID, keyID).Get(key) + if err != nil { + return "", err + } else if !has { + return "", ErrGPGKeyNotExist{} + } + + if err := key.LoadSubKeys(ctx); err != nil { + return "", err + } + + sig, err := extractSignature(signature) + if err != nil { + return "", ErrGPGInvalidTokenSignature{ + ID: key.KeyID, + Wrapped: err, + } + } + + signer, err := hashAndVerifyWithSubKeys(sig, token, key) + if err != nil { + return "", ErrGPGInvalidTokenSignature{ + ID: key.KeyID, + Wrapped: err, + } + } + if signer == nil { + signer, err = hashAndVerifyWithSubKeys(sig, token+"\n", key) + if err != nil { + return "", ErrGPGInvalidTokenSignature{ + ID: key.KeyID, + Wrapped: err, + } + } + } + if signer == nil { + signer, err = hashAndVerifyWithSubKeys(sig, token+"\n\n", key) + if err != nil { + return "", ErrGPGInvalidTokenSignature{ + ID: key.KeyID, + Wrapped: err, + } + } + } + + if signer == nil { + log.Error("Unable to validate token signature. Error: %v", err) + return "", ErrGPGInvalidTokenSignature{ + ID: key.KeyID, + } + } + + if signer.PrimaryKeyID != key.KeyID && signer.KeyID != key.KeyID { + return "", ErrGPGKeyNotExist{} + } + + key.Verified = true + if _, err := db.GetEngine(ctx).ID(key.ID).SetExpr("verified", true).Update(new(GPGKey)); err != nil { + return "", err + } + + if err := committer.Commit(); err != nil { + return "", err + } + + return key.KeyID, nil +} + +// VerificationToken returns token for the user that will be valid in minutes (time) +func VerificationToken(user *user_model.User, minutes int) string { + return base.EncodeSha256( + time.Now().Truncate(1*time.Minute).Add(time.Duration(minutes)*time.Minute).Format( + time.RFC1123Z) + ":" + + user.CreatedUnix.Format(time.RFC1123Z) + ":" + + user.Name + ":" + + user.Email + ":" + + strconv.FormatInt(user.ID, 10)) +} diff --git a/models/asymkey/main_test.go b/models/asymkey/main_test.go new file mode 100644 index 0000000..87b5c22 --- /dev/null +++ b/models/asymkey/main_test.go @@ -0,0 +1,24 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{ + FixtureFiles: []string{ + "gpg_key.yml", + "public_key.yml", + "TestParseCommitWithSSHSignature/public_key.yml", + "deploy_key.yml", + "gpg_key_import.yml", + "user.yml", + "email_address.yml", + }, + }) +} diff --git a/models/asymkey/ssh_key.go b/models/asymkey/ssh_key.go new file mode 100644 index 0000000..7a18732 --- /dev/null +++ b/models/asymkey/ssh_key.go @@ -0,0 +1,427 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + "fmt" + "strings" + "time" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "golang.org/x/crypto/ssh" + "xorm.io/builder" +) + +// KeyType specifies the key type +type KeyType int + +const ( + // KeyTypeUser specifies the user key + KeyTypeUser = iota + 1 + // KeyTypeDeploy specifies the deploy key + KeyTypeDeploy + // KeyTypePrincipal specifies the authorized principal key + KeyTypePrincipal +) + +// PublicKey represents a user or deploy SSH public key. +type PublicKey struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"INDEX NOT NULL"` + Name string `xorm:"NOT NULL"` + Fingerprint string `xorm:"INDEX NOT NULL"` + Content string `xorm:"MEDIUMTEXT NOT NULL"` + Mode perm.AccessMode `xorm:"NOT NULL DEFAULT 2"` + Type KeyType `xorm:"NOT NULL DEFAULT 1"` + LoginSourceID int64 `xorm:"NOT NULL DEFAULT 0"` + + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + HasRecentActivity bool `xorm:"-"` + HasUsed bool `xorm:"-"` + Verified bool `xorm:"NOT NULL DEFAULT false"` +} + +func init() { + db.RegisterModel(new(PublicKey)) +} + +// AfterLoad is invoked from XORM after setting the values of all fields of this object. +func (key *PublicKey) AfterLoad() { + key.HasUsed = key.UpdatedUnix > key.CreatedUnix + key.HasRecentActivity = key.UpdatedUnix.AddDuration(7*24*time.Hour) > timeutil.TimeStampNow() +} + +// OmitEmail returns content of public key without email address. +func (key *PublicKey) OmitEmail() string { + return strings.Join(strings.Split(key.Content, " ")[:2], " ") +} + +// AuthorizedString returns formatted public key string for authorized_keys file. +// +// TODO: Consider dropping this function +func (key *PublicKey) AuthorizedString() string { + return AuthorizedStringForKey(key) +} + +func addKey(ctx context.Context, key *PublicKey) (err error) { + if len(key.Fingerprint) == 0 { + key.Fingerprint, err = CalcFingerprint(key.Content) + if err != nil { + return err + } + } + + // Save SSH key. + if err = db.Insert(ctx, key); err != nil { + return err + } + + return appendAuthorizedKeysToFile(key) +} + +// AddPublicKey adds new public key to database and authorized_keys file. +func AddPublicKey(ctx context.Context, ownerID int64, name, content string, authSourceID int64) (*PublicKey, error) { + log.Trace(content) + + fingerprint, err := CalcFingerprint(content) + if err != nil { + return nil, err + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return nil, err + } + defer committer.Close() + + if err := checkKeyFingerprint(ctx, fingerprint); err != nil { + return nil, err + } + + // Key name of same user cannot be duplicated. + has, err := db.GetEngine(ctx). + Where("owner_id = ? AND name = ?", ownerID, name). + Get(new(PublicKey)) + if err != nil { + return nil, err + } else if has { + return nil, ErrKeyNameAlreadyUsed{ownerID, name} + } + + key := &PublicKey{ + OwnerID: ownerID, + Name: name, + Fingerprint: fingerprint, + Content: content, + Mode: perm.AccessModeWrite, + Type: KeyTypeUser, + LoginSourceID: authSourceID, + } + if err = addKey(ctx, key); err != nil { + return nil, fmt.Errorf("addKey: %w", err) + } + + return key, committer.Commit() +} + +// GetPublicKeyByID returns public key by given ID. +func GetPublicKeyByID(ctx context.Context, keyID int64) (*PublicKey, error) { + key := new(PublicKey) + has, err := db.GetEngine(ctx). + ID(keyID). + Get(key) + if err != nil { + return nil, err + } else if !has { + return nil, ErrKeyNotExist{keyID} + } + return key, nil +} + +// SearchPublicKeyByContent searches content as prefix (leak e-mail part) +// and returns public key found. +func SearchPublicKeyByContent(ctx context.Context, content string) (*PublicKey, error) { + key := new(PublicKey) + has, err := db.GetEngine(ctx). + Where("content like ?", content+"%"). + Get(key) + if err != nil { + return nil, err + } else if !has { + return nil, ErrKeyNotExist{} + } + return key, nil +} + +// SearchPublicKeyByContentExact searches content +// and returns public key found. +func SearchPublicKeyByContentExact(ctx context.Context, content string) (*PublicKey, error) { + key := new(PublicKey) + has, err := db.GetEngine(ctx). + Where("content = ?", content). + Get(key) + if err != nil { + return nil, err + } else if !has { + return nil, ErrKeyNotExist{} + } + return key, nil +} + +type FindPublicKeyOptions struct { + db.ListOptions + OwnerID int64 + Fingerprint string + KeyTypes []KeyType + NotKeytype KeyType + LoginSourceID int64 +} + +func (opts FindPublicKeyOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if opts.OwnerID > 0 { + cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) + } + if opts.Fingerprint != "" { + cond = cond.And(builder.Eq{"fingerprint": opts.Fingerprint}) + } + if len(opts.KeyTypes) > 0 { + cond = cond.And(builder.In("`type`", opts.KeyTypes)) + } + if opts.NotKeytype > 0 { + cond = cond.And(builder.Neq{"`type`": opts.NotKeytype}) + } + if opts.LoginSourceID > 0 { + cond = cond.And(builder.Eq{"login_source_id": opts.LoginSourceID}) + } + return cond +} + +// UpdatePublicKeyUpdated updates public key use time. +func UpdatePublicKeyUpdated(ctx context.Context, id int64) error { + // Check if key exists before update as affected rows count is unreliable + // and will return 0 affected rows if two updates are made at the same time + if cnt, err := db.GetEngine(ctx).ID(id).Count(&PublicKey{}); err != nil { + return err + } else if cnt != 1 { + return ErrKeyNotExist{id} + } + + _, err := db.GetEngine(ctx).ID(id).Cols("updated_unix").Update(&PublicKey{ + UpdatedUnix: timeutil.TimeStampNow(), + }) + if err != nil { + return err + } + return nil +} + +// PublicKeysAreExternallyManaged returns whether the provided KeyID represents an externally managed Key +func PublicKeysAreExternallyManaged(ctx context.Context, keys []*PublicKey) ([]bool, error) { + sourceCache := make(map[int64]*auth.Source, len(keys)) + externals := make([]bool, len(keys)) + + for i, key := range keys { + if key.LoginSourceID == 0 { + externals[i] = false + continue + } + + source, ok := sourceCache[key.LoginSourceID] + if !ok { + var err error + source, err = auth.GetSourceByID(ctx, key.LoginSourceID) + if err != nil { + if auth.IsErrSourceNotExist(err) { + externals[i] = false + sourceCache[key.LoginSourceID] = &auth.Source{ + ID: key.LoginSourceID, + } + continue + } + return nil, err + } + } + + if sshKeyProvider, ok := source.Cfg.(auth.SSHKeyProvider); ok && sshKeyProvider.ProvidesSSHKeys() { + // Disable setting SSH keys for this user + externals[i] = true + } + } + + return externals, nil +} + +// PublicKeyIsExternallyManaged returns whether the provided KeyID represents an externally managed Key +func PublicKeyIsExternallyManaged(ctx context.Context, id int64) (bool, error) { + key, err := GetPublicKeyByID(ctx, id) + if err != nil { + return false, err + } + if key.LoginSourceID == 0 { + return false, nil + } + source, err := auth.GetSourceByID(ctx, key.LoginSourceID) + if err != nil { + if auth.IsErrSourceNotExist(err) { + return false, nil + } + return false, err + } + if sshKeyProvider, ok := source.Cfg.(auth.SSHKeyProvider); ok && sshKeyProvider.ProvidesSSHKeys() { + // Disable setting SSH keys for this user + return true, nil + } + return false, nil +} + +// deleteKeysMarkedForDeletion returns true if ssh keys needs update +func deleteKeysMarkedForDeletion(ctx context.Context, keys []string) (bool, error) { + // Start session + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return false, err + } + defer committer.Close() + + // Delete keys marked for deletion + var sshKeysNeedUpdate bool + for _, KeyToDelete := range keys { + key, err := SearchPublicKeyByContent(ctx, KeyToDelete) + if err != nil { + log.Error("SearchPublicKeyByContent: %v", err) + continue + } + if _, err = db.DeleteByID[PublicKey](ctx, key.ID); err != nil { + log.Error("DeleteByID[PublicKey]: %v", err) + continue + } + sshKeysNeedUpdate = true + } + + if err := committer.Commit(); err != nil { + return false, err + } + + return sshKeysNeedUpdate, nil +} + +// AddPublicKeysBySource add a users public keys. Returns true if there are changes. +func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) bool { + var sshKeysNeedUpdate bool + for _, sshKey := range sshPublicKeys { + var err error + found := false + keys := []byte(sshKey) + loop: + for len(keys) > 0 && err == nil { + var out ssh.PublicKey + // We ignore options as they are not relevant to Gitea + out, _, _, keys, err = ssh.ParseAuthorizedKey(keys) + if err != nil { + break loop + } + found = true + marshalled := string(ssh.MarshalAuthorizedKey(out)) + marshalled = marshalled[:len(marshalled)-1] + sshKeyName := fmt.Sprintf("%s-%s", s.Name, ssh.FingerprintSHA256(out)) + + if _, err := AddPublicKey(ctx, usr.ID, sshKeyName, marshalled, s.ID); err != nil { + if IsErrKeyAlreadyExist(err) { + log.Trace("AddPublicKeysBySource[%s]: Public SSH Key %s already exists for user", sshKeyName, usr.Name) + } else { + log.Error("AddPublicKeysBySource[%s]: Error adding Public SSH Key for user %s: %v", sshKeyName, usr.Name, err) + } + } else { + log.Trace("AddPublicKeysBySource[%s]: Added Public SSH Key for user %s", sshKeyName, usr.Name) + sshKeysNeedUpdate = true + } + } + if !found && err != nil { + log.Warn("AddPublicKeysBySource[%s]: Skipping invalid Public SSH Key for user %s: %v", s.Name, usr.Name, sshKey) + } + } + return sshKeysNeedUpdate +} + +// SynchronizePublicKeys updates a users public keys. Returns true if there are changes. +func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) bool { + var sshKeysNeedUpdate bool + + log.Trace("synchronizePublicKeys[%s]: Handling Public SSH Key synchronization for user %s", s.Name, usr.Name) + + // Get Public Keys from DB with current LDAP source + var giteaKeys []string + keys, err := db.Find[PublicKey](ctx, FindPublicKeyOptions{ + OwnerID: usr.ID, + LoginSourceID: s.ID, + }) + if err != nil { + log.Error("synchronizePublicKeys[%s]: Error listing Public SSH Keys for user %s: %v", s.Name, usr.Name, err) + } + + for _, v := range keys { + giteaKeys = append(giteaKeys, v.OmitEmail()) + } + + // Process the provided keys to remove duplicates and name part + var providedKeys []string + for _, v := range sshPublicKeys { + sshKeySplit := strings.Split(v, " ") + if len(sshKeySplit) > 1 { + key := strings.Join(sshKeySplit[:2], " ") + if !util.SliceContainsString(providedKeys, key) { + providedKeys = append(providedKeys, key) + } + } + } + + // Check if Public Key sync is needed + if util.SliceSortedEqual(giteaKeys, providedKeys) { + log.Trace("synchronizePublicKeys[%s]: Public Keys are already in sync for %s (Source:%v/DB:%v)", s.Name, usr.Name, len(providedKeys), len(giteaKeys)) + return false + } + log.Trace("synchronizePublicKeys[%s]: Public Key needs update for user %s (Source:%v/DB:%v)", s.Name, usr.Name, len(providedKeys), len(giteaKeys)) + + // Add new Public SSH Keys that doesn't already exist in DB + var newKeys []string + for _, key := range providedKeys { + if !util.SliceContainsString(giteaKeys, key) { + newKeys = append(newKeys, key) + } + } + if AddPublicKeysBySource(ctx, usr, s, newKeys) { + sshKeysNeedUpdate = true + } + + // Mark keys from DB that no longer exist in the source for deletion + var giteaKeysToDelete []string + for _, giteaKey := range giteaKeys { + if !util.SliceContainsString(providedKeys, giteaKey) { + log.Trace("synchronizePublicKeys[%s]: Marking Public SSH Key for deletion for user %s: %v", s.Name, usr.Name, giteaKey) + giteaKeysToDelete = append(giteaKeysToDelete, giteaKey) + } + } + + // Delete keys from DB that no longer exist in the source + needUpd, err := deleteKeysMarkedForDeletion(ctx, giteaKeysToDelete) + if err != nil { + log.Error("synchronizePublicKeys[%s]: Error deleting Public Keys marked for deletion for user %s: %v", s.Name, usr.Name, err) + } + if needUpd { + sshKeysNeedUpdate = true + } + + return sshKeysNeedUpdate +} diff --git a/models/asymkey/ssh_key_authorized_keys.go b/models/asymkey/ssh_key_authorized_keys.go new file mode 100644 index 0000000..d3f9f3f --- /dev/null +++ b/models/asymkey/ssh_key_authorized_keys.go @@ -0,0 +1,220 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +// _____ __ .__ .__ .___ +// / _ \ __ ___/ |_| |__ ___________|__|_______ ____ __| _/ +// / /_\ \| | \ __\ | \ / _ \_ __ \ \___ // __ \ / __ | +// / | \ | /| | | Y ( <_> ) | \/ |/ /\ ___// /_/ | +// \____|__ /____/ |__| |___| /\____/|__| |__/_____ \\___ >____ | +// \/ \/ \/ \/ \/ +// ____ __. +// | |/ _|____ ___.__. ______ +// | <_/ __ < | |/ ___/ +// | | \ ___/\___ |\___ \ +// |____|__ \___ > ____/____ > +// \/ \/\/ \/ +// +// This file contains functions for creating authorized_keys files +// +// There is a dependence on the database within RegeneratePublicKeys however most of these functions probably belong in a module + +const ( + tplCommentPrefix = `# gitea public key` + tplPublicKey = tplCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict %s` + "\n" +) + +var sshOpLocker sync.Mutex + +// AuthorizedStringForKey creates the authorized keys string appropriate for the provided key +func AuthorizedStringForKey(key *PublicKey) string { + sb := &strings.Builder{} + _ = setting.SSH.AuthorizedKeysCommandTemplateTemplate.Execute(sb, map[string]any{ + "AppPath": util.ShellEscape(setting.AppPath), + "AppWorkPath": util.ShellEscape(setting.AppWorkPath), + "CustomConf": util.ShellEscape(setting.CustomConf), + "CustomPath": util.ShellEscape(setting.CustomPath), + "Key": key, + }) + + return fmt.Sprintf(tplPublicKey, util.ShellEscape(sb.String()), key.Content) +} + +// appendAuthorizedKeysToFile appends new SSH keys' content to authorized_keys file. +func appendAuthorizedKeysToFile(keys ...*PublicKey) error { + // Don't need to rewrite this file if builtin SSH server is enabled. + if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedKeysFile { + return nil + } + + sshOpLocker.Lock() + defer sshOpLocker.Unlock() + + if setting.SSH.RootPath != "" { + // First of ensure that the RootPath is present, and if not make it with 0700 permissions + // This of course doesn't guarantee that this is the right directory for authorized_keys + // but at least if it's supposed to be this directory and it doesn't exist and we're the + // right user it will at least be created properly. + err := os.MkdirAll(setting.SSH.RootPath, 0o700) + if err != nil { + log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err) + return err + } + } + + fPath := filepath.Join(setting.SSH.RootPath, "authorized_keys") + f, err := os.OpenFile(fPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + defer f.Close() + + // Note: chmod command does not support in Windows. + if !setting.IsWindows { + fi, err := f.Stat() + if err != nil { + return err + } + + // .ssh directory should have mode 700, and authorized_keys file should have mode 600. + if fi.Mode().Perm() > 0o600 { + log.Error("authorized_keys file has unusual permission flags: %s - setting to -rw-------", fi.Mode().Perm().String()) + if err = f.Chmod(0o600); err != nil { + return err + } + } + } + + for _, key := range keys { + if key.Type == KeyTypePrincipal { + continue + } + if _, err = f.WriteString(key.AuthorizedString()); err != nil { + return err + } + } + return nil +} + +// RewriteAllPublicKeys removes any authorized key and rewrite all keys from database again. +// Note: db.GetEngine(ctx).Iterate does not get latest data after insert/delete, so we have to call this function +// outside any session scope independently. +func RewriteAllPublicKeys(ctx context.Context) error { + // Don't rewrite key if internal server + if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedKeysFile { + return nil + } + + sshOpLocker.Lock() + defer sshOpLocker.Unlock() + + if setting.SSH.RootPath != "" { + // First of ensure that the RootPath is present, and if not make it with 0700 permissions + // This of course doesn't guarantee that this is the right directory for authorized_keys + // but at least if it's supposed to be this directory and it doesn't exist and we're the + // right user it will at least be created properly. + err := os.MkdirAll(setting.SSH.RootPath, 0o700) + if err != nil { + log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err) + return err + } + } + + fPath := filepath.Join(setting.SSH.RootPath, "authorized_keys") + tmpPath := fPath + ".tmp" + t, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer func() { + t.Close() + if err := util.Remove(tmpPath); err != nil { + log.Warn("Unable to remove temporary authorized keys file: %s: Error: %v", tmpPath, err) + } + }() + + if setting.SSH.AuthorizedKeysBackup { + isExist, err := util.IsExist(fPath) + if err != nil { + log.Error("Unable to check if %s exists. Error: %v", fPath, err) + return err + } + if isExist { + bakPath := fmt.Sprintf("%s_%d.gitea_bak", fPath, time.Now().Unix()) + if err = util.CopyFile(fPath, bakPath); err != nil { + return err + } + } + } + + if err := RegeneratePublicKeys(ctx, t); err != nil { + return err + } + + if err := t.Sync(); err != nil { + return err + } + if err := t.Close(); err != nil { + return err + } + return util.Rename(tmpPath, fPath) +} + +// RegeneratePublicKeys regenerates the authorized_keys file +func RegeneratePublicKeys(ctx context.Context, t io.StringWriter) error { + if err := db.GetEngine(ctx).Where("type != ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean any) (err error) { + _, err = t.WriteString((bean.(*PublicKey)).AuthorizedString()) + return err + }); err != nil { + return err + } + + fPath := filepath.Join(setting.SSH.RootPath, "authorized_keys") + isExist, err := util.IsExist(fPath) + if err != nil { + log.Error("Unable to check if %s exists. Error: %v", fPath, err) + return err + } + if isExist { + f, err := os.Open(fPath) + if err != nil { + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, tplCommentPrefix) { + scanner.Scan() + continue + } + _, err = t.WriteString(line + "\n") + if err != nil { + return err + } + } + if err = scanner.Err(); err != nil { + return fmt.Errorf("RegeneratePublicKeys scan: %w", err) + } + } + return nil +} diff --git a/models/asymkey/ssh_key_authorized_principals.go b/models/asymkey/ssh_key_authorized_principals.go new file mode 100644 index 0000000..f85de12 --- /dev/null +++ b/models/asymkey/ssh_key_authorized_principals.go @@ -0,0 +1,142 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +// _____ __ .__ .__ .___ +// / _ \ __ ___/ |_| |__ ___________|__|_______ ____ __| _/ +// / /_\ \| | \ __\ | \ / _ \_ __ \ \___ // __ \ / __ | +// / | \ | /| | | Y ( <_> ) | \/ |/ /\ ___// /_/ | +// \____|__ /____/ |__| |___| /\____/|__| |__/_____ \\___ >____ | +// \/ \/ \/ \/ \/ +// __________ .__ .__ .__ +// \______ _______|__| ____ ____ |_____________ | | ______ +// | ___\_ __ | |/ \_/ ___\| \____ \__ \ | | / ___/ +// | | | | \| | | \ \___| | |_> / __ \| |__\___ \ +// |____| |__| |__|___| /\___ |__| __(____ |____/____ > +// \/ \/ |__| \/ \/ +// +// This file contains functions for creating authorized_principals files +// +// There is a dependence on the database within RewriteAllPrincipalKeys & RegeneratePrincipalKeys +// The sshOpLocker is used from ssh_key_authorized_keys.go + +const authorizedPrincipalsFile = "authorized_principals" + +// RewriteAllPrincipalKeys removes any authorized principal and rewrite all keys from database again. +// Note: db.GetEngine(ctx).Iterate does not get latest data after insert/delete, so we have to call this function +// outside any session scope independently. +func RewriteAllPrincipalKeys(ctx context.Context) error { + // Don't rewrite key if internal server + if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedPrincipalsFile { + return nil + } + + sshOpLocker.Lock() + defer sshOpLocker.Unlock() + + if setting.SSH.RootPath != "" { + // First of ensure that the RootPath is present, and if not make it with 0700 permissions + // This of course doesn't guarantee that this is the right directory for authorized_keys + // but at least if it's supposed to be this directory and it doesn't exist and we're the + // right user it will at least be created properly. + err := os.MkdirAll(setting.SSH.RootPath, 0o700) + if err != nil { + log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err) + return err + } + } + + fPath := filepath.Join(setting.SSH.RootPath, authorizedPrincipalsFile) + tmpPath := fPath + ".tmp" + t, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer func() { + t.Close() + os.Remove(tmpPath) + }() + + if setting.SSH.AuthorizedPrincipalsBackup { + isExist, err := util.IsExist(fPath) + if err != nil { + log.Error("Unable to check if %s exists. Error: %v", fPath, err) + return err + } + if isExist { + bakPath := fmt.Sprintf("%s_%d.gitea_bak", fPath, time.Now().Unix()) + if err = util.CopyFile(fPath, bakPath); err != nil { + return err + } + } + } + + if err := regeneratePrincipalKeys(ctx, t); err != nil { + return err + } + + if err := t.Sync(); err != nil { + return err + } + if err := t.Close(); err != nil { + return err + } + return util.Rename(tmpPath, fPath) +} + +func regeneratePrincipalKeys(ctx context.Context, t io.StringWriter) error { + if err := db.GetEngine(ctx).Where("type = ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean any) (err error) { + _, err = t.WriteString((bean.(*PublicKey)).AuthorizedString()) + return err + }); err != nil { + return err + } + + fPath := filepath.Join(setting.SSH.RootPath, authorizedPrincipalsFile) + isExist, err := util.IsExist(fPath) + if err != nil { + log.Error("Unable to check if %s exists. Error: %v", fPath, err) + return err + } + if isExist { + f, err := os.Open(fPath) + if err != nil { + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, tplCommentPrefix) { + scanner.Scan() + continue + } + _, err = t.WriteString(line + "\n") + if err != nil { + return err + } + } + if err = scanner.Err(); err != nil { + return fmt.Errorf("regeneratePrincipalKeys scan: %w", err) + } + } + return nil +} diff --git a/models/asymkey/ssh_key_deploy.go b/models/asymkey/ssh_key_deploy.go new file mode 100644 index 0000000..923c502 --- /dev/null +++ b/models/asymkey/ssh_key_deploy.go @@ -0,0 +1,218 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + "fmt" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/builder" +) + +// ________ .__ ____ __. +// \______ \ ____ ______ | | ____ ___.__.| |/ _|____ ___.__. +// | | \_/ __ \\____ \| | / _ < | || <_/ __ < | | +// | ` \ ___/| |_> > |_( <_> )___ || | \ ___/\___ | +// /_______ /\___ > __/|____/\____// ____||____|__ \___ > ____| +// \/ \/|__| \/ \/ \/\/ +// +// This file contains functions specific to DeployKeys + +// DeployKey represents deploy key information and its relation with repository. +type DeployKey struct { + ID int64 `xorm:"pk autoincr"` + KeyID int64 `xorm:"UNIQUE(s) INDEX"` + RepoID int64 `xorm:"UNIQUE(s) INDEX"` + Name string + Fingerprint string + Content string `xorm:"-"` + + Mode perm.AccessMode `xorm:"NOT NULL DEFAULT 1"` + + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + HasRecentActivity bool `xorm:"-"` + HasUsed bool `xorm:"-"` +} + +// AfterLoad is invoked from XORM after setting the values of all fields of this object. +func (key *DeployKey) AfterLoad() { + key.HasUsed = key.UpdatedUnix > key.CreatedUnix + key.HasRecentActivity = key.UpdatedUnix.AddDuration(7*24*time.Hour) > timeutil.TimeStampNow() +} + +// GetContent gets associated public key content. +func (key *DeployKey) GetContent(ctx context.Context) error { + pkey, err := GetPublicKeyByID(ctx, key.KeyID) + if err != nil { + return err + } + key.Content = pkey.Content + return nil +} + +// IsReadOnly checks if the key can only be used for read operations, used by template +func (key *DeployKey) IsReadOnly() bool { + return key.Mode == perm.AccessModeRead +} + +func init() { + db.RegisterModel(new(DeployKey)) +} + +func checkDeployKey(ctx context.Context, keyID, repoID int64, name string) error { + // Note: We want error detail, not just true or false here. + has, err := db.GetEngine(ctx). + Where("key_id = ? AND repo_id = ?", keyID, repoID). + Get(new(DeployKey)) + if err != nil { + return err + } else if has { + return ErrDeployKeyAlreadyExist{keyID, repoID} + } + + has, err = db.GetEngine(ctx). + Where("repo_id = ? AND name = ?", repoID, name). + Get(new(DeployKey)) + if err != nil { + return err + } else if has { + return ErrDeployKeyNameAlreadyUsed{repoID, name} + } + + return nil +} + +// addDeployKey adds new key-repo relation. +func addDeployKey(ctx context.Context, keyID, repoID int64, name, fingerprint string, mode perm.AccessMode) (*DeployKey, error) { + if err := checkDeployKey(ctx, keyID, repoID, name); err != nil { + return nil, err + } + + key := &DeployKey{ + KeyID: keyID, + RepoID: repoID, + Name: name, + Fingerprint: fingerprint, + Mode: mode, + } + return key, db.Insert(ctx, key) +} + +// HasDeployKey returns true if public key is a deploy key of given repository. +func HasDeployKey(ctx context.Context, keyID, repoID int64) bool { + has, _ := db.GetEngine(ctx). + Where("key_id = ? AND repo_id = ?", keyID, repoID). + Get(new(DeployKey)) + return has +} + +// AddDeployKey add new deploy key to database and authorized_keys file. +func AddDeployKey(ctx context.Context, repoID int64, name, content string, readOnly bool) (*DeployKey, error) { + fingerprint, err := CalcFingerprint(content) + if err != nil { + return nil, err + } + + accessMode := perm.AccessModeRead + if !readOnly { + accessMode = perm.AccessModeWrite + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return nil, err + } + defer committer.Close() + + pkey, exist, err := db.Get[PublicKey](ctx, builder.Eq{"fingerprint": fingerprint}) + if err != nil { + return nil, err + } else if exist { + if pkey.Type != KeyTypeDeploy { + return nil, ErrKeyAlreadyExist{0, fingerprint, ""} + } + } else { + // First time use this deploy key. + pkey = &PublicKey{ + Fingerprint: fingerprint, + Mode: accessMode, + Type: KeyTypeDeploy, + Content: content, + Name: name, + } + if err = addKey(ctx, pkey); err != nil { + return nil, fmt.Errorf("addKey: %w", err) + } + } + + key, err := addDeployKey(ctx, pkey.ID, repoID, name, pkey.Fingerprint, accessMode) + if err != nil { + return nil, err + } + + return key, committer.Commit() +} + +// GetDeployKeyByID returns deploy key by given ID. +func GetDeployKeyByID(ctx context.Context, id int64) (*DeployKey, error) { + key, exist, err := db.GetByID[DeployKey](ctx, id) + if err != nil { + return nil, err + } else if !exist { + return nil, ErrDeployKeyNotExist{id, 0, 0} + } + return key, nil +} + +// GetDeployKeyByRepo returns deploy key by given public key ID and repository ID. +func GetDeployKeyByRepo(ctx context.Context, keyID, repoID int64) (*DeployKey, error) { + key, exist, err := db.Get[DeployKey](ctx, builder.Eq{"key_id": keyID, "repo_id": repoID}) + if err != nil { + return nil, err + } else if !exist { + return nil, ErrDeployKeyNotExist{0, keyID, repoID} + } + return key, nil +} + +// IsDeployKeyExistByKeyID return true if there is at least one deploykey with the key id +func IsDeployKeyExistByKeyID(ctx context.Context, keyID int64) (bool, error) { + return db.GetEngine(ctx). + Where("key_id = ?", keyID). + Get(new(DeployKey)) +} + +// UpdateDeployKeyCols updates deploy key information in the specified columns. +func UpdateDeployKeyCols(ctx context.Context, key *DeployKey, cols ...string) error { + _, err := db.GetEngine(ctx).ID(key.ID).Cols(cols...).Update(key) + return err +} + +// ListDeployKeysOptions are options for ListDeployKeys +type ListDeployKeysOptions struct { + db.ListOptions + RepoID int64 + KeyID int64 + Fingerprint string +} + +func (opt ListDeployKeysOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if opt.RepoID != 0 { + cond = cond.And(builder.Eq{"repo_id": opt.RepoID}) + } + if opt.KeyID != 0 { + cond = cond.And(builder.Eq{"key_id": opt.KeyID}) + } + if opt.Fingerprint != "" { + cond = cond.And(builder.Eq{"fingerprint": opt.Fingerprint}) + } + return cond +} diff --git a/models/asymkey/ssh_key_fingerprint.go b/models/asymkey/ssh_key_fingerprint.go new file mode 100644 index 0000000..1ed3b5d --- /dev/null +++ b/models/asymkey/ssh_key_fingerprint.go @@ -0,0 +1,89 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + + "golang.org/x/crypto/ssh" + "xorm.io/builder" +) + +// ___________.__ .__ __ +// \_ _____/|__| ____ ____ ________________________|__| _____/ |_ +// | __) | |/ \ / ___\_/ __ \_ __ \____ \_ __ \ |/ \ __\ +// | \ | | | \/ /_/ > ___/| | \/ |_> > | \/ | | \ | +// \___ / |__|___| /\___ / \___ >__| | __/|__| |__|___| /__| +// \/ \//_____/ \/ |__| \/ +// +// This file contains functions for fingerprinting SSH keys +// +// The database is used in checkKeyFingerprint however most of these functions probably belong in a module + +// checkKeyFingerprint only checks if key fingerprint has been used as public key, +// it is OK to use same key as deploy key for multiple repositories/users. +func checkKeyFingerprint(ctx context.Context, fingerprint string) error { + has, err := db.Exist[PublicKey](ctx, builder.Eq{"fingerprint": fingerprint}) + if err != nil { + return err + } else if has { + return ErrKeyAlreadyExist{0, fingerprint, ""} + } + return nil +} + +func calcFingerprintSSHKeygen(publicKeyContent string) (string, error) { + // Calculate fingerprint. + tmpPath, err := writeTmpKeyFile(publicKeyContent) + if err != nil { + return "", err + } + defer func() { + if err := util.Remove(tmpPath); err != nil { + log.Warn("Unable to remove temporary key file: %s: Error: %v", tmpPath, err) + } + }() + stdout, stderr, err := process.GetManager().Exec("AddPublicKey", "ssh-keygen", "-lf", tmpPath) + if err != nil { + if strings.Contains(stderr, "is not a public key file") { + return "", ErrKeyUnableVerify{stderr} + } + return "", util.NewInvalidArgumentErrorf("'ssh-keygen -lf %s' failed with error '%s': %s", tmpPath, err, stderr) + } else if len(stdout) < 2 { + return "", util.NewInvalidArgumentErrorf("not enough output for calculating fingerprint: %s", stdout) + } + return strings.Split(stdout, " ")[1], nil +} + +func calcFingerprintNative(publicKeyContent string) (string, error) { + // Calculate fingerprint. + pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(publicKeyContent)) + if err != nil { + return "", err + } + return ssh.FingerprintSHA256(pk), nil +} + +// CalcFingerprint calculate public key's fingerprint +func CalcFingerprint(publicKeyContent string) (string, error) { + // Call the method based on configuration + useNative := setting.SSH.KeygenPath == "" + calcFn := util.Iif(useNative, calcFingerprintNative, calcFingerprintSSHKeygen) + fp, err := calcFn(publicKeyContent) + if err != nil { + if IsErrKeyUnableVerify(err) { + return "", err + } + return "", fmt.Errorf("CalcFingerprint(%s): %w", util.Iif(useNative, "native", "ssh-keygen"), err) + } + return fp, nil +} diff --git a/models/asymkey/ssh_key_object_verification.go b/models/asymkey/ssh_key_object_verification.go new file mode 100644 index 0000000..5ad6fdb --- /dev/null +++ b/models/asymkey/ssh_key_object_verification.go @@ -0,0 +1,85 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "bytes" + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + + "github.com/42wim/sshsig" +) + +// ParseObjectWithSSHSignature check if signature is good against keystore. +func ParseObjectWithSSHSignature(ctx context.Context, c *GitObject, committer *user_model.User) *ObjectVerification { + // Now try to associate the signature with the committer, if present + if committer.ID != 0 { + keys, err := db.Find[PublicKey](ctx, FindPublicKeyOptions{ + OwnerID: committer.ID, + NotKeytype: KeyTypePrincipal, + }) + if err != nil { // Skipping failed to get ssh keys of user + log.Error("ListPublicKeys: %v", err) + return &ObjectVerification{ + CommittingUser: committer, + Verified: false, + Reason: "gpg.error.failed_retrieval_gpg_keys", + } + } + + committerEmailAddresses, err := user_model.GetEmailAddresses(ctx, committer.ID) + if err != nil { + log.Error("GetEmailAddresses: %v", err) + } + + // Add the noreply email address as verified address. + committerEmailAddresses = append(committerEmailAddresses, &user_model.EmailAddress{ + IsActivated: true, + Email: committer.GetPlaceholderEmail(), + }) + + activated := false + for _, e := range committerEmailAddresses { + if e.IsActivated && strings.EqualFold(e.Email, c.Committer.Email) { + activated = true + break + } + } + + for _, k := range keys { + if k.Verified && activated { + commitVerification := verifySSHObjectVerification(c.Signature.Signature, c.Signature.Payload, k, committer, committer, c.Committer.Email) + if commitVerification != nil { + return commitVerification + } + } + } + } + + return &ObjectVerification{ + CommittingUser: committer, + Verified: false, + Reason: NoKeyFound, + } +} + +func verifySSHObjectVerification(sig, payload string, k *PublicKey, committer, signer *user_model.User, email string) *ObjectVerification { + if err := sshsig.Verify(bytes.NewBuffer([]byte(payload)), []byte(sig), []byte(k.Content), "git"); err != nil { + return nil + } + + return &ObjectVerification{ // Everything is ok + CommittingUser: committer, + Verified: true, + Reason: fmt.Sprintf("%s / %s", signer.Name, k.Fingerprint), + SigningUser: signer, + SigningSSHKey: k, + SigningEmail: email, + } +} diff --git a/models/asymkey/ssh_key_object_verification_test.go b/models/asymkey/ssh_key_object_verification_test.go new file mode 100644 index 0000000..0d5ebab --- /dev/null +++ b/models/asymkey/ssh_key_object_verification_test.go @@ -0,0 +1,153 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseCommitWithSSHSignature(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + sshKey := unittest.AssertExistsAndLoadBean(t, &PublicKey{ID: 1000, OwnerID: 2}) + + t.Run("No commiter", func(t *testing.T) { + o := commitToGitObject(&git.Commit{}) + commitVerification := ParseObjectWithSSHSignature(db.DefaultContext, &o, &user_model.User{}) + assert.False(t, commitVerification.Verified) + assert.Equal(t, NoKeyFound, commitVerification.Reason) + }) + + t.Run("Commiter without keys", func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + o := commitToGitObject(&git.Commit{Committer: &git.Signature{Email: user.Email}}) + commitVerification := ParseObjectWithSSHSignature(db.DefaultContext, &o, user) + assert.False(t, commitVerification.Verified) + assert.Equal(t, NoKeyFound, commitVerification.Reason) + }) + + t.Run("Correct signature with wrong email", func(t *testing.T) { + gitCommit := &git.Commit{ + Committer: &git.Signature{ + Email: "non-existent", + }, + Signature: &git.ObjectSignature{ + Payload: `tree 2d491b2985a7ff848d5c02748e7ea9f9f7619f9f +parent 45b03601635a1f463b81963a4022c7f87ce96ef9 +author user2 <non-existent> 1699710556 +0100 +committer user2 <non-existent> 1699710556 +0100 + +Using email that isn't known to Forgejo +`, + Signature: `-----BEGIN SSH SIGNATURE----- +U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgoGSe9Zy7Ez9bSJcaTNjh/Y7p95 +f5DujjqkpzFRtw6CEAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 +AAAAQIMufOuSjZeDUujrkVK4sl7ICa0WwEftas8UAYxx0Thdkiw2qWjR1U1PKfTLm16/w8 +/bS1LX1lZNuzm2LR2qEgw= +-----END SSH SIGNATURE----- +`, + }, + } + o := commitToGitObject(gitCommit) + commitVerification := ParseObjectWithSSHSignature(db.DefaultContext, &o, user2) + assert.False(t, commitVerification.Verified) + assert.Equal(t, NoKeyFound, commitVerification.Reason) + }) + + t.Run("Incorrect signature with correct email", func(t *testing.T) { + gitCommit := &git.Commit{ + Committer: &git.Signature{ + Email: "user2@example.com", + }, + Signature: &git.ObjectSignature{ + Payload: `tree 853694aae8816094a0d875fee7ea26278dbf5d0f +parent c2780d5c313da2a947eae22efd7dacf4213f4e7f +author user2 <user2@example.com> 1699707877 +0100 +committer user2 <user2@example.com> 1699707877 +0100 + +Add content +`, + Signature: `-----BEGIN SSH SIGNATURE-----`, + }, + } + + o := commitToGitObject(gitCommit) + commitVerification := ParseObjectWithSSHSignature(db.DefaultContext, &o, user2) + assert.False(t, commitVerification.Verified) + assert.Equal(t, NoKeyFound, commitVerification.Reason) + }) + + t.Run("Valid signature with correct email", func(t *testing.T) { + gitCommit := &git.Commit{ + Committer: &git.Signature{ + Email: "user2@example.com", + }, + Signature: &git.ObjectSignature{ + Payload: `tree 853694aae8816094a0d875fee7ea26278dbf5d0f +parent c2780d5c313da2a947eae22efd7dacf4213f4e7f +author user2 <user2@example.com> 1699707877 +0100 +committer user2 <user2@example.com> 1699707877 +0100 + +Add content +`, + Signature: `-----BEGIN SSH SIGNATURE----- +U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgoGSe9Zy7Ez9bSJcaTNjh/Y7p95 +f5DujjqkpzFRtw6CEAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 +AAAAQBe2Fwk/FKY3SBCnG6jSYcO6ucyahp2SpQ/0P+otslzIHpWNW8cQ0fGLdhhaFynJXQ +fs9cMpZVM9BfIKNUSO8QY= +-----END SSH SIGNATURE----- +`, + }, + } + + o := commitToGitObject(gitCommit) + commitVerification := ParseObjectWithSSHSignature(db.DefaultContext, &o, user2) + assert.True(t, commitVerification.Verified) + assert.Equal(t, "user2 / SHA256:TKfwbZMR7e9OnlV2l1prfah1TXH8CmqR0PvFEXVCXA4", commitVerification.Reason) + assert.Equal(t, sshKey, commitVerification.SigningSSHKey) + }) + + t.Run("Valid signature with noreply email", func(t *testing.T) { + defer test.MockVariableValue(&setting.Service.NoReplyAddress, "noreply.example.com")() + + gitCommit := &git.Commit{ + Committer: &git.Signature{ + Email: "user2@noreply.example.com", + }, + Signature: &git.ObjectSignature{ + Payload: `tree 4836c7f639f37388bab4050ef5c97bbbd54272fc +parent 795be1b0117ea5c65456050bb9fd84744d4fd9c6 +author user2 <user2@noreply.example.com> 1699709594 +0100 +committer user2 <user2@noreply.example.com> 1699709594 +0100 + +Commit with noreply +`, + Signature: `-----BEGIN SSH SIGNATURE----- +U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgoGSe9Zy7Ez9bSJcaTNjh/Y7p95 +f5DujjqkpzFRtw6CEAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 +AAAAQJz83KKxD6Bz/ZvNpqkA3RPOSQ4LQ5FfEItbtoONkbwV9wAWMnmBqgggo/lnXCJ3oq +muPLbvEduU+Ze/1Ol1pgk= +-----END SSH SIGNATURE----- +`, + }, + } + + o := commitToGitObject(gitCommit) + commitVerification := ParseObjectWithSSHSignature(db.DefaultContext, &o, user2) + assert.True(t, commitVerification.Verified) + assert.Equal(t, "user2 / SHA256:TKfwbZMR7e9OnlV2l1prfah1TXH8CmqR0PvFEXVCXA4", commitVerification.Reason) + assert.Equal(t, sshKey, commitVerification.SigningSSHKey) + }) +} diff --git a/models/asymkey/ssh_key_parse.go b/models/asymkey/ssh_key_parse.go new file mode 100644 index 0000000..94b1cf1 --- /dev/null +++ b/models/asymkey/ssh_key_parse.go @@ -0,0 +1,312 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/asn1" + "encoding/base64" + "encoding/binary" + "encoding/pem" + "fmt" + "math/big" + "os" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + + "golang.org/x/crypto/ssh" +) + +// ____ __. __________ +// | |/ _|____ ___.__. \______ \_____ _______ ______ ___________ +// | <_/ __ < | | | ___/\__ \\_ __ \/ ___// __ \_ __ \ +// | | \ ___/\___ | | | / __ \| | \/\___ \\ ___/| | \/ +// |____|__ \___ > ____| |____| (____ /__| /____ >\___ >__| +// \/ \/\/ \/ \/ \/ +// +// This file contains functions for parsing ssh-keys +// +// TODO: Consider if these functions belong in models - no other models function call them or are called by them +// They may belong in a service or a module + +const ssh2keyStart = "---- BEGIN SSH2 PUBLIC KEY ----" + +func extractTypeFromBase64Key(key string) (string, error) { + b, err := base64.StdEncoding.DecodeString(key) + if err != nil || len(b) < 4 { + return "", fmt.Errorf("invalid key format: %w", err) + } + + keyLength := int(binary.BigEndian.Uint32(b)) + if len(b) < 4+keyLength { + return "", fmt.Errorf("invalid key format: not enough length %d", keyLength) + } + + return string(b[4 : 4+keyLength]), nil +} + +// parseKeyString parses any key string in OpenSSH or SSH2 format to clean OpenSSH string (RFC4253). +func parseKeyString(content string) (string, error) { + // remove whitespace at start and end + content = strings.TrimSpace(content) + + var keyType, keyContent, keyComment string + + if strings.HasPrefix(content, ssh2keyStart) { + // Parse SSH2 file format. + + // Transform all legal line endings to a single "\n". + content = strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(content) + + lines := strings.Split(content, "\n") + continuationLine := false + + for _, line := range lines { + // Skip lines that: + // 1) are a continuation of the previous line, + // 2) contain ":" as that are comment lines + // 3) contain "-" as that are begin and end tags + if continuationLine || strings.ContainsAny(line, ":-") { + continuationLine = strings.HasSuffix(line, "\\") + } else { + keyContent += line + } + } + + t, err := extractTypeFromBase64Key(keyContent) + if err != nil { + return "", fmt.Errorf("extractTypeFromBase64Key: %w", err) + } + keyType = t + } else { + if strings.Contains(content, "-----BEGIN") { + // Convert PEM Keys to OpenSSH format + // Transform all legal line endings to a single "\n". + content = strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(content) + + block, _ := pem.Decode([]byte(content)) + if block == nil { + return "", fmt.Errorf("failed to parse PEM block containing the public key") + } + if strings.Contains(block.Type, "PRIVATE") { + return "", ErrKeyIsPrivate + } + + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + var pk rsa.PublicKey + _, err2 := asn1.Unmarshal(block.Bytes, &pk) + if err2 != nil { + return "", fmt.Errorf("failed to parse DER encoded public key as either PKIX or PEM RSA Key: %v %w", err, err2) + } + pub = &pk + } + + sshKey, err := ssh.NewPublicKey(pub) + if err != nil { + return "", fmt.Errorf("unable to convert to ssh public key: %w", err) + } + content = string(ssh.MarshalAuthorizedKey(sshKey)) + } + // Parse OpenSSH format. + + // Remove all newlines + content = strings.NewReplacer("\r\n", "", "\n", "").Replace(content) + + parts := strings.SplitN(content, " ", 3) + switch len(parts) { + case 0: + return "", util.NewInvalidArgumentErrorf("empty key") + case 1: + keyContent = parts[0] + case 2: + keyType = parts[0] + keyContent = parts[1] + default: + keyType = parts[0] + keyContent = parts[1] + keyComment = parts[2] + } + + // If keyType is not given, extract it from content. If given, validate it. + t, err := extractTypeFromBase64Key(keyContent) + if err != nil { + return "", fmt.Errorf("extractTypeFromBase64Key: %w", err) + } + if len(keyType) == 0 { + keyType = t + } else if keyType != t { + return "", fmt.Errorf("key type and content does not match: %s - %s", keyType, t) + } + } + // Finally we need to check whether we can actually read the proposed key: + _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyType + " " + keyContent + " " + keyComment)) + if err != nil { + return "", fmt.Errorf("invalid ssh public key: %w", err) + } + return keyType + " " + keyContent + " " + keyComment, nil +} + +// CheckPublicKeyString checks if the given public key string is recognized by SSH. +// It returns the actual public key line on success. +func CheckPublicKeyString(content string) (_ string, err error) { + content, err = parseKeyString(content) + if err != nil { + return "", err + } + + content = strings.TrimRight(content, "\n\r") + if strings.ContainsAny(content, "\n\r") { + return "", util.NewInvalidArgumentErrorf("only a single line with a single key please") + } + + // remove any unnecessary whitespace now + content = strings.TrimSpace(content) + + if !setting.SSH.MinimumKeySizeCheck { + return content, nil + } + + var ( + fnName string + keyType string + length int + ) + if len(setting.SSH.KeygenPath) == 0 { + fnName = "SSHNativeParsePublicKey" + keyType, length, err = SSHNativeParsePublicKey(content) + } else { + fnName = "SSHKeyGenParsePublicKey" + keyType, length, err = SSHKeyGenParsePublicKey(content) + } + if err != nil { + return "", fmt.Errorf("%s: %w", fnName, err) + } + log.Trace("Key info [native: %v]: %s-%d", setting.SSH.StartBuiltinServer, keyType, length) + + if minLen, found := setting.SSH.MinimumKeySizes[keyType]; found && length >= minLen { + return content, nil + } else if found && length < minLen { + return "", fmt.Errorf("key length is not enough: got %d, needs %d", length, minLen) + } + return "", fmt.Errorf("key type is not allowed: %s", keyType) +} + +// SSHNativeParsePublicKey extracts the key type and length using the golang SSH library. +func SSHNativeParsePublicKey(keyLine string) (string, int, error) { + fields := strings.Fields(keyLine) + if len(fields) < 2 { + return "", 0, fmt.Errorf("not enough fields in public key line: %s", keyLine) + } + + raw, err := base64.StdEncoding.DecodeString(fields[1]) + if err != nil { + return "", 0, err + } + + pkey, err := ssh.ParsePublicKey(raw) + if err != nil { + if strings.Contains(err.Error(), "ssh: unknown key algorithm") { + return "", 0, ErrKeyUnableVerify{err.Error()} + } + return "", 0, fmt.Errorf("ParsePublicKey: %w", err) + } + + // The ssh library can parse the key, so next we find out what key exactly we have. + switch pkey.Type() { + case ssh.KeyAlgoDSA: + rawPub := struct { + Name string + P, Q, G, Y *big.Int + }{} + if err := ssh.Unmarshal(pkey.Marshal(), &rawPub); err != nil { + return "", 0, err + } + // as per https://bugzilla.mindrot.org/show_bug.cgi?id=1647 we should never + // see dsa keys != 1024 bit, but as it seems to work, we will not check here + return "dsa", rawPub.P.BitLen(), nil // use P as per crypto/dsa/dsa.go (is L) + case ssh.KeyAlgoRSA: + rawPub := struct { + Name string + E *big.Int + N *big.Int + }{} + if err := ssh.Unmarshal(pkey.Marshal(), &rawPub); err != nil { + return "", 0, err + } + return "rsa", rawPub.N.BitLen(), nil // use N as per crypto/rsa/rsa.go (is bits) + case ssh.KeyAlgoECDSA256: + return "ecdsa", 256, nil + case ssh.KeyAlgoECDSA384: + return "ecdsa", 384, nil + case ssh.KeyAlgoECDSA521: + return "ecdsa", 521, nil + case ssh.KeyAlgoED25519: + return "ed25519", 256, nil + case ssh.KeyAlgoSKECDSA256: + return "ecdsa-sk", 256, nil + case ssh.KeyAlgoSKED25519: + return "ed25519-sk", 256, nil + } + return "", 0, fmt.Errorf("unsupported key length detection for type: %s", pkey.Type()) +} + +// writeTmpKeyFile writes key content to a temporary file +// and returns the name of that file, along with any possible errors. +func writeTmpKeyFile(content string) (string, error) { + tmpFile, err := os.CreateTemp(setting.SSH.KeyTestPath, "gitea_keytest") + if err != nil { + return "", fmt.Errorf("TempFile: %w", err) + } + defer tmpFile.Close() + + if _, err = tmpFile.WriteString(content); err != nil { + return "", fmt.Errorf("WriteString: %w", err) + } + return tmpFile.Name(), nil +} + +// SSHKeyGenParsePublicKey extracts key type and length using ssh-keygen. +func SSHKeyGenParsePublicKey(key string) (string, int, error) { + tmpName, err := writeTmpKeyFile(key) + if err != nil { + return "", 0, fmt.Errorf("writeTmpKeyFile: %w", err) + } + defer func() { + if err := util.Remove(tmpName); err != nil { + log.Warn("Unable to remove temporary key file: %s: Error: %v", tmpName, err) + } + }() + + keygenPath := setting.SSH.KeygenPath + if len(keygenPath) == 0 { + keygenPath = "ssh-keygen" + } + + stdout, stderr, err := process.GetManager().Exec("SSHKeyGenParsePublicKey", keygenPath, "-lf", tmpName) + if err != nil { + return "", 0, fmt.Errorf("fail to parse public key: %s - %s", err, stderr) + } + if strings.Contains(stdout, "is not a public key file") { + return "", 0, ErrKeyUnableVerify{stdout} + } + + fields := strings.Split(stdout, " ") + if len(fields) < 4 { + return "", 0, fmt.Errorf("invalid public key line: %s", stdout) + } + + keyType := strings.Trim(fields[len(fields)-1], "()\r\n") + length, err := strconv.ParseInt(fields[0], 10, 32) + if err != nil { + return "", 0, err + } + return strings.ToLower(keyType), int(length), nil +} diff --git a/models/asymkey/ssh_key_principals.go b/models/asymkey/ssh_key_principals.go new file mode 100644 index 0000000..4e7dee2 --- /dev/null +++ b/models/asymkey/ssh_key_principals.go @@ -0,0 +1,96 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +// AddPrincipalKey adds new principal to database and authorized_principals file. +func AddPrincipalKey(ctx context.Context, ownerID int64, content string, authSourceID int64) (*PublicKey, error) { + dbCtx, committer, err := db.TxContext(ctx) + if err != nil { + return nil, err + } + defer committer.Close() + + // Principals cannot be duplicated. + has, err := db.GetEngine(dbCtx). + Where("content = ? AND type = ?", content, KeyTypePrincipal). + Get(new(PublicKey)) + if err != nil { + return nil, err + } else if has { + return nil, ErrKeyAlreadyExist{0, "", content} + } + + key := &PublicKey{ + OwnerID: ownerID, + Name: content, + Content: content, + Mode: perm.AccessModeWrite, + Type: KeyTypePrincipal, + LoginSourceID: authSourceID, + } + if err = db.Insert(dbCtx, key); err != nil { + return nil, fmt.Errorf("addKey: %w", err) + } + + if err = committer.Commit(); err != nil { + return nil, err + } + + committer.Close() + + return key, RewriteAllPrincipalKeys(ctx) +} + +// CheckPrincipalKeyString strips spaces and returns an error if the given principal contains newlines +func CheckPrincipalKeyString(ctx context.Context, user *user_model.User, content string) (_ string, err error) { + if setting.SSH.Disabled { + return "", db.ErrSSHDisabled{} + } + + content = strings.TrimSpace(content) + if strings.ContainsAny(content, "\r\n") { + return "", util.NewInvalidArgumentErrorf("only a single line with a single principal please") + } + + // check all the allowed principals, email, username or anything + // if any matches, return ok + for _, v := range setting.SSH.AuthorizedPrincipalsAllow { + switch v { + case "anything": + return content, nil + case "email": + emails, err := user_model.GetEmailAddresses(ctx, user.ID) + if err != nil { + return "", err + } + for _, email := range emails { + if !email.IsActivated { + continue + } + if content == email.Email { + return content, nil + } + } + + case "username": + if content == user.Name { + return content, nil + } + } + } + + return "", fmt.Errorf("didn't match allowed principals: %s", setting.SSH.AuthorizedPrincipalsAllow) +} diff --git a/models/asymkey/ssh_key_test.go b/models/asymkey/ssh_key_test.go new file mode 100644 index 0000000..2625d6a --- /dev/null +++ b/models/asymkey/ssh_key_test.go @@ -0,0 +1,513 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + + "github.com/42wim/sshsig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_SSHParsePublicKey(t *testing.T) { + testCases := []struct { + name string + skipSSHKeygen bool + keyType string + length int + content string + }{ + {"rsa-1024", false, "rsa", 1024, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\n"}, + {"rsa-2048", false, "rsa", 2048, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDMZXh+1OBUwSH9D45wTaxErQIN9IoC9xl7MKJkqvTvv6O5RR9YW/IK9FbfjXgXsppYGhsCZo1hFOOsXHMnfOORqu/xMDx4yPuyvKpw4LePEcg4TDipaDFuxbWOqc/BUZRZcXu41QAWfDLrInwsltWZHSeG7hjhpacl4FrVv9V1pS6Oc5Q1NxxEzTzuNLS/8diZrTm/YAQQ/+B+mzWI3zEtF4miZjjAljWd1LTBPvU23d29DcBmmFahcZ441XZsTeAwGxG/Q6j8NgNXj9WxMeWwxXV2jeAX/EBSpZrCVlCQ1yJswT6xCp8TuBnTiGWYMBNTbOZvPC4e0WI2/yZW/s5F nocomment"}, + {"ecdsa-256", false, "ecdsa", 256, "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFQacN3PrOll7PXmN5B/ZNVahiUIqI05nbBlZk1KXsO3d06ktAWqbNflv2vEmA38bTFTfJ2sbn2B5ksT52cDDbA= nocomment"}, + {"ecdsa-384", false, "ecdsa", 384, "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBINmioV+XRX1Fm9Qk2ehHXJ2tfVxW30ypUWZw670Zyq5GQfBAH6xjygRsJ5wWsHXBsGYgFUXIHvMKVAG1tpw7s6ax9oA+dJOJ7tj+vhn8joFqT+sg3LYHgZkHrfqryRasQ== nocomment"}, + {"ecdsa-sk", true, "ecdsa-sk", 256, "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment"}, + {"ed25519-sk", true, "ed25519-sk", 256, "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIE7kM1R02+4ertDKGKEDcKG0s+2vyDDcIvceJ0Gqv5f1AAAABHNzaDo= nocomment"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("Native", func(t *testing.T) { + keyTypeN, lengthN, err := SSHNativeParsePublicKey(tc.content) + require.NoError(t, err) + assert.Equal(t, tc.keyType, keyTypeN) + assert.EqualValues(t, tc.length, lengthN) + }) + if tc.skipSSHKeygen { + return + } + t.Run("SSHKeygen", func(t *testing.T) { + keyTypeK, lengthK, err := SSHKeyGenParsePublicKey(tc.content) + if err != nil { + // Some servers do not support ecdsa format. + if !strings.Contains(err.Error(), "line 1 too long:") { + assert.FailNow(t, "%v", err) + } + } + assert.Equal(t, tc.keyType, keyTypeK) + assert.EqualValues(t, tc.length, lengthK) + }) + t.Run("SSHParseKeyNative", func(t *testing.T) { + keyTypeK, lengthK, err := SSHNativeParsePublicKey(tc.content) + if err != nil { + assert.FailNow(t, "%v", err) + } + assert.Equal(t, tc.keyType, keyTypeK) + assert.EqualValues(t, tc.length, lengthK) + }) + }) + } +} + +func Test_CheckPublicKeyString(t *testing.T) { + oldValue := setting.SSH.MinimumKeySizeCheck + setting.SSH.MinimumKeySizeCheck = false + for _, test := range []struct { + content string + }{ + {"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\n"}, + {"ssh-rsa AAAAB3NzaC1yc2EA\r\nAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+\r\nBZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNx\r\nfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\r\n\r\n"}, + {"ssh-rsa AAAAB3NzaC1yc2EA\r\nAAADAQABAAAAgQDAu7tvI\nvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+\r\nBZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvW\nqIwC4prx/WVk2wLTJjzBAhyNx\r\nfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\r\n\r\n"}, + {"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf"}, + {"\r\nssh-ed25519 \r\nAAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf\r\n\r\n"}, + {"sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment"}, + {"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIE7kM1R02+4ertDKGKEDcKG0s+2vyDDcIvceJ0Gqv5f1AAAABHNzaDo= nocomment"}, + {`---- BEGIN SSH2 PUBLIC KEY ---- +Comment: "1024-bit DSA, converted by andrew@phaedra from OpenSSH" +AAAAB3NzaC1kc3MAAACBAOChCC7lf6Uo9n7BmZ6M8St19PZf4Tn59NriyboW2x/DZuYAz3 +ibZ2OkQ3S0SqDIa0HXSEJ1zaExQdmbO+Ux/wsytWZmCczWOVsaszBZSl90q8UnWlSH6P+/ +YA+RWJm5SFtuV9PtGIhyZgoNuz5kBQ7K139wuQsecdKktISwTakzAAAAFQCzKsO2JhNKlL ++wwwLGOcLffoAmkwAAAIBpK7/3xvduajLBD/9vASqBQIHrgK2J+wiQnIb/Wzy0UsVmvfn8 +A+udRbBo+csM8xrSnlnlJnjkJS3qiM5g+eTwsLIV1IdKPEwmwB+VcP53Cw6lSyWyJcvhFb +0N6s08NZysLzvj0N+ZC/FnhKTLzIyMtkHf/IrPCwlM+pV/M/96YgAAAIEAqQcGn9CKgzgP +aguIZooTAOQdvBLMI5y0bQjOW6734XOpqQGf/Kra90wpoasLKZjSYKNPjE+FRUOrStLrxc +Ns4BeVKhy2PYTRnybfYVk1/dmKgH6P1YSRONsGKvTsH6c5IyCRG0ncCgYeF8tXppyd6429 +82daopE7zQ/NPAnJfag= +---- END SSH2 PUBLIC KEY ---- +`}, + {`---- BEGIN SSH2 PUBLIC KEY ---- +Comment: "1024-bit RSA, converted by andrew@phaedra from OpenSSH" +AAAAB3NzaC1yc2EAAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxB +cQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jBeSjH2G7lxet9kbcH+kIV +j0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C9CeiX9pQEbEqJfkKCQ== +---- END SSH2 PUBLIC KEY ---- +`}, + {`-----BEGIN RSA PUBLIC KEY----- +MIGJAoGBAMC7u28i9fpketFe5k1+RHdcsdKy4Ir1mfdfnyXEFxDO6jnFmAHq9HDC +b9C0m4X7Nk+1jmGxAgsEuYX4FnlakpmnWMF5KMfYbuXF632Rtwf6QhWPS08USjIo +j3C9aojALimvH9ZWTbAtMmPMECHI3F8SrsL0J6Jf2lARsSol+QoJAgMBAAE= +-----END RSA PUBLIC KEY----- +`}, + {`-----BEGIN PUBLIC KEY----- +MIIBtzCCASsGByqGSM44BAEwggEeAoGBAOChCC7lf6Uo9n7BmZ6M8St19PZf4Tn5 +9NriyboW2x/DZuYAz3ibZ2OkQ3S0SqDIa0HXSEJ1zaExQdmbO+Ux/wsytWZmCczW +OVsaszBZSl90q8UnWlSH6P+/YA+RWJm5SFtuV9PtGIhyZgoNuz5kBQ7K139wuQse +cdKktISwTakzAhUAsyrDtiYTSpS/sMMCxjnC336AJpMCgYBpK7/3xvduajLBD/9v +ASqBQIHrgK2J+wiQnIb/Wzy0UsVmvfn8A+udRbBo+csM8xrSnlnlJnjkJS3qiM5g ++eTwsLIV1IdKPEwmwB+VcP53Cw6lSyWyJcvhFb0N6s08NZysLzvj0N+ZC/FnhKTL +zIyMtkHf/IrPCwlM+pV/M/96YgOBhQACgYEAqQcGn9CKgzgPaguIZooTAOQdvBLM +I5y0bQjOW6734XOpqQGf/Kra90wpoasLKZjSYKNPjE+FRUOrStLrxcNs4BeVKhy2 +PYTRnybfYVk1/dmKgH6P1YSRONsGKvTsH6c5IyCRG0ncCgYeF8tXppyd642982da +opE7zQ/NPAnJfag= +-----END PUBLIC KEY----- +`}, + {`-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDAu7tvIvX6ZHrRXuZNfkR3XLHS +suCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jB +eSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C +9CeiX9pQEbEqJfkKCQIDAQAB +-----END PUBLIC KEY----- +`}, + {`-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzGV4ftTgVMEh/Q+OcE2s +RK0CDfSKAvcZezCiZKr077+juUUfWFvyCvRW3414F7KaWBobAmaNYRTjrFxzJ3zj +karv8TA8eMj7sryqcOC3jxHIOEw4qWgxbsW1jqnPwVGUWXF7uNUAFnwy6yJ8LJbV +mR0nhu4Y4aWnJeBa1b/VdaUujnOUNTccRM087jS0v/HYma05v2AEEP/gfps1iN8x +LReJomY4wJY1ndS0wT71Nt3dvQ3AZphWoXGeONV2bE3gMBsRv0Oo/DYDV4/VsTHl +sMV1do3gF/xAUqWawlZQkNcibME+sQqfE7gZ04hlmDATU2zmbzwuHtFiNv8mVv7O +RQIDAQAB +-----END PUBLIC KEY----- +`}, + {`---- BEGIN SSH2 PUBLIC KEY ---- +Comment: "256-bit ED25519, converted by andrew@phaedra from OpenSSH" +AAAAC3NzaC1lZDI1NTE5AAAAICV0MGX/W9IvLA4FXpIuUcdDcbj5KX4syHgsTy7soVgf +---- END SSH2 PUBLIC KEY ---- +`}, + } { + _, err := CheckPublicKeyString(test.content) + require.NoError(t, err) + } + setting.SSH.MinimumKeySizeCheck = oldValue + for _, invalidKeys := range []struct { + content string + }{ + {"test"}, + {"---- NOT A REAL KEY ----"}, + {"bad\nkey"}, + {"\t\t:)\t\r\n"}, + {"\r\ntest \r\ngitea\r\n\r\n"}, + } { + _, err := CheckPublicKeyString(invalidKeys.content) + require.Error(t, err) + } +} + +func Test_calcFingerprint(t *testing.T) { + testCases := []struct { + name string + skipSSHKeygen bool + fp string + content string + }{ + {"rsa-1024", false, "SHA256:vSnDkvRh/xM6kMxPidLgrUhq3mCN7CDaronCEm2joyQ", "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDAu7tvIvX6ZHrRXuZNfkR3XLHSsuCK9Zn3X58lxBcQzuo5xZgB6vRwwm/QtJuF+zZPtY5hsQILBLmF+BZ5WpKZp1jBeSjH2G7lxet9kbcH+kIVj0tPFEoyKI9wvWqIwC4prx/WVk2wLTJjzBAhyNxfEq7C9CeiX9pQEbEqJfkKCQ== nocomment\n"}, + {"rsa-2048", false, "SHA256:ZHD//a1b9VuTq9XSunAeYjKeU1xDa2tBFZYrFr2Okkg", "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDMZXh+1OBUwSH9D45wTaxErQIN9IoC9xl7MKJkqvTvv6O5RR9YW/IK9FbfjXgXsppYGhsCZo1hFOOsXHMnfOORqu/xMDx4yPuyvKpw4LePEcg4TDipaDFuxbWOqc/BUZRZcXu41QAWfDLrInwsltWZHSeG7hjhpacl4FrVv9V1pS6Oc5Q1NxxEzTzuNLS/8diZrTm/YAQQ/+B+mzWI3zEtF4miZjjAljWd1LTBPvU23d29DcBmmFahcZ441XZsTeAwGxG/Q6j8NgNXj9WxMeWwxXV2jeAX/EBSpZrCVlCQ1yJswT6xCp8TuBnTiGWYMBNTbOZvPC4e0WI2/yZW/s5F nocomment"}, + {"ecdsa-256", false, "SHA256:Bqx/xgWqRKLtkZ0Lr4iZpgb+5lYsFpSwXwVZbPwuTRw", "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFQacN3PrOll7PXmN5B/ZNVahiUIqI05nbBlZk1KXsO3d06ktAWqbNflv2vEmA38bTFTfJ2sbn2B5ksT52cDDbA= nocomment"}, + {"ecdsa-384", false, "SHA256:4qfJOgJDtUd8BrEjyVNdI8IgjiZKouztVde43aDhe1E", "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBINmioV+XRX1Fm9Qk2ehHXJ2tfVxW30ypUWZw670Zyq5GQfBAH6xjygRsJ5wWsHXBsGYgFUXIHvMKVAG1tpw7s6ax9oA+dJOJ7tj+vhn8joFqT+sg3LYHgZkHrfqryRasQ== nocomment"}, + {"ecdsa-sk", true, "SHA256:4wcIu4z+53gHc+db85OPfy8IydyNzPLCr6kHIs625LQ", "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment"}, + {"ed25519-sk", true, "SHA256:RB4ku1OeWKN7fLMrjxz38DK0mp1BnOPBx4BItjTvJ0g", "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIE7kM1R02+4ertDKGKEDcKG0s+2vyDDcIvceJ0Gqv5f1AAAABHNzaDo= nocomment"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Run("Native", func(t *testing.T) { + fpN, err := calcFingerprintNative(tc.content) + require.NoError(t, err) + assert.Equal(t, tc.fp, fpN) + }) + if tc.skipSSHKeygen { + return + } + t.Run("SSHKeygen", func(t *testing.T) { + fpK, err := calcFingerprintSSHKeygen(tc.content) + require.NoError(t, err) + assert.Equal(t, tc.fp, fpK) + }) + }) + } +} + +var ( + // Generated with "ssh-keygen -C test@rekor.dev -f id_rsa" + sshPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY----- +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 +-----END OPENSSH PRIVATE KEY----- +` + sshPublicKey = `ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDXofkiahE7uavjWvxnwkUF27qMgz7pdTwzSv0XzVG6EtirOv3PDWct4YKoXE9c0EqbxnIfYEKwEextdvB7zkgwczdJSHxf/18jQumLn/FuoCmugVSk1H5Qli/qzwBpaTnOk3WuakGuoYUl8ZAokKKgOKLA0aZJ1WRQ2ZCZggA3EkwNZiY17y9Q6HqdgQcH6XN8aAMADNVJdMAJb33hSRJjjsAPTmzBTishP8lYDoGRSsSE7/8XRBCEV5E4I8mI9GElcZwV/1KJx98mpH8QvMzXM1idFcwPRtt1NTAOshwgUU0Fu1x8lU5RQIa6ZKW36qNQLvLxy/BscC7B/mdLptoDs/ot9NimUXZcgCR1a2Q3o7Wi6jIgcgJcyV10Nba81ol4RdN4qPHnVZIzuo+dBkqwG3CMtB4Rj84+Qi+7zyU01hIPreoxQDXaayiGPBUUIiAlW9gsiuRWJzNnu3cvuWDLVfQIkjh7Wug58z+v2NOJ7IMdyERillhzDcvVHaq14+U= test@rekor.dev +` + // Generated with "ssh-keygen -C other-test@rekor.dev -f id_rsa" + otherSSHPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY----- +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 +-----END OPENSSH PRIVATE KEY----- +` + otherSSHPublicKey = `ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDD9YJJYL1MS8JA7A75PrwS81rc5Ii/VjT6xPy5K/KM/IQ7T6nS3wKD/PLru87GJClTmM3owi2NcCMyZ4KbwYoy3qCT5wOhoB6/9l+O7NIvtFrITGmaEGV6HZfcYouSRcX0AEU1yGVOpIs5mISMOg2lsW/XopPWwToKpbwOPFdCRCT0krrmEsf4HF5Yw0IQlVoKZrhfThomYLvMkCLnIZ55PRIpWoyiFq8X3Q7peJgUJAe7Bc8/Id+hyqC52ZeejPP7oPprEkpkzBCw2ndYq6Y6OXNafEEIIHWXaM3pFqDxonbbvuIwVdHCNMv/yNoSxbgqTKwN/QaNXb+KpuvSrlvRqsNhu/sKsYFH64fTAbP9miDXHmJkA05uFlQukOstUmJ0QxzbsdcFvs8yw0PLsEZhEHXJzR3TLzenyZSq86VZICvGfVacBk7TikCBOtyWuESHhlc6SfZKfzZ67cOlDyKeSiVjgh+eEh9s4h56ahQ2rW05Sq6GjD0YtEzog2J4csE= other-test@rekor.dev +` + + // Generated with ssh-keygen -C test@rekor.dev -t ed25519 -f id_ed25519 + ed25519PrivateKey = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBB45zRHxPPFtabwS3Vd6Lb9vMe+tIHZj2qN5VQ+bgLfQAAAJgyRa3cMkWt +3AAAAAtzc2gtZWQyNTUxOQAAACBB45zRHxPPFtabwS3Vd6Lb9vMe+tIHZj2qN5VQ+bgLfQ +AAAED7y4N/DsVnRQiBZNxEWdsJ9RmbranvtQ3X9jnb6gFed0HjnNEfE88W1pvBLdV3otv2 +8x760gdmPao3lVD5uAt9AAAADnRlc3RAcmVrb3IuZGV2AQIDBAUGBw== +-----END OPENSSH PRIVATE KEY----- +` + ed25519PublicKey = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEHjnNEfE88W1pvBLdV3otv28x760gdmPao3lVD5uAt9 test@rekor.dev +` +) + +func TestFromOpenSSH(t *testing.T) { + for _, tt := range []struct { + name string + pub string + priv string + }{ + { + name: "rsa", + pub: sshPublicKey, + priv: sshPrivateKey, + }, + { + name: "ed25519", + pub: ed25519PublicKey, + priv: ed25519PrivateKey, + }, + } { + if _, err := exec.LookPath("ssh-keygen"); err != nil { + t.Skip("skip TestFromOpenSSH: missing ssh-keygen in PATH") + } + t.Run(tt.name, func(t *testing.T) { + tt := tt + + // Test that a signature from the cli can validate here. + td := t.TempDir() + + data := []byte("hello, ssh world") + dataPath := write(t, data, td, "data") + + privPath := write(t, []byte(tt.priv), td, "id") + write(t, []byte(tt.pub), td, "id.pub") + + sigPath := dataPath + ".sig" + run(t, nil, "ssh-keygen", "-Y", "sign", "-n", "file", "-f", privPath, dataPath) + + sigBytes, err := os.ReadFile(sigPath) + if err != nil { + t.Fatal(err) + } + if err := sshsig.Verify(bytes.NewReader(data), sigBytes, []byte(tt.pub), "file"); err != nil { + t.Error(err) + } + + // It should not verify if we check against another public key + if err := sshsig.Verify(bytes.NewReader(data), sigBytes, []byte(otherSSHPublicKey), "file"); err == nil { + t.Error("expected error with incorrect key") + } + + // It should not verify if the data is tampered + if err := sshsig.Verify(strings.NewReader("bad data"), sigBytes, []byte(sshPublicKey), "file"); err == nil { + t.Error("expected error with incorrect data") + } + }) + } +} + +func TestToOpenSSH(t *testing.T) { + for _, tt := range []struct { + name string + pub string + priv string + }{ + { + name: "rsa", + pub: sshPublicKey, + priv: sshPrivateKey, + }, + { + name: "ed25519", + pub: ed25519PublicKey, + priv: ed25519PrivateKey, + }, + } { + if _, err := exec.LookPath("ssh-keygen"); err != nil { + t.Skip("skip TestToOpenSSH: missing ssh-keygen in PATH") + } + t.Run(tt.name, func(t *testing.T) { + tt := tt + // Test that a signature from here can validate in the CLI. + td := t.TempDir() + + data := []byte("hello, ssh world") + write(t, data, td, "data") + + armored, err := sshsig.Sign([]byte(tt.priv), bytes.NewReader(data), "file") + if err != nil { + t.Fatal(err) + } + + sigPath := write(t, armored, td, "oursig") + + // Create an allowed_signers file with two keys to check against. + allowedSigner := "test@rekor.dev " + tt.pub + "\n" + allowedSigner += "othertest@rekor.dev " + otherSSHPublicKey + "\n" + allowedSigners := write(t, []byte(allowedSigner), td, "allowed_signer") + + // We use the correct principal here so it should work. + run(t, data, "ssh-keygen", "-Y", "verify", "-f", allowedSigners, + "-I", "test@rekor.dev", "-n", "file", "-s", sigPath) + + // Just to be sure, check against the other public key as well. + runErr(t, data, "ssh-keygen", "-Y", "verify", "-f", allowedSigners, + "-I", "othertest@rekor.dev", "-n", "file", "-s", sigPath) + + // It should error if we run it against other data + data = []byte("other data!") + runErr(t, data, "ssh-keygen", "-Y", "check-novalidate", "-n", "file", "-s", sigPath) + }) + } +} + +func TestRoundTrip(t *testing.T) { + data := []byte("my good data to be signed!") + + // Create one extra signature for all the tests. + otherSig, err := sshsig.Sign([]byte(otherSSHPrivateKey), bytes.NewReader(data), "file") + if err != nil { + t.Fatal(err) + } + + for _, tt := range []struct { + name string + pub string + priv string + }{ + { + name: "rsa", + pub: sshPublicKey, + priv: sshPrivateKey, + }, + { + name: "ed25519", + pub: ed25519PublicKey, + priv: ed25519PrivateKey, + }, + } { + t.Run(tt.name, func(t *testing.T) { + tt := tt + sig, err := sshsig.Sign([]byte(tt.priv), bytes.NewReader(data), "file") + if err != nil { + t.Fatal(err) + } + + // Check the signature against that data and public key + if err := sshsig.Verify(bytes.NewReader(data), sig, []byte(tt.pub), "file"); err != nil { + t.Error(err) + } + + // Now check it against invalid data. + if err := sshsig.Verify(strings.NewReader("invalid data!"), sig, []byte(tt.pub), "file"); err == nil { + t.Error("expected error!") + } + + // Now check it against the wrong key. + if err := sshsig.Verify(bytes.NewReader(data), sig, []byte(otherSSHPublicKey), "file"); err == nil { + t.Error("expected error!") + } + + // Now check it against an invalid signature data. + if err := sshsig.Verify(bytes.NewReader(data), []byte("invalid signature!"), []byte(tt.pub), "file"); err == nil { + t.Error("expected error!") + } + + // Once more, use the wrong signature and check it against the original (wrong public key) + if err := sshsig.Verify(bytes.NewReader(data), otherSig, []byte(tt.pub), "file"); err == nil { + t.Error("expected error!") + } + // It should work against the correct public key. + if err := sshsig.Verify(bytes.NewReader(data), otherSig, []byte(otherSSHPublicKey), "file"); err != nil { + t.Error(err) + } + }) + } +} + +func write(t *testing.T, d []byte, fp ...string) string { + p := filepath.Join(fp...) + if err := os.WriteFile(p, d, 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func run(t *testing.T, stdin []byte, args ...string) { + t.Helper() + /* #nosec */ + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdin = bytes.NewReader(stdin) + out, err := cmd.CombinedOutput() + t.Logf("cmd %v: %s", cmd, string(out)) + if err != nil { + t.Fatal(err) + } +} + +func runErr(t *testing.T, stdin []byte, args ...string) { + t.Helper() + /* #nosec */ + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdin = bytes.NewReader(stdin) + out, err := cmd.CombinedOutput() + t.Logf("cmd %v: %s", cmd, string(out)) + if err == nil { + t.Fatal("expected error") + } +} + +func Test_PublicKeysAreExternallyManaged(t *testing.T) { + key1 := unittest.AssertExistsAndLoadBean(t, &PublicKey{ID: 1}) + externals, err := PublicKeysAreExternallyManaged(db.DefaultContext, []*PublicKey{key1}) + require.NoError(t, err) + assert.Len(t, externals, 1) + assert.False(t, externals[0]) +} diff --git a/models/asymkey/ssh_key_verify.go b/models/asymkey/ssh_key_verify.go new file mode 100644 index 0000000..208288c --- /dev/null +++ b/models/asymkey/ssh_key_verify.go @@ -0,0 +1,55 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "bytes" + "context" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + + "github.com/42wim/sshsig" +) + +// VerifySSHKey marks a SSH key as verified +func VerifySSHKey(ctx context.Context, ownerID int64, fingerprint, token, signature string) (string, error) { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return "", err + } + defer committer.Close() + + key := new(PublicKey) + + has, err := db.GetEngine(ctx).Where("owner_id = ? AND fingerprint = ?", ownerID, fingerprint).Get(key) + if err != nil { + return "", err + } else if !has { + return "", ErrKeyNotExist{} + } + + err = sshsig.Verify(bytes.NewBuffer([]byte(token)), []byte(signature), []byte(key.Content), "gitea") + if err != nil { + // edge case for Windows based shells that will add CR LF if piped to ssh-keygen command + // see https://github.com/PowerShell/PowerShell/issues/5974 + if sshsig.Verify(bytes.NewBuffer([]byte(token+"\r\n")), []byte(signature), []byte(key.Content), "gitea") != nil { + log.Error("Unable to validate token signature. Error: %v", err) + return "", ErrSSHInvalidTokenSignature{ + Fingerprint: key.Fingerprint, + } + } + } + + key.Verified = true + if _, err := db.GetEngine(ctx).ID(key.ID).Cols("verified").Update(key); err != nil { + return "", err + } + + if err := committer.Commit(); err != nil { + return "", err + } + + return key.Fingerprint, nil +} |