Files
Warp/server/internal/matrix/signing/key.go
T
tajniak81andClaude Opus 5 b4fb60a8ab
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Phase 0: bootstrap the Warp homeserver
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>
2026-10-15 17:01:13 +02:00

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)
}