Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement http signatures support for the API #17565

Merged
merged 27 commits into from
Jun 5, 2022
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4effd55
Implement http signatures support for the API
42wim Nov 5, 2021
465a7f4
Return nil on error
42wim Nov 6, 2021
c8c344e
Add Failed authentication attempt warning
42wim Nov 6, 2021
8612a72
Fix upstream api changes to user_model
42wim Dec 2, 2021
b0005d5
Fix upstream api changes to asymkey_model
42wim Dec 12, 2021
8377c7f
Merge branch 'main' into httpsign
42wim Feb 13, 2022
c86617b
Apply suggestions from code review
42wim Feb 13, 2022
0cf37bb
Apply more suggestions from code review
42wim Feb 13, 2022
b7dc05c
Merge branch 'main' into HEAD
42wim May 29, 2022
98b5bd5
Fix upstream main API changes
42wim May 29, 2022
44cfbfb
Add error when principal doesn't exist in gitea
42wim May 29, 2022
437bb86
Marshal auth only once
42wim May 30, 2022
0e5560a
Add doVerify comment
42wim May 30, 2022
a91b996
Optimize marshal in ssh module
42wim May 30, 2022
988f425
Update services/auth/httpsign.go
42wim May 30, 2022
b19b39b
Add support for normal pubkeys
42wim May 30, 2022
a8aa576
Merge branch 'main' into httpsign
6543 May 30, 2022
864deef
Apply suggestions from code review
zeripath Jun 1, 2022
826eb39
Apply suggestions from code review
zeripath Jun 1, 2022
c3ad5e8
Properly verify the publickey signing (#1)
zeripath Jun 1, 2022
81365cf
Add code review changes
42wim Jun 3, 2022
6c00f75
Add integration tests for pub/privkey and certificate
42wim Jun 3, 2022
40da8bd
Add copyright and blank line
42wim Jun 4, 2022
b8d43cd
Add SSH_TRUSTED_USER_CA_KEYS to all database templates
42wim Jun 4, 2022
2870bc0
Merge branch 'main' into httpsign
lunny Jun 4, 2022
b723c85
Merge branch 'main' into httpsign
lunny Jun 4, 2022
fa92d97
Merge branch 'main' into httpsign
lunny Jun 5, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ require (
github.com/go-chi/chi/v5 v5.0.7
github.com/go-chi/cors v1.2.1
github.com/go-enry/go-enry/v2 v2.8.2
github.com/go-fed/httpsig v1.1.0
42wim marked this conversation as resolved.
Show resolved Hide resolved
github.com/go-git/go-billy/v5 v5.3.1
github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4
github.com/go-ldap/ldap/v3 v3.4.3
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,8 @@ github.com/go-enry/go-enry/v2 v2.8.2 h1:uiGmC+3K8sVd/6DOe2AOJEOihJdqda83nPyJNtMR
github.com/go-enry/go-enry/v2 v2.8.2/go.mod h1:GVzIiAytiS5uT/QiuakK7TF1u4xDab87Y8V5EJRpsIQ=
github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
Expand Down
3 changes: 2 additions & 1 deletion modules/ssh/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,9 @@ func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool {

c := &gossh.CertChecker{
IsUserAuthority: func(auth gossh.PublicKey) bool {
marshaled := auth.Marshal()
for _, k := range setting.SSH.TrustedUserCAKeysParsed {
if bytes.Equal(auth.Marshal(), k.Marshal()) {
if bytes.Equal(marshaled, k.Marshal()) {
return true
}
}
Expand Down
1 change: 1 addition & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@ func bind(obj interface{}) http.HandlerFunc {
func buildAuthGroup() *auth.Group {
group := auth.NewGroup(
&auth.OAuth2{},
&auth.HTTPSign{},
&auth.Basic{}, // FIXME: this should be removed once we don't allow basic auth in API
)
if setting.Service.EnableReverseProxyAuth {
Expand Down
231 changes: 231 additions & 0 deletions services/auth/httpsign.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package auth

import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strings"

asymkey_model "code.gitea.io/gitea/models/asymkey"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web/middleware"

"github.com/go-fed/httpsig"
42wim marked this conversation as resolved.
Show resolved Hide resolved
"golang.org/x/crypto/ssh"
)

// Ensure the struct implements the interface.
var (
_ Method = &HTTPSign{}
_ Named = &HTTPSign{}
)

// HTTPSign implements the Auth interface and authenticates requests (API requests
// only) by looking for http signature data in the "Signature" header.
// more information can be found on https://github.com/go-fed/httpsig
type HTTPSign struct{}

// Name represents the name of auth method
func (h *HTTPSign) Name() string {
return "httpsign"
}

// Verify extracts and validates HTTPsign from the Signature header of the request and returns
// the corresponding user object on successful validation.
// Returns nil if header is empty or validation fails.
func (h *HTTPSign) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) *user_model.User {
// HTTPSign authentication should only fire on API
if !middleware.IsAPIPath(req) {
42wim marked this conversation as resolved.
Show resolved Hide resolved
return nil
}

sigHead := req.Header.Get("Signature")
zeripath marked this conversation as resolved.
Show resolved Hide resolved
if len(sigHead) == 0 {
return nil
}

var (
u *user_model.User
validpk *asymkey_model.PublicKey
err error
)

// Handle SSH certificates
if len(req.Header.Get("X-Ssh-Certificate")) != 0 {
if len(setting.SSH.TrustedUserCAKeys) == 0 {
return nil
}

validpk, err = VerifyCert(req)
if err != nil {
log.Debug("VerifyCert on request from %s: failed: %v", req.RemoteAddr, err)
log.Warn("Failed authentication attempt from %s", req.RemoteAddr)
return nil
}
} else {
keyID, err := GetKeyID(req)
if err != nil {
log.Debug("GetKeyID failed: %v", err)
return nil
}

validpk, err = VerifyPubKey(req, keyID)
if err != nil {
log.Debug("VerifyPubKey on request from %s: failed: %v", req.RemoteAddr, err)
log.Warn("Failed authentication attempt from %s", req.RemoteAddr)
return nil
}
}

u, err = user_model.GetUserByID(validpk.OwnerID)
if err != nil {
log.Error("GetUserByID: %v", err)
return nil
}

store.GetData()["IsApiToken"] = true

log.Trace("HTTP Sign: Logged in user %-v", u)

return u
}

func VerifyPubKey(r *http.Request, keyID string) (*asymkey_model.PublicKey, error) {
validpk, err := asymkey_model.SearchPublicKey(0, keyID)
if err != nil {
return nil, err
}

if len(validpk) == 0 {
return nil, fmt.Errorf("no public key found for keyid %s", keyID)
}

return validpk[0], nil
}

// VerifyCert verifies the validity of the ssh certificate and returns the publickey of the signer
// We verify that the certificate is signed with the correct CA
// We verify that the http request is signed with the private key (of the public key mentioned in the certificate)
func VerifyCert(r *http.Request) (*asymkey_model.PublicKey, error) {
var validpk *asymkey_model.PublicKey

// Get our certificate from the header
bcert, err := base64.RawStdEncoding.DecodeString(r.Header.Get("x-ssh-certificate"))
if err != nil {
return validpk, err
}

pk, err := ssh.ParsePublicKey(bcert)
if err != nil {
return validpk, err
}

// Check if it's really a ssh certificate
cert, ok := pk.(*ssh.Certificate)
if !ok {
return validpk, fmt.Errorf("no certificate found")
}

for _, principal := range cert.ValidPrincipals {
validpk, err = asymkey_model.SearchPublicKeyByContentExact(r.Context(), principal)
if err != nil {
if asymkey_model.IsErrKeyNotExist(err) {
continue
}
log.Error("SearchPublicKeyByContentExact: %v", err)
return validpk, err
}

c := &ssh.CertChecker{
IsUserAuthority: func(auth ssh.PublicKey) bool {
marshaled := auth.Marshal()

for _, k := range setting.SSH.TrustedUserCAKeysParsed {
if bytes.Equal(marshaled, k.Marshal()) {
return true
}
}

return false
},
}

// check the CA of the cert
if !c.IsUserAuthority(cert.SignatureKey) {
return validpk, fmt.Errorf("CA check failed")
}

// validate the cert for this principal
if err := c.CheckCert(principal, cert); err != nil {
return validpk, fmt.Errorf("no valid principal found")
}

break
}

// validpk will be nil when we didn't find a principal matching the certificate registered in gitea
if validpk == nil {
return validpk, fmt.Errorf("no valid principal found")
}

verifier, err := httpsig.NewVerifier(r)
if err != nil {
return validpk, fmt.Errorf("httpsig.NewVerifier failed: %s", err)
}

// now verify that we signed this request with the publickey of the cert
err = doVerify(verifier, []ssh.PublicKey{cert.Key})
if err != nil {
return validpk, err
}

return validpk, nil
}

// doVerify iterates across the provided public keys attempting the verify the current request against each key in turn
func doVerify(verifier httpsig.Verifier, publickeys []ssh.PublicKey) error {
42wim marked this conversation as resolved.
Show resolved Hide resolved
verified := false

for _, pubkey := range publickeys {
cryptoPubkey := pubkey.(ssh.CryptoPublicKey).CryptoPublicKey()

var algo httpsig.Algorithm

switch {
case strings.HasPrefix(pubkey.Type(), "ssh-ed25519"):
algo = httpsig.ED25519
case strings.HasPrefix(pubkey.Type(), "ssh-rsa"):
algo = httpsig.RSA_SHA1
42wim marked this conversation as resolved.
Show resolved Hide resolved
}

err := verifier.Verify(cryptoPubkey, algo)
if err == nil {
verified = true
lunny marked this conversation as resolved.
Show resolved Hide resolved
break
}
}

if verified {
return nil
}

return errors.New("verification failed")
}

// GetKeyID returns the keyid from the httpsignature or an error if doesn't exist
func GetKeyID(r *http.Request) (string, error) {
verifier, err := httpsig.NewVerifier(r)
if err != nil {
return "", fmt.Errorf("httpsig.NewVerifier failed: %s", err)
}

return verifier.KeyId(), nil
}