mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
- `internal/auth`: credentials in the OS keyring or a private file, OAuth login with token refresh, SSH HTTP-signature signers (key files with passphrase prompt, ssh-agent keys and certificates), and the Git credential helper protocol. - `internal/git`: cancellable Git subprocesses, repository and remote discovery (tracked remote via `branch.<name>.remote`), PR checkout by full ref, AGit push, branch cleanup. Nothing imports these packages yet. Reviewed-on: https://gitea.com/gitea/tea/pulls/1123 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
206 lines
6.3 KiB
Go
206 lines
6.3 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package auth
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dev/tea/internal/settings"
|
|
"github.com/42wim/httpsig"
|
|
"golang.org/x/crypto/ssh"
|
|
"golang.org/x/crypto/ssh/agent"
|
|
)
|
|
|
|
// HTTPSign signs Gitea API requests with an SSH private key or certificate.
|
|
type HTTPSign struct {
|
|
ssh.Signer
|
|
Cert bool
|
|
}
|
|
|
|
// SignWithAlgorithm implements ssh.AlgorithmSigner for RSA keys under httpsig.
|
|
func (h *HTTPSign) SignWithAlgorithm(rand io.Reader, data []byte, algorithm string) (*ssh.Signature, error) {
|
|
if as, ok := h.Signer.(ssh.AlgorithmSigner); ok {
|
|
return as.SignWithAlgorithm(rand, data, algorithm)
|
|
}
|
|
return h.Sign(rand, data)
|
|
}
|
|
|
|
// SignRequest attaches Signature and Digest headers required by Gitea HTTPSign.
|
|
func (h *HTTPSign) SignRequest(r *http.Request, contents []byte) error {
|
|
headersToSign := []string{httpsig.RequestTarget, "(created)", "(expires)"}
|
|
if h.Cert {
|
|
pubkey, err := ssh.ParsePublicKey(h.PublicKey().Marshal())
|
|
if err != nil {
|
|
return errors.New("cannot parse SSH certificate for request signing")
|
|
}
|
|
cert, ok := pubkey.(*ssh.Certificate)
|
|
if !ok {
|
|
return errors.New("no SSH certificate found for request signing")
|
|
}
|
|
r.Header.Set("x-ssh-certificate", base64.RawStdEncoding.EncodeToString(cert.Marshal()))
|
|
headersToSign = append(headersToSign, "x-ssh-certificate")
|
|
}
|
|
if r.Body != nil && r.Body != http.NoBody {
|
|
headersToSign = append(headersToSign, "Digest")
|
|
}
|
|
signer, _, err := httpsig.NewSSHSigner(h, httpsig.DigestSha512, headersToSign, httpsig.Signature, 10)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot create HTTP signature: %w", err)
|
|
}
|
|
keyID := "gitea"
|
|
if !h.Cert {
|
|
keyID = ssh.FingerprintSHA256(h.PublicKey())
|
|
}
|
|
return signer.SignRequest(keyID, r, contents)
|
|
}
|
|
|
|
// SignerForAccount builds an HTTP signer from persisted account SSH metadata. passphrase is
|
|
// called only for an encrypted key file; when it is nil, such a key fails with advice to use ssh-agent.
|
|
func SignerForAccount(account settings.Account, passphrase func() ([]byte, error)) (*HTTPSign, error) {
|
|
keyPath := account.SSHKey
|
|
if account.SSHAgent {
|
|
keyPath = ""
|
|
}
|
|
if account.SSHCertPrincipal != "" || account.AuthMethod == settings.AuthSSH {
|
|
return newHTTPSign(keyPath, true, account.SSHCertPrincipal, passphrase)
|
|
}
|
|
return newHTTPSign(keyPath, false, account.SSHKeyFingerprint, passphrase)
|
|
}
|
|
|
|
// newHTTPSign loads a key file, or selects an ssh-agent key matching the fingerprint or, for
|
|
// certificates, the principal.
|
|
func newHTTPSign(keyPath string, cert bool, match string, passphrase func() ([]byte, error)) (*HTTPSign, error) {
|
|
if keyPath == "" {
|
|
agentClient, release, err := getAgent()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
signer, err := agentSigner(agentClient, cert, match)
|
|
if err != nil {
|
|
release()
|
|
return nil, err
|
|
}
|
|
// The agent connection stays open for the signer's lifetime: every request is signed by the agent.
|
|
return signer, nil
|
|
}
|
|
signer, err := parsePrivateKey(keyPath, passphrase)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cert {
|
|
certbytes, err := os.ReadFile(keyPath + "-cert.pub")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot read SSH certificate: %w", err)
|
|
}
|
|
pub, _, _, _, err := ssh.ParseAuthorizedKey(certbytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot parse SSH certificate: %w", err)
|
|
}
|
|
certificate, ok := pub.(*ssh.Certificate)
|
|
if !ok {
|
|
return nil, errors.New("SSH certificate file does not contain a certificate")
|
|
}
|
|
signer, err = ssh.NewCertSigner(certificate, signer)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot use SSH certificate: %w", err)
|
|
}
|
|
}
|
|
return &HTTPSign{Signer: signer, Cert: cert}, nil
|
|
}
|
|
|
|
// agentSigner selects the agent key matching the fingerprint or, for certificates, the principal.
|
|
func agentSigner(agentClient agent.Agent, cert bool, match string) (*HTTPSign, error) {
|
|
signers, err := agentClient.Signers()
|
|
if err != nil {
|
|
return nil, errors.New("cannot list keys from ssh-agent")
|
|
}
|
|
if len(signers) == 0 {
|
|
return nil, errors.New("no signers found in ssh-agent")
|
|
}
|
|
if cert {
|
|
if signer := findCertSigner(signers, match); signer != nil {
|
|
return &HTTPSign{Signer: signer, Cert: true}, nil
|
|
}
|
|
return nil, fmt.Errorf("no certificate found in ssh-agent for principal %q", match)
|
|
}
|
|
if signer := findPubkeySigner(signers, match); signer != nil {
|
|
return &HTTPSign{Signer: signer}, nil
|
|
}
|
|
return nil, fmt.Errorf("no public key found in ssh-agent for %q", match)
|
|
}
|
|
|
|
func parsePrivateKey(keyPath string, passphrase func() ([]byte, error)) (ssh.Signer, error) {
|
|
priv, err := os.ReadFile(keyPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot read SSH key: %w", err)
|
|
}
|
|
signer, err := ssh.ParsePrivateKey(priv)
|
|
if _, ok := errors.AsType[*ssh.PassphraseMissingError](err); ok {
|
|
if passphrase == nil {
|
|
return nil, fmt.Errorf("SSH key %s is passphrase protected; add it to ssh-agent with ssh-add and log in with --ssh-agent-key or --ssh-agent-principal", keyPath)
|
|
}
|
|
secret, promptErr := passphrase()
|
|
if promptErr != nil {
|
|
return nil, promptErr
|
|
}
|
|
signer, err = ssh.ParsePrivateKeyWithPassphrase(priv, secret)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot parse SSH key: %w", err)
|
|
}
|
|
return signer, nil
|
|
}
|
|
|
|
func findCertSigner(sshsigners []ssh.Signer, principal string) ssh.Signer {
|
|
for _, s := range sshsigners {
|
|
if !strings.Contains(s.PublicKey().Type(), "cert-v01@openssh.com") {
|
|
continue
|
|
}
|
|
mpubkey, err := ssh.ParsePublicKey(s.PublicKey().Marshal())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
cert, ok := mpubkey.(*ssh.Certificate)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if cert.ValidBefore != ssh.CertTimeInfinity && time.Until(time.Unix(int64(cert.ValidBefore), 0)) <= 10*time.Second {
|
|
continue
|
|
}
|
|
if principal == "" {
|
|
return s
|
|
}
|
|
if slices.Contains(cert.ValidPrincipals, principal) {
|
|
return s
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func findPubkeySigner(sshsigners []ssh.Signer, fingerprint string) ssh.Signer {
|
|
for _, s := range sshsigners {
|
|
if strings.Contains(s.PublicKey().Type(), "cert-v01@openssh.com") {
|
|
continue
|
|
}
|
|
if fingerprint == "" {
|
|
return s
|
|
}
|
|
if strings.TrimSpace(string(ssh.MarshalAuthorizedKey(s.PublicKey()))) == fingerprint {
|
|
return s
|
|
}
|
|
if ssh.FingerprintSHA256(s.PublicKey()) == fingerprint {
|
|
return s
|
|
}
|
|
}
|
|
return nil
|
|
}
|