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
}