Gitfed
bastien-mrq/gitfed / internal / ca / ca.go
// Package ca generates the instance's local certificate authority keypair
// and issues short-TTL OpenSSH user certificates, per DESIGN.md §5.1.
package ca

import (
	"crypto/ed25519"
	"crypto/rand"
	"encoding/pem"
	"fmt"
	"os"
	"path/filepath"
	"time"

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

const (
	privateKeyFile = "ca_key"
	publicKeyFile  = "ca_key.pub"
)

// CA holds the instance's signing keypair.
type CA struct {
	signer ssh.Signer
	pub    ssh.PublicKey
}

// LoadOrCreate loads the CA keypair from dir, generating a new ed25519
// keypair on first run.
func LoadOrCreate(dir string) (*CA, error) {
	privPath := filepath.Join(dir, privateKeyFile)
	pubPath := filepath.Join(dir, publicKeyFile)

	if _, err := os.Stat(privPath); err == nil {
		return load(privPath)
	} else if !os.IsNotExist(err) {
		return nil, fmt.Errorf("ca: stat %s: %w", privPath, err)
	}

	if err := os.MkdirAll(dir, 0700); err != nil {
		return nil, fmt.Errorf("ca: mkdir %s: %w", dir, err)
	}

	pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		return nil, fmt.Errorf("ca: generate key: %w", err)
	}

	block, err := ssh.MarshalPrivateKey(privKey, "gitfed CA")
	if err != nil {
		return nil, fmt.Errorf("ca: marshal private key: %w", err)
	}
	if err := os.WriteFile(privPath, pem.EncodeToMemory(block), 0600); err != nil {
		return nil, fmt.Errorf("ca: write %s: %w", privPath, err)
	}

	sshPub, err := ssh.NewPublicKey(pubKey)
	if err != nil {
		return nil, fmt.Errorf("ca: derive public key: %w", err)
	}
	if err := os.WriteFile(pubPath, ssh.MarshalAuthorizedKey(sshPub), 0644); err != nil {
		return nil, fmt.Errorf("ca: write %s: %w", pubPath, err)
	}

	return load(privPath)
}

func load(privPath string) (*CA, error) {
	data, err := os.ReadFile(privPath)
	if err != nil {
		return nil, fmt.Errorf("ca: read %s: %w", privPath, err)
	}
	signer, err := ssh.ParsePrivateKey(data)
	if err != nil {
		return nil, fmt.Errorf("ca: parse private key: %w", err)
	}
	return &CA{signer: signer, pub: signer.PublicKey()}, nil
}

// PublicKeyAuthorized returns the CA's public key in authorized_keys format,
// e.g. for publishing in /.well-known/gitfed.json.
func (c *CA) PublicKeyAuthorized() string {
	return string(ssh.MarshalAuthorizedKey(c.pub))
}

// PublicKey returns the CA's ssh.PublicKey.
func (c *CA) PublicKey() ssh.PublicKey {
	return c.pub
}

// SignBytes signs arbitrary data with the CA's private key. Used outside
// certificate issuance for exactly one thing: proving authorship of
// outbound federated notifications (see internal/federation) — a receiving
// instance verifies it against the sender's CA public key, the same one
// already published for certificate trust.
func (c *CA) SignBytes(data []byte) (*ssh.Signature, error) {
	return c.signer.Sign(rand.Reader, data)
}

// IssueParams describes a certificate to be issued for a local user.
type IssueParams struct {
	Username string
	Domain   string
	UserKey  ssh.PublicKey // the user's own SSH public key being certified
	TTL      time.Duration
}

// IssueUserCert signs a short-lived OpenSSH user certificate for the given
// user key, with principal "<username>@<domain>" per DESIGN.md §5.1.
func (c *CA) IssueUserCert(p IssueParams) (*ssh.Certificate, error) {
	if p.TTL <= 0 {
		p.TTL = 48 * time.Hour
	}
	now := time.Now()
	principal := fmt.Sprintf("%s@%s", p.Username, p.Domain)

	keyID, err := randomKeyID()
	if err != nil {
		return nil, err
	}

	cert := &ssh.Certificate{
		Key:             p.UserKey,
		Serial:          0,
		CertType:        ssh.UserCert,
		KeyId:           keyID,
		ValidPrincipals: []string{principal},
		ValidAfter:      uint64(now.Add(-1 * time.Minute).Unix()),
		ValidBefore:     uint64(now.Add(p.TTL).Unix()),
		Permissions: ssh.Permissions{
			Extensions: map[string]string{
				"permit-pty": "",
			},
		},
	}
	if err := cert.SignCert(rand.Reader, c.signer); err != nil {
		return nil, fmt.Errorf("ca: sign certificate: %w", err)
	}
	return cert, nil
}

func randomKeyID() (string, error) {
	buf := make([]byte, 16)
	if _, err := rand.Read(buf); err != nil {
		return "", err
	}
	return fmt.Sprintf("gitfed-%x", buf), nil
}