Gitfed
bastien-mrq/gitfed / internal / ssh / server.go
// Package ssh implements the gitfed SSH server: certificate/raw-key
// authentication, ACL enforcement, and wrapping of git-receive-pack /
// git-upload-pack, per DESIGN.md §5, §6, §7.
package ssh

import (
	"bytes"
	"crypto/ed25519"
	"crypto/rand"
	"encoding/pem"
	"fmt"
	"log"
	"net"
	"os"
	"time"

	gossh "golang.org/x/crypto/ssh"

	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/ca"
	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/config"
	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/federation"
	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/store"
)

type Server struct {
	cfg      config.Config
	store    *store.Store
	ca       *ca.CA
	resolver *federation.Resolver
	hostKey  gossh.Signer

	sshConfig *gossh.ServerConfig
}

func New(cfg config.Config, st *store.Store, localCA *ca.CA, resolver *federation.Resolver) (*Server, error) {
	hostKey, err := loadOrCreateHostKey(cfg.HostKeyPath())
	if err != nil {
		return nil, err
	}

	s := &Server{cfg: cfg, store: st, ca: localCA, resolver: resolver, hostKey: hostKey}

	sc := &gossh.ServerConfig{PublicKeyCallback: s.publicKeyCallback}
	sc.AddHostKey(hostKey)
	s.sshConfig = sc
	return s, nil
}

func loadOrCreateHostKey(path string) (gossh.Signer, error) {
	if data, err := os.ReadFile(path); err == nil {
		return gossh.ParsePrivateKey(data)
	} else if !os.IsNotExist(err) {
		return nil, fmt.Errorf("ssh: read host key %s: %w", path, err)
	}

	pub, priv, err := ed25519.GenerateKey(rand.Reader)
	_ = pub
	if err != nil {
		return nil, fmt.Errorf("ssh: generate host key: %w", err)
	}
	block, err := gossh.MarshalPrivateKey(priv, "gitfed host key")
	if err != nil {
		return nil, err
	}
	if err := os.WriteFile(path, pem.EncodeToMemory(block), 0600); err != nil {
		return nil, fmt.Errorf("ssh: write host key %s: %w", path, err)
	}
	return gossh.ParsePrivateKey(pem.EncodeToMemory(block))
}

// ListenAndServe accepts SSH connections until the listener errors.
func (s *Server) ListenAndServe() error {
	l, err := net.Listen("tcp", s.cfg.ListenSSH)
	if err != nil {
		return fmt.Errorf("ssh: listen %s: %w", s.cfg.ListenSSH, err)
	}
	log.Printf("gitfed ssh listening on %s", s.cfg.ListenSSH)
	for {
		conn, err := l.Accept()
		if err != nil {
			return err
		}
		go s.handleConn(conn)
	}
}

func (s *Server) handleConn(nConn net.Conn) {
	sshConn, chans, reqs, err := gossh.NewServerConn(nConn, s.sshConfig)
	if err != nil {
		log.Printf("ssh: handshake failed from %s: %v", nConn.RemoteAddr(), err)
		return
	}
	defer sshConn.Close()
	go gossh.DiscardRequests(reqs)

	for newChannel := range chans {
		if newChannel.ChannelType() != "session" {
			_ = newChannel.Reject(gossh.UnknownChannelType, "only session channels supported")
			continue
		}
		channel, requests, err := newChannel.Accept()
		if err != nil {
			log.Printf("ssh: accept channel: %v", err)
			continue
		}
		id := identityFromPermissions(sshConn.Permissions)
		go s.handleSession(id, channel, requests)
	}
}

func identityFromPermissions(p *gossh.Permissions) Identity {
	return Identity{
		Principal: p.Extensions[extPrincipal],
		Username:  p.Extensions[extUsername],
		Domain:    p.Extensions[extDomain],
		Local:     p.Extensions[extLocal] == "1",
		PubKey:    p.Extensions[extPubKey],
	}
}

// publicKeyCallback authenticates either an OpenSSH certificate (local or
// federated principal, §5.1/§5.2) or a raw key registered to a local user.
func (s *Server) publicKeyCallback(conn gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) {
	if cert, ok := key.(*gossh.Certificate); ok {
		return s.checkCert(cert)
	}
	return s.checkRawKey(key)
}

func (s *Server) checkCert(cert *gossh.Certificate) (*gossh.Permissions, error) {
	fail := func(principal, domain string, err error) (*gossh.Permissions, error) {
		s.audit("ssh-auth-cert", principal, domain, err.Error(), false)
		return nil, err
	}

	if cert.CertType != gossh.UserCert {
		return fail("", "", fmt.Errorf("gitfed: not a user certificate"))
	}
	if len(cert.ValidPrincipals) != 1 {
		return fail("", "", fmt.Errorf("gitfed: certificate must have exactly one principal"))
	}
	principal := cert.ValidPrincipals[0]
	username, domain, ok := splitPrincipal(principal)
	if !ok {
		return fail(principal, "", fmt.Errorf("gitfed: invalid principal %q", principal))
	}

	var authority gossh.PublicKey
	if domain == s.cfg.Domain {
		authority = s.ca.PublicKey()
	} else {
		trusted, err := s.store.GetTrustedCA(domain)
		if err != nil || trusted.Status != store.TrustTrusted {
			return fail(principal, domain, fmt.Errorf("gitfed: domain %q is not a trusted CA", domain))
		}
		parsed, _, _, _, err := gossh.ParseAuthorizedKey([]byte(trusted.CAPublicKey))
		if err != nil {
			return fail(principal, domain, fmt.Errorf("gitfed: corrupt trust store entry for %q: %w", domain, err))
		}
		authority = parsed
	}

	if !bytes.Equal(authority.Marshal(), cert.SignatureKey.Marshal()) {
		return fail(principal, domain, fmt.Errorf("gitfed: certificate not signed by the authority for %q", domain))
	}

	checker := &gossh.CertChecker{
		Clock:     time.Now,
		IsRevoked: s.isCertRevoked,
	}
	if err := checker.CheckCert(principal, cert); err != nil {
		return fail(principal, domain, fmt.Errorf("gitfed: invalid certificate: %w", err))
	}

	s.audit("ssh-auth-cert", principal, domain, "", true)
	return &gossh.Permissions{
		Extensions: map[string]string{
			extPrincipal: principal,
			extUsername:  username,
			extDomain:    domain,
			extLocal:     boolStr(domain == s.cfg.Domain),
		},
	}, nil
}

// isCertRevoked is wired into CertChecker: it rejects a certificate whose
// principal or certified key has been revoked (user deleted, key removed).
// A store error is treated as revoked — failing closed is the safe default
// for an auth decision.
func (s *Server) isCertRevoked(cert *gossh.Certificate) bool {
	if len(cert.ValidPrincipals) == 1 {
		if revoked, err := s.store.IsPrincipalRevoked(cert.ValidPrincipals[0]); err != nil || revoked {
			return true
		}
	}
	if revoked, err := s.store.IsKeyRevoked(gossh.FingerprintSHA256(cert.Key)); err != nil || revoked {
		return true
	}
	return false
}

func (s *Server) checkRawKey(key gossh.PublicKey) (*gossh.Permissions, error) {
	authorized := string(bytes.TrimSpace(gossh.MarshalAuthorizedKey(key)))
	user, err := s.store.FindUserByKey(authorized)
	if err != nil {
		s.audit("ssh-auth-key", "", s.cfg.Domain, "unrecognized key", false)
		return nil, fmt.Errorf("gitfed: unrecognized key")
	}
	principal := fmt.Sprintf("%s@%s", user.Username, s.cfg.Domain)
	s.audit("ssh-auth-key", principal, s.cfg.Domain, "", true)
	return &gossh.Permissions{
		Extensions: map[string]string{
			extPrincipal: principal,
			extUsername:  user.Username,
			extDomain:    s.cfg.Domain,
			extLocal:     "1",
			extPubKey:    authorized,
		},
	}, nil
}

func (s *Server) audit(action, principal, domain, detail string, allowed bool) {
	_ = s.store.AppendAudit(store.AuditEvent{
		Action:    action,
		Principal: principal,
		Domain:    domain,
		Allowed:   allowed,
		Detail:    detail,
	})
}

func (s *Server) auditRepo(action, principal, repo, detail string, allowed bool) {
	_ = s.store.AppendAudit(store.AuditEvent{
		Action:    action,
		Principal: principal,
		Repo:      repo,
		Allowed:   allowed,
		Detail:    detail,
	})
}

func boolStr(b bool) string {
	if b {
		return "1"
	}
	return "0"
}