// 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:" 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 // // 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 \"", 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 decodes a base64 value from the Matrix wire format. // // Matrix writes these unpadded, but the spec asks decoders to accept // padding too wherever they can: keys, hashes and signatures get copied // between implementations by hand, and rejecting a trailing "=" would turn // a harmless transcription into a verification failure. func DecodeBase64(s string) ([]byte, error) { return decodeBase64(s) } func decodeBase64(s string) ([]byte, error) { if strings.HasSuffix(s, "=") { return base64.StdEncoding.DecodeString(s) } return base64.RawStdEncoding.DecodeString(s) }