// 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"
}