a73x

internal/cli/mint.go

Ref:   Size: 5.4 KiB   History

package cli

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

	"github.com/a73x/eitri/internal/guest"
	"golang.org/x/crypto/ssh"
	"golang.org/x/term"
)

// EnsureKeypair generates an ed25519 keypair at keyPath (+ .pub) if absent.
// An existing key is never touched, including one that appears between the
// stat and the create — newEd25519Key's O_EXCL makes that race a no-op rather
// than an overwrite.
func EnsureKeypair(keyPath string) error {
	if _, err := os.Stat(keyPath); err == nil {
		return nil
	} else if !os.IsNotExist(err) {
		return err
	}
	_, err := newEd25519Key(keyPath)
	if errors.Is(err, os.ErrExist) {
		return nil // another eitri run won the race; its pair is authoritative
	}
	if err != nil {
		return err
	}
	fmt.Fprintf(os.Stderr, "eitri: generated SSH key at %s\n", keyPath)
	return nil
}

// newEd25519Key writes a fresh ed25519 keypair at path (0600) and path+".pub"
// (0644), returning the public half. The private key is created O_EXCL — an
// existing file is never overwritten and a concurrent run cannot interleave two
// generations, so a caller that sees os.ErrExist knows a key it did not make is
// already there. Both the user key and, via `eitri init`, the tenant's CA are
// born here.
func newEd25519Key(path string) (ssh.PublicKey, error) {
	pub, priv, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		return nil, err
	}
	block, err := ssh.MarshalPrivateKey(priv, "")
	if err != nil {
		return nil, err
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
		return nil, err
	}
	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
	if err != nil {
		return nil, err
	}
	if _, err := f.Write(pem.EncodeToMemory(block)); err != nil {
		f.Close()
		return nil, err
	}
	if err := f.Close(); err != nil {
		return nil, err
	}
	sshPub, err := ssh.NewPublicKey(pub)
	if err != nil {
		return nil, err
	}
	// The .pub is a convenience copy — minting and ssh derive the public key
	// from the private one — so it is written after whichever call won the
	// create.
	return sshPub, os.WriteFile(path+".pub", ssh.MarshalAuthorizedKey(sshPub), 0o644)
}

// MintCert self-signs a short-lived user certificate for keyPath's public key
// with the tenant user CA at caPath, writing <keyPath>-cert.pub (which
// OpenSSH auto-offers). Mirrors internal/gateclient's mint, but for the
// user's persistent key and on-disk cert rather than an in-memory ephemeral.
// The five extensions are ssh-keygen's signing defaults — pty allocation
// breaks without them.
func MintCert(caPath, keyPath, keyID string) error {
	raw, err := os.ReadFile(caPath)
	if err != nil {
		if os.IsNotExist(err) {
			return fmt.Errorf("no user CA at %s — run 'eitri init', which offers to make one and register it", caPath)
		}
		return err
	}
	signer, err := ssh.ParsePrivateKey(raw)
	if err != nil {
		var pmerr *ssh.PassphraseMissingError
		if !errors.As(err, &pmerr) {
			return fmt.Errorf("parse user CA %s: %w", caPath, err)
		}
		if !term.IsTerminal(int(os.Stdin.Fd())) {
			return fmt.Errorf("user CA %s is passphrase-protected; run interactively to enter it", caPath)
		}
		fmt.Fprintf(os.Stderr, "passphrase for %s: ", caPath)
		pw, perr := term.ReadPassword(int(os.Stdin.Fd()))
		fmt.Fprintln(os.Stderr)
		if perr != nil {
			return perr
		}
		signer, err = ssh.ParsePrivateKeyWithPassphrase(raw, pw)
		if err != nil {
			return fmt.Errorf("parse user CA %s: %w", caPath, err)
		}
	}

	pub, err := userPublicKey(keyPath)
	if err != nil {
		return err
	}

	var serial uint64
	if err := binary.Read(rand.Reader, binary.BigEndian, &serial); err != nil {
		return err
	}
	now := time.Now()
	cert := &ssh.Certificate{
		Key:             pub,
		Serial:          serial,
		CertType:        ssh.UserCert,
		KeyId:           keyID,
		ValidPrincipals: []string{guest.LoginUser},
		ValidAfter:      uint64(now.Add(-time.Minute).Unix()),
		ValidBefore:     uint64(now.Add(30 * time.Minute).Unix()),
		Permissions: ssh.Permissions{Extensions: map[string]string{
			"permit-X11-forwarding":   "",
			"permit-agent-forwarding": "",
			"permit-port-forwarding":  "",
			"permit-pty":              "",
			"permit-user-rc":          "",
		}},
	}
	if err := cert.SignCert(rand.Reader, signer); err != nil {
		return err
	}
	return os.WriteFile(keyPath+"-cert.pub", ssh.MarshalAuthorizedKey(cert), 0o644)
}

// userPublicKey derives the user's public key from the private key at
// keyPath, falling back to <keyPath>.pub when the private key is
// passphrase-encrypted (ssh prompts for the passphrase itself at connect
// time; minting only needs the public half).
func userPublicKey(keyPath string) (ssh.PublicKey, error) {
	keyRaw, err := os.ReadFile(keyPath)
	if err != nil {
		return nil, err
	}
	signer, err := ssh.ParsePrivateKey(keyRaw)
	if err == nil {
		return signer.PublicKey(), nil
	}
	var pmerr *ssh.PassphraseMissingError
	if !errors.As(err, &pmerr) {
		return nil, fmt.Errorf("parse %s: %w", keyPath, err)
	}
	if pmerr.PublicKey != nil { // openssh format embeds the public key unencrypted
		return pmerr.PublicKey, nil
	}
	pubRaw, err := os.ReadFile(keyPath + ".pub")
	if err != nil {
		return nil, fmt.Errorf("%s is passphrase-encrypted and %s.pub is unreadable: %w", keyPath, keyPath, err)
	}
	pub, _, _, _, perr := ssh.ParseAuthorizedKey(pubRaw)
	if perr != nil {
		return nil, fmt.Errorf("parse %s.pub: %w", keyPath, perr)
	}
	return pub, nil
}