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>
This commit is contained in:
commit
b4fb60a8ab
50 files changed
+5672
No files matched your search
@@ -0,0 +1,182 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package signing
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateSaveLoadRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "keys", "signing.key")
|
||||
|
||||
key, err := Generate("")
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(key.ID, "a_") {
|
||||
t.Errorf("generated key ID %q should look like a_xxxxx", key.ID)
|
||||
}
|
||||
if got, want := key.KeyID(), "ed25519:"+key.ID; got != want {
|
||||
t.Errorf("KeyID() = %q, want %q", got, want)
|
||||
}
|
||||
if err := key.Save(path); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if loaded.ID != key.ID {
|
||||
t.Errorf("key ID = %q, want %q", loaded.ID, key.ID)
|
||||
}
|
||||
if !loaded.Private.Equal(key.Private) {
|
||||
t.Error("loaded private key differs from the generated one")
|
||||
}
|
||||
if loaded.PublicBase64() != key.PublicBase64() {
|
||||
t.Error("loaded public key differs from the generated one")
|
||||
}
|
||||
|
||||
msg := []byte("the quick brown fox")
|
||||
if !ed25519.Verify(loaded.Public, msg, ed25519.Sign(loaded.Private, msg)) {
|
||||
t.Error("loaded key does not sign and verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRefusesToOverwrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "signing.key")
|
||||
key, err := Generate("test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := key.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = key.Save(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected the second Save to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "refusing to overwrite") {
|
||||
t.Errorf("error should explain itself, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveFormat(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "signing.key")
|
||||
key, err := Generate("abc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := key.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) != 3 || fields[0] != "ed25519" || fields[1] != "abc" {
|
||||
t.Fatalf("unexpected key file contents: %q", string(data))
|
||||
}
|
||||
if strings.Contains(fields[2], "=") {
|
||||
t.Error("the seed should be unpadded base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsGarbage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cases := map[string]string{
|
||||
"empty": "",
|
||||
"only a comment": "# nothing here\n",
|
||||
"wrong field count": "ed25519 abc\n",
|
||||
"wrong algorithm": "rsa abc AAAA\n",
|
||||
"bad key id": "ed25519 a:b AAAA\n",
|
||||
"short seed": "ed25519 abc AAAA\n",
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(dir, strings.ReplaceAll(name, " ", "_")+".key")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatalf("expected Load to reject %q", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateKeyID(t *testing.T) {
|
||||
for _, ok := range []string{"a_abc", "ABC123", "x_1"} {
|
||||
if err := ValidateKeyID(ok); err != nil {
|
||||
t.Errorf("ValidateKeyID(%q) = %v, want nil", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "ed25519:abc", "a-b", "a b", "ä"} {
|
||||
if err := ValidateKeyID(bad); err == nil {
|
||||
t.Errorf("ValidateKeyID(%q) = nil, want an error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIgnoresCommentsAndBlankLines(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "signing.key")
|
||||
key, err := Generate("real")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := key.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, append([]byte("# warp signing key\n\n"), body...), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if loaded.ID != "real" {
|
||||
t.Errorf("key ID = %q, want %q", loaded.ID, "real")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user