Scaffolds the server module and everything Phase 0 of PLAN.md calls for: a binary that boots, serves its discovery endpoints and nothing else yet. - config: one YAML file plus per-field WARP_* environment overrides, unknown fields rejected, validation that reports every problem at once - logging and metrics: slog, a Prometheus registry, per-route HTTP instrumentation labelled by pattern rather than path - httputil: the Matrix error envelope, error-returning handlers, CORS, request logging, panic recovery, and a router over http.ServeMux - endpoints: /_matrix/client/versions, /.well-known/matrix/client, /.well-known/matrix/server (gated), /healthz, /metrics, and M_UNRECOGNIZED for everything else - storage: driver registry, shared database/sql base, checksummed migration runner, sqlite and postgres drivers, pocketbase stub - settings: server_settings table behind a cached service, so the federation and registration switches change without a restart - federation: the gate that makes federation a runtime mode - cmd/warp: serve, migrate, generate-keys, version - Makefile, golangci-lint v2, GitHub Actions CI (Linux and Windows tests, race on Linux, storage tests against PostgreSQL, lint, boot smoke test), Dockerfile, example config and docs Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
183 lines
5.6 KiB
Go
183 lines
5.6 KiB
Go
// Package signing handles the server's ed25519 signing keys: generating
|
|
// them, reading and writing the on-disk format, and naming them.
|
|
//
|
|
// Event signing itself (canonical JSON, hashes, signature blocks) arrives
|
|
// with the event core in Phase 2; this package deliberately stops at key
|
|
// material so that `warp generate-keys` can exist from day one.
|
|
package signing
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// KeyIDPrefix is the only signing algorithm Matrix defines today.
|
|
const KeyIDPrefix = "ed25519"
|
|
|
|
// FileMode is the permission a freshly written key file gets. On Windows it
|
|
// is advisory; the warning in Load covers the Unix case.
|
|
const FileMode os.FileMode = 0o600
|
|
|
|
// KeyPair is a server signing key and the key ID that names it in a
|
|
// signature block ("ed25519:abc123").
|
|
type KeyPair struct {
|
|
// ID is the part after the algorithm, e.g. "a_RVfN".
|
|
ID string
|
|
|
|
Private ed25519.PrivateKey
|
|
Public ed25519.PublicKey
|
|
}
|
|
|
|
// KeyID returns the full "ed25519:<id>" identifier.
|
|
func (k *KeyPair) KeyID() string { return KeyIDPrefix + ":" + k.ID }
|
|
|
|
// PublicBase64 returns the unpadded base64 public key, the encoding Matrix
|
|
// uses on the wire.
|
|
func (k *KeyPair) PublicBase64() string {
|
|
return base64.RawStdEncoding.EncodeToString(k.Public)
|
|
}
|
|
|
|
// Generate creates a new key. An empty id gets a random one.
|
|
func Generate(id string) (*KeyPair, error) {
|
|
if id == "" {
|
|
var err error
|
|
if id, err = RandomKeyID(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := ValidateKeyID(id); err != nil {
|
|
return nil, err
|
|
}
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate ed25519 key: %w", err)
|
|
}
|
|
return &KeyPair{ID: id, Private: priv, Public: pub}, nil
|
|
}
|
|
|
|
// keyIDAlphabet is deliberately narrow: the spec allows [a-zA-Z0-9_] and
|
|
// mixed case key IDs are a nuisance to type.
|
|
const keyIDAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
|
|
// RandomKeyID returns an identifier of the "a_xxxxx" shape Synapse uses, so
|
|
// operators recognise it.
|
|
func RandomKeyID() (string, error) {
|
|
buf := make([]byte, 5)
|
|
if _, err := io.ReadFull(rand.Reader, buf); err != nil {
|
|
return "", fmt.Errorf("read randomness: %w", err)
|
|
}
|
|
out := make([]byte, 0, len(buf)+2)
|
|
out = append(out, 'a', '_')
|
|
for _, b := range buf {
|
|
out = append(out, keyIDAlphabet[int(b)%len(keyIDAlphabet)])
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
// ValidateKeyID checks the identifier against the grammar in the spec.
|
|
func ValidateKeyID(id string) error {
|
|
if id == "" {
|
|
return errors.New("key ID must not be empty")
|
|
}
|
|
if strings.Contains(id, ":") {
|
|
return fmt.Errorf("key ID %q must not contain a colon; pass the part after %q", id, KeyIDPrefix+":")
|
|
}
|
|
for _, r := range id {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_':
|
|
default:
|
|
return fmt.Errorf("key ID %q contains %q; only [a-zA-Z0-9_] are allowed", id, r)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Save writes the key to path in the one-line interchange format shared with
|
|
// Synapse:
|
|
//
|
|
// ed25519 <key-id> <unpadded base64 seed>
|
|
//
|
|
// It refuses to clobber an existing file: a lost signing key means every
|
|
// event this server ever signed stops verifying.
|
|
func (k *KeyPair) Save(path string) error {
|
|
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return fmt.Errorf("create key directory: %w", err)
|
|
}
|
|
}
|
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, FileMode)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrExist) {
|
|
return fmt.Errorf("%s already exists; refusing to overwrite a signing key", path)
|
|
}
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
seed := base64.RawStdEncoding.EncodeToString(k.Private.Seed())
|
|
if _, err := fmt.Fprintf(f, "%s %s %s\n", KeyIDPrefix, k.ID, seed); err != nil {
|
|
return fmt.Errorf("write key: %w", err)
|
|
}
|
|
return f.Close()
|
|
}
|
|
|
|
// Load reads a key file written by Save. Lines starting with '#' and blank
|
|
// lines are ignored; the first key line wins. Older keys (a file with
|
|
// several entries) will matter once key rotation lands in Phase 5.
|
|
func Load(path string) (*KeyPair, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read signing key: %w", err)
|
|
}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
return parseKeyLine(line, path)
|
|
}
|
|
return nil, fmt.Errorf("%s contains no signing key", path)
|
|
}
|
|
|
|
func parseKeyLine(line, path string) (*KeyPair, error) {
|
|
parts := strings.Fields(line)
|
|
if len(parts) != 3 {
|
|
return nil, fmt.Errorf("%s: malformed key line, want \"ed25519 <key-id> <base64-seed>\"", path)
|
|
}
|
|
if parts[0] != KeyIDPrefix {
|
|
return nil, fmt.Errorf("%s: unsupported key algorithm %q", path, parts[0])
|
|
}
|
|
if err := ValidateKeyID(parts[1]); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
seed, err := decodeBase64(parts[2])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: decode seed: %w", path, err)
|
|
}
|
|
if len(seed) != ed25519.SeedSize {
|
|
return nil, fmt.Errorf("%s: seed is %d bytes, want %d", path, len(seed), ed25519.SeedSize)
|
|
}
|
|
priv := ed25519.NewKeyFromSeed(seed)
|
|
pub, ok := priv.Public().(ed25519.PublicKey)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s: unexpected public key type", path)
|
|
}
|
|
return &KeyPair{ID: parts[1], Private: priv, Public: pub}, nil
|
|
}
|
|
|
|
// decodeBase64 accepts both padded and unpadded standard base64, because
|
|
// keys get copied between implementations by hand.
|
|
func decodeBase64(s string) ([]byte, error) {
|
|
if strings.HasSuffix(s, "=") {
|
|
return base64.StdEncoding.DecodeString(s)
|
|
}
|
|
return base64.RawStdEncoding.DecodeString(s)
|
|
}
|