diff --git a/federation/keyserver.go b/federation/keyserver.go new file mode 100644 index 00000000..3e74bfdf --- /dev/null +++ b/federation/keyserver.go @@ -0,0 +1,203 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gorilla/mux" + "go.mau.fi/util/jsontime" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +type ServerVersion struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// ServerKeyProvider is an interface that returns private server keys for server key requests. +type ServerKeyProvider interface { + Get(r *http.Request) (serverName string, key *SigningKey) +} + +// StaticServerKey is an implementation of [ServerKeyProvider] that always returns the same server name and key. +type StaticServerKey struct { + ServerName string + Key *SigningKey +} + +func (ssk *StaticServerKey) Get(r *http.Request) (serverName string, key *SigningKey) { + return ssk.ServerName, ssk.Key +} + +// KeyServer implements a basic Matrix key server that can serve its own keys, plus the federation version endpoint. +// +// It does not implement querying keys of other servers, nor any other federation endpoints. +type KeyServer struct { + KeyProvider ServerKeyProvider + Version ServerVersion + WellKnownTarget string +} + +// Register registers the key server endpoints to the given router. +func (ks *KeyServer) Register(r *mux.Router) { + r.HandleFunc("/.well-known/matrix/server", ks.GetWellKnown).Methods(http.MethodGet) + r.HandleFunc("/_matrix/federation/v1/version", ks.GetServerVersion).Methods(http.MethodGet) + keyRouter := r.PathPrefix("/_matrix/key").Subrouter() + keyRouter.HandleFunc("/v2/server", ks.GetServerKey).Methods(http.MethodGet) + keyRouter.HandleFunc("/v2/query/{serverName}", ks.GetQueryKeys).Methods(http.MethodGet) + keyRouter.HandleFunc("/v2/query", ks.PostQueryKeys).Methods(http.MethodPost) + keyRouter.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ + ErrCode: mautrix.MUnrecognized.ErrCode, + Err: "Unrecognized endpoint", + }) + }) + keyRouter.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, http.StatusMethodNotAllowed, &mautrix.RespError{ + ErrCode: mautrix.MUnrecognized.ErrCode, + Err: "Invalid method for endpoint", + }) + }) +} + +func jsonResponse(w http.ResponseWriter, code int, data any) { + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(data) +} + +// RespWellKnown is the response body for the `GET /.well-known/matrix/server` endpoint. +type RespWellKnown struct { + Server string `json:"m.server"` +} + +// GetWellKnown implements the `GET /.well-known/matrix/server` endpoint +// +// https://spec.matrix.org/v1.9/server-server-api/#get_well-knownmatrixserver +func (ks *KeyServer) GetWellKnown(w http.ResponseWriter, r *http.Request) { + if ks.WellKnownTarget == "" { + jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ + ErrCode: mautrix.MNotFound.ErrCode, + Err: "No well-known target set", + }) + } else { + jsonResponse(w, http.StatusOK, &RespWellKnown{Server: ks.WellKnownTarget}) + } +} + +// RespServerVersion is the response body for the `GET /_matrix/federation/v1/version` endpoint +type RespServerVersion struct { + Server ServerVersion `json:"server"` +} + +// GetServerVersion implements the `GET /_matrix/federation/v1/version` endpoint +// +// https://spec.matrix.org/v1.9/server-server-api/#get_matrixfederationv1version +func (ks *KeyServer) GetServerVersion(w http.ResponseWriter, r *http.Request) { + jsonResponse(w, http.StatusOK, &RespServerVersion{Server: ks.Version}) +} + +// GetServerKey implements the `GET /_matrix/key/v2/server` endpoint. +// +// https://spec.matrix.org/v1.9/server-server-api/#get_matrixkeyv2server +func (ks *KeyServer) GetServerKey(w http.ResponseWriter, r *http.Request) { + domain, key := ks.KeyProvider.Get(r) + if key == nil { + jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ + ErrCode: mautrix.MNotFound.ErrCode, + Err: fmt.Sprintf("No signing key found for %q", r.Host), + }) + } else { + jsonResponse(w, http.StatusOK, key.GenerateKeyResponse(domain, nil)) + } +} + +// ReqQueryKeys is the request body for the `POST /_matrix/key/v2/query` endpoint +type ReqQueryKeys struct { + ServerKeys map[string]map[id.KeyID]QueryKeysCriteria `json:"server_keys"` +} + +type QueryKeysCriteria struct { + MinimumValidUntilTS jsontime.UnixMilli `json:"minimum_valid_until_ts"` +} + +// PostQueryKeysResponse is the response body for the `POST /_matrix/key/v2/query` endpoint +type PostQueryKeysResponse struct { + ServerKeys map[string]*ServerKeyResponse `json:"server_keys"` +} + +// PostQueryKeys implements the `POST /_matrix/key/v2/query` endpoint +// +// https://spec.matrix.org/v1.9/server-server-api/#post_matrixkeyv2query +func (ks *KeyServer) PostQueryKeys(w http.ResponseWriter, r *http.Request) { + var req ReqQueryKeys + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ + ErrCode: mautrix.MBadJSON.ErrCode, + Err: fmt.Sprintf("failed to parse request: %v", err), + }) + return + } + + resp := &PostQueryKeysResponse{ + ServerKeys: make(map[string]*ServerKeyResponse), + } + for serverName, keys := range req.ServerKeys { + domain, key := ks.KeyProvider.Get(r) + if domain != serverName { + continue + } + for keyID, criteria := range keys { + if key.ID == keyID && criteria.MinimumValidUntilTS.Before(time.Now().Add(24*time.Hour)) { + resp.ServerKeys[serverName] = key.GenerateKeyResponse(serverName, nil) + } + } + } + jsonResponse(w, http.StatusOK, resp) +} + +// GetQueryKeysResponse is the response body for the `GET /_matrix/key/v2/query/{serverName}` endpoint +type GetQueryKeysResponse struct { + ServerKeys []*ServerKeyResponse `json:"server_keys"` +} + +// GetQueryKeys implements the `GET /_matrix/key/v2/query/{serverName}` endpoint +// +// https://spec.matrix.org/v1.9/server-server-api/#get_matrixkeyv2queryservername +func (ks *KeyServer) GetQueryKeys(w http.ResponseWriter, r *http.Request) { + serverName := mux.Vars(r)["serverName"] + minimumValidUntilTSString := r.URL.Query().Get("minimum_valid_until_ts") + minimumValidUntilTS, err := strconv.ParseInt(minimumValidUntilTSString, 10, 64) + if err != nil && minimumValidUntilTSString != "" { + jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ + ErrCode: mautrix.MInvalidParam.ErrCode, + Err: fmt.Sprintf("failed to parse ?minimum_valid_until_ts: %v", err), + }) + return + } else if time.UnixMilli(minimumValidUntilTS).After(time.Now().Add(24 * time.Hour)) { + jsonResponse(w, http.StatusBadRequest, &mautrix.RespError{ + ErrCode: mautrix.MInvalidParam.ErrCode, + Err: "minimum_valid_until_ts may not be more than 24 hours in the future", + }) + return + } + resp := &GetQueryKeysResponse{ + ServerKeys: []*ServerKeyResponse{}, + } + if domain, key := ks.KeyProvider.Get(r); key != nil && domain == serverName { + resp.ServerKeys = append(resp.ServerKeys, key.GenerateKeyResponse(serverName, nil)) + } + jsonResponse(w, http.StatusOK, resp) +} diff --git a/federation/signingkey.go b/federation/signingkey.go new file mode 100644 index 00000000..3d118233 --- /dev/null +++ b/federation/signingkey.go @@ -0,0 +1,123 @@ +// Copyright (c) 2024 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package federation + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" + + "go.mau.fi/util/jsontime" + + "maunium.net/go/mautrix/crypto/canonicaljson" + "maunium.net/go/mautrix/id" +) + +// SigningKey is a Matrix federation signing key pair. +type SigningKey struct { + ID id.KeyID + Pub id.SigningKey + Priv ed25519.PrivateKey +} + +// SynapseString returns a string representation of the private key compatible with Synapse's .signing.key file format. +// +// The output of this function can be parsed back into a [SigningKey] using the [ParseSynapseKey] function. +func (sk *SigningKey) SynapseString() string { + alg, id := sk.ID.Parse() + return fmt.Sprintf("%s %s %s", alg, id, base64.RawStdEncoding.EncodeToString(sk.Priv.Seed())) +} + +// ParseSynapseKey parses a Synapse-compatible private key string into a SigningKey. +func ParseSynapseKey(key string) (*SigningKey, error) { + parts := strings.Split(key, " ") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid key format (expected 3 space-separated parts, got %d)", len(parts)) + } else if parts[0] != string(id.KeyAlgorithmEd25519) { + return nil, fmt.Errorf("unsupported key algorithm %s (only ed25519 is supported)", parts[0]) + } + seed, err := base64.RawStdEncoding.DecodeString(parts[2]) + if err != nil { + return nil, fmt.Errorf("invalid private key: %w", err) + } + priv := ed25519.NewKeyFromSeed(seed) + pub := base64.RawStdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)) + return &SigningKey{ + ID: id.NewKeyID(id.KeyAlgorithmEd25519, parts[1]), + Pub: id.SigningKey(pub), + Priv: priv, + }, nil +} + +// GenerateSigningKey generates a new random signing key. +func GenerateSigningKey() *SigningKey { + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + panic(err) + } + return &SigningKey{ + ID: id.NewKeyID(id.KeyAlgorithmEd25519, base64.RawURLEncoding.EncodeToString(pub[:4])), + Pub: id.SigningKey(base64.RawStdEncoding.EncodeToString(pub)), + Priv: priv, + } +} + +// ServerKeyResponse is the response body for the `GET /_matrix/key/v2/server` endpoint. +// It's also used inside the query endpoint response structs. +type ServerKeyResponse struct { + ServerName string `json:"server_name"` + VerifyKeys map[id.KeyID]ServerVerifyKey `json:"verify_keys"` + OldVerifyKeys map[id.KeyID]OldVerifyKey `json:"old_verify_keys,omitempty"` + Signatures map[string]map[id.KeyID]string `json:"signatures,omitempty"` + ValidUntilTS jsontime.UnixMilli `json:"valid_until_ts"` +} + +type ServerVerifyKey struct { + Key id.SigningKey `json:"key"` +} + +type OldVerifyKey struct { + Key id.SigningKey `json:"key"` + ExpiredTS jsontime.UnixMilli `json:"expired_ts"` +} + +func (sk *SigningKey) SignJSON(data any) ([]byte, error) { + marshaled, err := json.Marshal(data) + if err != nil { + return nil, err + } + return sk.SignRawJSON(marshaled), nil +} + +func (sk *SigningKey) SignRawJSON(data json.RawMessage) []byte { + return ed25519.Sign(sk.Priv, canonicaljson.CanonicalJSONAssumeValid(data)) +} + +// GenerateKeyResponse generates a key response signed by this key with the given server name and optionally some old verify keys. +func (sk *SigningKey) GenerateKeyResponse(serverName string, oldVerifyKeys map[id.KeyID]OldVerifyKey) *ServerKeyResponse { + skr := &ServerKeyResponse{ + ServerName: serverName, + OldVerifyKeys: oldVerifyKeys, + ValidUntilTS: jsontime.UM(time.Now().Add(24 * time.Hour)), + VerifyKeys: map[id.KeyID]ServerVerifyKey{ + sk.ID: {Key: sk.Pub}, + }, + } + signature, err := sk.SignJSON(skr) + if err != nil { + panic(err) + } + skr.Signatures = map[string]map[id.KeyID]string{ + serverName: { + sk.ID: base64.RawURLEncoding.EncodeToString(signature), + }, + } + return skr +}