// Package admincrypto loads the server's Ed25519 signing key used to // authenticate admin-issued artifacts (currently: event badge designs). // // Key resolution order: // // 1. `ADMIN_SIGNING_KEY` env var — base64-encoded 64-byte Ed25519 seed // (i.e. the private key from `ed25519.GenerateKey`). This is the // production path: rotate by changing the env var. // // 2. A file at `data/admin_signing.key` relative to the working // directory. Auto-generated on first boot in dev mode if the env // var is unset. The file is gitignored. // // We deliberately keep the key in-memory only at runtime — there's no // API for callers to read the raw bytes. Sign() / Verify() are the // public surface. package admincrypto import ( "crypto/ed25519" "crypto/rand" "encoding/base64" "errors" "fmt" "os" "path/filepath" "sync" ) // Signer holds the Ed25519 keypair and exposes Sign/Verify. type Signer struct { priv ed25519.PrivateKey pub ed25519.PublicKey // KeyID is a short label used in stored signatures so we can rotate // keys later by tagging signatures with which key generated them. // Always "v1" for now. KeyID string } // Load resolves an admin signing key from env or disk. If `allowGenerate` // is true and neither source is present, a fresh keypair is generated // and persisted to `data/admin_signing.key`. Production deployments // should set ADMIN_SIGNING_KEY and pass allowGenerate=false. func Load(allowGenerate bool) (*Signer, error) { // 1. env var if raw := os.Getenv("ADMIN_SIGNING_KEY"); raw != "" { seed, err := base64.StdEncoding.DecodeString(raw) if err != nil { return nil, fmt.Errorf("admincrypto: ADMIN_SIGNING_KEY not valid base64: %w", err) } if len(seed) != ed25519.SeedSize { return nil, fmt.Errorf("admincrypto: ADMIN_SIGNING_KEY wrong size; want %d bytes, got %d", ed25519.SeedSize, len(seed)) } priv := ed25519.NewKeyFromSeed(seed) return &Signer{priv: priv, pub: priv.Public().(ed25519.PublicKey), KeyID: "v1"}, nil } // 2. file (dev mode) const path = "data/admin_signing.key" if seed, err := os.ReadFile(path); err == nil { if len(seed) != ed25519.SeedSize { return nil, fmt.Errorf("admincrypto: %s wrong size; want %d bytes, got %d", path, ed25519.SeedSize, len(seed)) } priv := ed25519.NewKeyFromSeed(seed) return &Signer{priv: priv, pub: priv.Public().(ed25519.PublicKey), KeyID: "v1"}, nil } // 3. generate (dev mode opt-in) if !allowGenerate { return nil, errors.New("admincrypto: no ADMIN_SIGNING_KEY env var and no data/admin_signing.key file") } pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { return nil, fmt.Errorf("admincrypto: generate: %w", err) } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, fmt.Errorf("admincrypto: mkdir: %w", err) } if err := os.WriteFile(path, priv.Seed(), 0o600); err != nil { return nil, fmt.Errorf("admincrypto: write key: %w", err) } return &Signer{priv: priv, pub: pub, KeyID: "v1"}, nil } // MustLoad is a convenience wrapper for main() that panics on failure. func MustLoad(allowGenerate bool) *Signer { s, err := Load(allowGenerate) if err != nil { panic(err) } return s } // Sign returns a base64-encoded Ed25519 signature over msg. func (s *Signer) Sign(msg []byte) string { if s == nil { return "" } sig := ed25519.Sign(s.priv, msg) return base64.StdEncoding.EncodeToString(sig) } // Verify returns true if encodedSig (base64) is a valid Ed25519 signature // over msg under this signer's public key. func (s *Signer) Verify(msg []byte, encodedSig string) bool { if s == nil { return false } sig, err := base64.StdEncoding.DecodeString(encodedSig) if err != nil { return false } return ed25519.Verify(s.pub, msg, sig) } // PublicKeyBase64 returns the base64-encoded public key — useful for an // admin debug page that wants to show what the server is verifying with. func (s *Signer) PublicKeyBase64() string { if s == nil { return "" } return base64.StdEncoding.EncodeToString(s.pub) } // guard against accidental concurrent rotation. Unused for now; reserved // for a future RotateKey method. var _ sync.Mutex