Implements PLAN.md Phase 7: the server half of end-to-end encryption.
Devices upload identity, one-time and fallback keys; other devices query
and claim them, across servers too, and every one-time key is handed out
exactly once. Cross-signing, signature uploads, device list tracking and
server-side key backup complete the set.
internal/keyserver is the component. Keys are stored in canonical JSON
so that re-uploading the same key is a byte comparison, and signatures
uploaded later are kept apart from the key they sign and merged in on
read, so a key's own JSON stays what its owner uploaded. Device keys must
be for the caller's own device; cross-signing keys must be the
uploader's, carry the right usage and, for self- and user-signing keys,
be signed by the master key. Signatures are verified against the key as
stored. Replacing a master key asks for the password; a first upload or
an upload of the keys already stored does not, as the spec allows. A
signature of another user's master key is shown only to the user who
made it and never federated.
Every write that changes what /keys/query answers moves the device list
stream in the same transaction, one position per device, and wakes the
syncs of everyone sharing a room. The stream is the sixth field of a sync
token; older tokens still parse. The syncapi works out device_lists
changed and left from the stream and from membership moves in the
window, skipping invites and knocks, and reports
device_one_time_keys_count and device_unused_fallback_key_types, the
latter as [] when empty. GET /keys/changes uses the same logic.
Over federation: /user/keys/query, /user/keys/claim and
/user/devices/{userId}, and m.device_list_update and m.signing_key_update
both ways. Remote keys are not cached; an update about a remote user
sharing a room moves them in the stream so local clients re-query.
Device display names are not sent to other servers. With federation off,
remote users are reported in failures at once.
Deleting a device deletes its keys, through a new userapi
DeviceObserver. Key backup keeps the better copy of each session by the
spec's rule, never reuses a deleted version's number, and answers writes
to a stale version with M_WRONG_ROOM_KEYS_VERSION.
Storage is migration 0008 for sqlite and postgres, with a PocketBase
stub. The Complement target sets gain the key, device list, fallback,
cross-signing and backup tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
375 lines
13 KiB
Go
375 lines
13 KiB
Go
package clientapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
|
"github.com/dariuszjaniak/warp/server/internal/keyserver"
|
|
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
|
)
|
|
|
|
// The end-to-end encryption endpoints: device keys, one-time keys,
|
|
// cross-signing, signatures and the server-side key backup. Every one of
|
|
// them is the keyserver's; the handlers decode and shape.
|
|
|
|
// registerKeyRoutes adds the keyserver endpoints, if there is one.
|
|
func (c *ClientAPI) registerKeyRoutes(r *httputil.Router) {
|
|
if c.keys == nil {
|
|
return
|
|
}
|
|
|
|
c.authed(r, "POST "+v3+"/keys/upload", c.limitDefault, c.handleKeysUpload)
|
|
c.authed(r, "POST "+v3+"/keys/query", c.limitDefault, c.handleKeysQuery)
|
|
c.authed(r, "POST "+v3+"/keys/claim", c.limitDefault, c.handleKeysClaim)
|
|
c.authed(r, "POST "+v3+"/keys/device_signing/upload", c.limitDefault, c.handleCrossSigningUpload)
|
|
c.authed(r, "POST "+v3+"/keys/signatures/upload", c.limitDefault, c.handleSignaturesUpload)
|
|
if c.sync != nil {
|
|
c.authed(r, "GET "+v3+"/keys/changes", c.limitDefault, c.handleKeysChanges)
|
|
}
|
|
|
|
c.authed(r, "POST "+v3+"/room_keys/version", c.limitDefault, c.handleCreateBackup)
|
|
c.authed(r, "GET "+v3+"/room_keys/version", c.limitDefault, c.handleGetBackup)
|
|
c.authed(r, "GET "+v3+"/room_keys/version/{version}", c.limitDefault, c.handleGetBackup)
|
|
c.authed(r, "PUT "+v3+"/room_keys/version/{version}", c.limitDefault, c.handlePutBackup)
|
|
c.authed(r, "DELETE "+v3+"/room_keys/version/{version}", c.limitDefault, c.handleDeleteBackup)
|
|
|
|
// The keys themselves, at the three granularities the spec gives:
|
|
// the whole backup, one room, one session.
|
|
for _, path := range []string{
|
|
v3 + "/room_keys/keys",
|
|
v3 + "/room_keys/keys/{roomID}",
|
|
v3 + "/room_keys/keys/{roomID}/{sessionID}",
|
|
} {
|
|
c.authed(r, "PUT "+path, c.limitDefault, c.handlePutRoomKeys)
|
|
c.authed(r, "GET "+path, c.limitDefault, c.handleGetRoomKeys)
|
|
c.authed(r, "DELETE "+path, c.limitDefault, c.handleDeleteRoomKeys)
|
|
}
|
|
}
|
|
|
|
// keyError maps a keyserver error onto the Matrix error a client is sent.
|
|
func keyError(err error) error {
|
|
var (
|
|
matrixErr *httputil.MatrixError
|
|
wrongBackup *keyserver.WrongBackupVersionError
|
|
)
|
|
switch {
|
|
case err == nil:
|
|
return nil
|
|
case errors.As(err, &matrixErr):
|
|
return err
|
|
case errors.As(err, &wrongBackup):
|
|
e := httputil.NewError(http.StatusForbidden, httputil.ErrCodeWrongRoomKeysVersion, "%s", err.Error())
|
|
e.CurrentVersion = wrongBackup.Current
|
|
return e
|
|
case errors.Is(err, keyserver.ErrNoSuchBackup):
|
|
return httputil.NotFound("Unknown backup version")
|
|
case errors.Is(err, keyserver.ErrNoSuchBackupKey):
|
|
return httputil.NotFound("No room keys found")
|
|
case errors.Is(err, keyserver.ErrInvalidSignature):
|
|
return httputil.NewError(http.StatusBadRequest, httputil.ErrCodeInvalidSignature, "%s", err.Error())
|
|
case errors.Is(err, keyserver.ErrMissingParam):
|
|
return httputil.MissingParam("%s", err.Error())
|
|
case errors.Is(err, keyserver.ErrInvalidParam), errors.Is(err, keyserver.ErrKeyConflict):
|
|
return httputil.InvalidParam("%s", err.Error())
|
|
}
|
|
return err
|
|
}
|
|
|
|
// --- device and one-time keys ---------------------------------------------------
|
|
|
|
type keysUploadResponse struct {
|
|
OneTimeKeyCounts map[string]int `json:"one_time_key_counts"`
|
|
}
|
|
|
|
// handleKeysUpload implements POST /_matrix/client/v3/keys/upload.
|
|
func (c *ClientAPI) handleKeysUpload(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
|
var req keyserver.UploadRequest
|
|
if err := httputil.DecodeJSON(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
counts, err := c.keys.UploadKeys(r.Context(), auth.UserID, auth.DeviceID, req)
|
|
if err != nil {
|
|
return keyError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, keysUploadResponse{OneTimeKeyCounts: counts})
|
|
return nil
|
|
}
|
|
|
|
// keysQueryRequest is the body of POST /keys/query.
|
|
type keysQueryRequest struct {
|
|
DeviceKeys map[string][]string `json:"device_keys"`
|
|
|
|
// Timeout is in milliseconds.
|
|
Timeout int64 `json:"timeout"`
|
|
}
|
|
|
|
// handleKeysQuery implements POST /_matrix/client/v3/keys/query.
|
|
func (c *ClientAPI) handleKeysQuery(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
|
var req keysQueryRequest
|
|
if err := httputil.DecodeJSON(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
resp, err := c.keys.QueryKeys(r.Context(), auth.UserID, keyserver.QueryRequest{
|
|
DeviceKeys: req.DeviceKeys,
|
|
Timeout: time.Duration(req.Timeout) * time.Millisecond,
|
|
})
|
|
if err != nil {
|
|
return keyError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
// keysClaimRequest is the body of POST /keys/claim.
|
|
type keysClaimRequest struct {
|
|
OneTimeKeys map[string]map[string]string `json:"one_time_keys"`
|
|
Timeout int64 `json:"timeout"`
|
|
}
|
|
|
|
// handleKeysClaim implements POST /_matrix/client/v3/keys/claim.
|
|
func (c *ClientAPI) handleKeysClaim(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
|
|
var req keysClaimRequest
|
|
if err := httputil.DecodeJSON(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
resp, err := c.keys.ClaimKeys(r.Context(), keyserver.ClaimRequest{
|
|
OneTimeKeys: req.OneTimeKeys,
|
|
Timeout: time.Duration(req.Timeout) * time.Millisecond,
|
|
})
|
|
if err != nil {
|
|
return keyError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
// handleKeysChanges implements GET /_matrix/client/v3/keys/changes.
|
|
func (c *ClientAPI) handleKeysChanges(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
|
query := r.URL.Query()
|
|
from := query.Get("from")
|
|
if from == "" {
|
|
return httputil.MissingParam("from is required")
|
|
}
|
|
lists, err := c.sync.KeyChanges(r.Context(), auth.UserID, from, query.Get("to"))
|
|
if err != nil {
|
|
return syncError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, lists)
|
|
return nil
|
|
}
|
|
|
|
// --- cross-signing ------------------------------------------------------------
|
|
|
|
// crossSigningUploadRequest is the body of POST /keys/device_signing/upload.
|
|
type crossSigningUploadRequest struct {
|
|
keyserver.CrossSigningRequest
|
|
Auth *uiaRequest `json:"auth"`
|
|
}
|
|
|
|
// handleCrossSigningUpload implements
|
|
// POST /_matrix/client/v3/keys/device_signing/upload.
|
|
//
|
|
// Replacing cross-signing keys asks for the password first, for the same
|
|
// reason deleting a device does: a stolen access token must not be enough
|
|
// to become the user to everyone who has verified them. The keyserver says
|
|
// when the upload is one of the two the spec exempts.
|
|
func (c *ClientAPI) handleCrossSigningUpload(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
|
var req crossSigningUploadRequest
|
|
if err := httputil.DecodeJSON(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
|
|
needsAuth, err := c.keys.CrossSigningNeedsAuth(r.Context(), auth.UserID, req.CrossSigningRequest)
|
|
if err != nil {
|
|
return keyError(err)
|
|
}
|
|
sessionID := ""
|
|
if needsAuth {
|
|
flows := []uiaFlow{{Stages: []string{stagePassword}}}
|
|
var done bool
|
|
sessionID, done, err = c.beginOrAdvanceUIA(w, flows, req.Auth,
|
|
func(_ string, stage *uiaRequest) (*httputil.MatrixError, error) {
|
|
return c.passwordStage(r, auth, stage)
|
|
})
|
|
if err != nil || !done {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := c.keys.UploadCrossSigningKeys(r.Context(), auth.UserID, req.CrossSigningRequest); err != nil {
|
|
return keyError(err)
|
|
}
|
|
if sessionID != "" {
|
|
c.uia.remove(sessionID)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, struct{}{})
|
|
return nil
|
|
}
|
|
|
|
type signaturesUploadResponse struct {
|
|
Failures map[string]map[string]keyserver.SignatureFailure `json:"failures"`
|
|
}
|
|
|
|
// handleSignaturesUpload implements
|
|
// POST /_matrix/client/v3/keys/signatures/upload.
|
|
func (c *ClientAPI) handleSignaturesUpload(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
|
var req map[string]map[string]json.RawMessage
|
|
if err := httputil.DecodeJSON(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
failures, err := c.keys.UploadSignatures(r.Context(), auth.UserID, req)
|
|
if err != nil {
|
|
return keyError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, signaturesUploadResponse{Failures: failures})
|
|
return nil
|
|
}
|
|
|
|
// --- key backup ---------------------------------------------------------------
|
|
|
|
// backupVersionRequest is the body of POST and PUT /room_keys/version.
|
|
type backupVersionRequest struct {
|
|
Algorithm string `json:"algorithm"`
|
|
AuthData json.RawMessage `json:"auth_data"`
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
// handleCreateBackup implements POST /_matrix/client/v3/room_keys/version.
|
|
func (c *ClientAPI) handleCreateBackup(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
|
var req backupVersionRequest
|
|
if err := httputil.DecodeJSON(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
version, err := c.keys.CreateBackup(r.Context(), auth.UserID, req.Algorithm, req.AuthData)
|
|
if err != nil {
|
|
return keyError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, map[string]string{"version": version})
|
|
return nil
|
|
}
|
|
|
|
// handleGetBackup implements GET /_matrix/client/v3/room_keys/version and
|
|
// GET /_matrix/client/v3/room_keys/version/{version}.
|
|
func (c *ClientAPI) handleGetBackup(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
|
version := r.PathValue("version")
|
|
info, err := c.keys.Backup(r.Context(), auth.UserID, version)
|
|
if errors.Is(err, keyserver.ErrNoSuchBackup) && version == "" {
|
|
return httputil.NotFound("No current backup version")
|
|
}
|
|
if err != nil {
|
|
return keyError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, info)
|
|
return nil
|
|
}
|
|
|
|
// handlePutBackup implements PUT /_matrix/client/v3/room_keys/version/{version}.
|
|
func (c *ClientAPI) handlePutBackup(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
|
var req backupVersionRequest
|
|
if err := httputil.DecodeJSON(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
if err := c.keys.UpdateBackup(r.Context(), auth.UserID, r.PathValue("version"),
|
|
req.Algorithm, req.Version, req.AuthData); err != nil {
|
|
return keyError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, struct{}{})
|
|
return nil
|
|
}
|
|
|
|
// handleDeleteBackup implements DELETE /_matrix/client/v3/room_keys/version/{version}.
|
|
func (c *ClientAPI) handleDeleteBackup(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
|
if err := c.keys.DeleteBackup(r.Context(), auth.UserID, r.PathValue("version")); err != nil {
|
|
return keyError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, struct{}{})
|
|
return nil
|
|
}
|
|
|
|
// roomKeysRequest is the body of PUT /room_keys/keys at whatever
|
|
// granularity the path names; only the matching field is read.
|
|
type roomKeysRequest struct {
|
|
Rooms map[string]keyserver.RoomKeyBackup `json:"rooms"`
|
|
Sessions map[string]keyserver.KeyBackupData `json:"sessions"`
|
|
}
|
|
|
|
// handlePutRoomKeys implements PUT /_matrix/client/v3/room_keys/keys,
|
|
// .../{roomId} and .../{roomId}/{sessionId}.
|
|
func (c *ClientAPI) handlePutRoomKeys(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
|
roomID, sessionID := r.PathValue("roomID"), r.PathValue("sessionID")
|
|
|
|
var rooms map[string]keyserver.RoomKeyBackup
|
|
switch {
|
|
case sessionID != "":
|
|
var data keyserver.KeyBackupData
|
|
if err := httputil.DecodeJSON(w, r, &data); err != nil {
|
|
return err
|
|
}
|
|
rooms = map[string]keyserver.RoomKeyBackup{
|
|
roomID: {Sessions: map[string]keyserver.KeyBackupData{sessionID: data}},
|
|
}
|
|
case roomID != "":
|
|
var req roomKeysRequest
|
|
if err := httputil.DecodeJSON(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
rooms = map[string]keyserver.RoomKeyBackup{roomID: {Sessions: req.Sessions}}
|
|
default:
|
|
var req roomKeysRequest
|
|
if err := httputil.DecodeJSON(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
rooms = req.Rooms
|
|
}
|
|
|
|
result, err := c.keys.PutBackupKeys(r.Context(), auth.UserID, r.URL.Query().Get("version"), rooms)
|
|
if err != nil {
|
|
return keyError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, result)
|
|
return nil
|
|
}
|
|
|
|
// handleGetRoomKeys implements GET /_matrix/client/v3/room_keys/keys,
|
|
// .../{roomId} and .../{roomId}/{sessionId}.
|
|
func (c *ClientAPI) handleGetRoomKeys(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
|
roomID, sessionID := r.PathValue("roomID"), r.PathValue("sessionID")
|
|
rooms, err := c.keys.BackupKeys(r.Context(), auth.UserID, r.URL.Query().Get("version"), roomID, sessionID)
|
|
if err != nil {
|
|
return keyError(err)
|
|
}
|
|
|
|
switch {
|
|
case sessionID != "":
|
|
data, ok := rooms[roomID].Sessions[sessionID]
|
|
if !ok {
|
|
return httputil.NotFound("No room key found for that session")
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, data)
|
|
case roomID != "":
|
|
room, ok := rooms[roomID]
|
|
if !ok {
|
|
room = keyserver.RoomKeyBackup{Sessions: map[string]keyserver.KeyBackupData{}}
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, room)
|
|
default:
|
|
httputil.WriteJSON(w, http.StatusOK, map[string]any{"rooms": rooms})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleDeleteRoomKeys implements DELETE /_matrix/client/v3/room_keys/keys,
|
|
// .../{roomId} and .../{roomId}/{sessionId}.
|
|
func (c *ClientAPI) handleDeleteRoomKeys(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
|
result, err := c.keys.DeleteBackupKeys(r.Context(), auth.UserID, r.URL.Query().Get("version"),
|
|
r.PathValue("roomID"), r.PathValue("sessionID"))
|
|
if err != nil {
|
|
return keyError(err)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, result)
|
|
return nil
|
|
}
|