Files
Tea-Cli/internal/auth/httpsign.go
T
bircni 88b91faa54 refactor: add credential and Git packages (#1123)
- `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>
2026-10-11 17:09:37 +00:00

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
}