1 module matrix.matrix;
2 
3 import std.json;
4 import std.conv : to, text;
5 import std.array : array;
6 import std.algorithm : map, countUntil, canFind;
7 import std.exception : enforce;
8 import std.stdio; // for debugging
9 
10 import requests;
11 import requests.utils : urlEncoded;
12 
13 import matrix.session : Session, Account;
14 import matrix.inbound_group : InboundGroupSession;
15 import matrix.outbound_group : OutboundGroupSession;
16 
17 enum RoomPreset {
18     /// Joining requires an invite
19     private_chat,
20     /// Joining requires an invite, everybody is admin
21     trusted_private_chat,
22     /// Anybody can join
23     public_chat
24 }
25 
26 enum Presence:string {
27     online = "online",
28     offline = "offline",
29     unavailable = "unavailable" // e.g. idle
30 }
31 
32 abstract class Client {
33     private:
34     /// Server, e.g. "https://matrix.org"
35     string server_url;
36     /// Token received after successful login
37     string access_token;
38     /// Generate a transaction ID unique across requests with the same access token
39     long tid;
40     Request rq;
41     /// Account data for encryption with olm
42     Account account = null;
43     /// State of the client, which should be preserved
44     JSONValue state;
45     /// Path where to store the state permanently
46     string state_path;
47     /// Encryption key used for all account and session serializations
48     string key;
49 
50     public this(string url, string state_path) {
51         this.server_url = url;
52         this.state_path = state_path;
53         updateFromStatePath();
54         //this.rq.verbosity = 1;
55     }
56 
57     import std.file;
58 
59     private void updateFromStatePath() {
60         if (state_path.exists) {
61             string raw = cast(string) read(state_path);
62             this.state = parseJSON(raw);
63             assert("user_id" in state);
64             assert("device_id" in state);
65             assert("next_batch" in state);
66         } else {
67             /* ensure basic fields exist */
68             /// Identifier of this device by server
69             this.state["user_id"] = "";
70             /// Matrix user id, known after login
71             this.state["device_id"] = "";
72             /// ID of the last sync
73             this.state["next_batch"] = "";
74             /// We must keep track of rooms
75             this.state["rooms"] = parseJSON("{}");
76             /// We must keep track of other users
77             this.state["users"] = parseJSON("{}");
78         }
79     }
80 
81     public void saveState() const @safe {
82         this.state_path.write(state.toPrettyString());
83     }
84 
85     public string[] versions() {
86         auto res = rq.get(server_url ~ "/_matrix/client/versions");
87         auto j = parseResponse(res);
88         return j["versions"].array.map!"a.str".array;
89     }
90 
91     public void login(string name, string password) {
92         auto payload = "{ \"type\": \"m.login.password\", \"user\": \""~name~"\", \"password\": \""~password~"\" }";
93         /* Creating a JSONValue object would be safer, but matrix.org
94          * requires a fixed order for the keys. */
95         auto res = rq.post(server_url ~ "/_matrix/client/r0/login",
96                 payload, "application/json");
97         auto j = parseResponse(res);
98         this.access_token = j["access_token"].str;
99         if (state["device_id"].str == "")
100             this.state["device_id"] = j["device_id"];
101         this.state["user_id"] = j["user_id"];
102         // TODO store and use refresh_token
103     }
104 
105     public void logout() {
106         setPresence(Presence.offline, "off");
107         auto res = rq.post(server_url ~ "/_matrix/client/r0/logout"
108             ~ "?access_token=" ~ urlEncoded(this.access_token));
109         auto j = parseResponse(res);
110         this.access_token = "";
111     }
112 
113     public void setPresence(Presence p, string status_msg) {
114         JSONValue payload = [
115             "presence": text(p),
116             "status_msg": status_msg,
117             ];
118         string url = server_url ~ "/_matrix/client/r0/presence/"
119             ~ state["user_id"].str ~ "/status"
120             ~ "?access_token=" ~ urlEncoded(this.access_token);
121         auto res = rq.exec!"PUT"(url, text(payload));
122         auto j = parseResponse(res);
123     }
124 
125     public void sync(int timeout) {
126         auto qp = queryParams("set_presence", "online",
127             "timeout", timeout,
128             "access_token", this.access_token);
129         if (state["next_batch"].str != "")
130             qp = queryParams("set_presence", "online",
131                 "since", state["next_batch"].str,
132                 "timeout", timeout,
133                 "access_token", this.access_token);
134         auto res = rq.get(server_url ~ "/_matrix/client/r0/sync", qp);
135         auto j = parseResponse(res);
136         /* sync room states */
137         if ("rooms" in j) {
138             syncRoomState(j["rooms"]);
139         }
140         /* sync presence states */
141         if ("presence" in j && "events" in j["presence"]) {
142             auto events = j["presence"]["events"].array;
143             foreach(JSONValue e; events) {
144                 if (e["type"].str == "m.presence") {
145                     auto user_id = e["sender"].str;
146                     if ("presence" !in state["users"][user_id])
147                         state["users"][user_id]["presence"] = parseJSON("{}");
148                     auto uj = state["users"][user_id]["presence"];
149                     uj["presence"] = e["content"]["presence"];
150                     if ("currently_active" in e["content"])
151                         uj["currently_active"] = e["content"]["currently_active"];
152                     if ("status_msg" in e["content"])
153                         uj["status_msg"] = e["content"]["status_msg"];
154                     continue;
155                 }
156                 onPresenceEvent(e);
157             }
158         }
159         /* sync account_data states */
160         if ("account_data" in j && "events" in j["account_data"]) {
161             auto events = j["account_data"]["events"].array;
162             foreach(JSONValue e; events) {
163                 onSyncAccountDataEvent(e);
164             }
165         }
166         /* direct to device messsages */
167         if ("to_device" in j) {
168             auto events = j["to_device"]["events"].array;
169             foreach(JSONValue e; events) {
170                 if (e["type"].str == "m.new_device") {
171                     if (e["content"]["device_id"].str == state["device_id"].str)
172                         continue;
173                     writeln("NEW DEVICE: ", e);
174                 }
175                 writeln("TO_DEVICE ", e);
176                 assert(false);
177                 // FIXME
178             }
179         }
180         /* No idea what device lists is for ... */
181         if ("device_lists" in j) {
182             writeln("DEVICE_LISTS? ", j["device_lists"]);
183             // TODO
184         }
185         // Make sure we notice, if another key pops up
186         foreach (key, j2; j.object) {
187             switch (key) {
188                 case "next_batch":
189                 case "rooms":
190                 case "presence":
191                 case "account_data":
192                 case "to_device":
193                 case "device_lists":
194                     break;
195                 default:
196                     writeln("UNKNOWN KEY ", key);
197                     assert(false);
198             }
199         }
200         if (state["next_batch"].str == "") {
201             /* announce this device */
202             JSONValue ann = [
203                 "device_id": state["device_id"],
204                 "rooms": parseJSON("[]"),
205             ];
206             JSONValue payload = parseJSON("{}");
207             string[] users;
208             foreach (room_id, j; state["rooms"].object) {
209                 if ("encrypted" !in j.object)
210                     continue;
211                 ann["rooms"].array ~= JSONValue(room_id);
212                 foreach (user_id, j; state["rooms"][room_id]["members"].object) {
213                     if (users.canFind(user_id)) continue;
214                     if (user_id !in payload) {
215                         payload[user_id] = ["*": parseJSON("{}")];
216                         payload[user_id]["*"]["device_id"] = state["device_id"];
217                         payload[user_id]["*"]["rooms"] = parseJSON("[]");
218                     }
219                     payload[user_id]["*"]["rooms"].array ~= JSONValue(room_id);
220                     users ~= user_id;
221                 }
222             }
223             JSONValue wrap = ["messages": payload];
224             // see https://github.com/matrix-org/matrix-doc/issues/860
225             sendToDevice(wrap.toString(), "m.new_device");
226         }
227         state["next_batch"] = j["next_batch"];
228     }
229 
230     private void syncRoomState(JSONValue json) {
231         if ("invite" in json) {
232             foreach (string roomname, JSONValue left_room; json["invite"]) {
233                 onInviteRoom(roomname);
234                 foreach (JSONValue event; left_room["invite_state"]["events"].array)
235                     onInviteEvent(roomname, event);
236             }
237         }
238         if ("leave" in json) {
239             foreach (string roomname, JSONValue left_room; json["leave"]) {
240                 assert (roomname in state["rooms"]);
241                 state["rooms"][roomname] = null;
242                 onLeaveRoom(roomname, left_room);
243                 if ("timeline" in left_room) {
244                     // TODO limited, prev_batch
245                     foreach (event; left_room["timeline"]["events"].array)
246                         onLeaveTimelineEvent(roomname, event);
247                 }
248                 if ("state" in left_room) {
249                     foreach (event; left_room["state"]["events"].array)
250                         onLeaveStateEvent(roomname, event);
251                 }
252             }
253         }
254         if ("join" in json) {
255             foreach (string roomname, JSONValue joined_room; json["join"]) {
256                 auto un = joined_room["unread_notifications"];
257                 ulong hc, nc;
258                 if ("highlight_count" in un)
259                     hc = un["highlight_count"].integer;
260                 if ("notification_count" in un)
261                     nc = un["notification_count"].integer;
262                 if (roomname !in state["rooms"])
263                     state["rooms"][roomname] = parseJSON("{\"members\": {}}");
264                 onJoinRoom(roomname, hc, nc);
265                 if ("state" in joined_room) {
266                     foreach (event; joined_room["state"]["events"].array) {
267                         auto sender = event["sender"].str;
268                         if (event["type"].str == "m.room.name") {
269                             state["rooms"][roomname]["name"]
270                                 = event["content"]["name"].str;
271                             continue;
272                         }
273                         if (event["type"].str == "m.room.topic") {
274                             state["rooms"][roomname]["topic"]
275                                 = event["content"]["topic"].str;
276                             continue;
277                         }
278                         if (event["type"].str == "m.room.member") {
279                             seenUserIdInRoom(event["sender"], roomname);
280                             state["rooms"][roomname]["members"][sender] = parseJSON("{}");
281                             continue;
282                         }
283                         if (event["type"].str == "m.room.encryption") {
284                             state["rooms"][roomname]["encrypted"] = true;
285                             // only support megolm
286                             assert (event["content"]["algorithm"].str == "m.megolm.v1.aes-sha2");
287                             writeln(sender, " enabled encryption for ", roomname);
288                             continue;
289                         }
290                         onJoinStateEvent(roomname, event);
291                     }
292                 }
293                 if ("timeline" in joined_room) {
294                     // TODO limited, prev_batch
295                     foreach (event; joined_room["timeline"]["events"].array) {
296                         auto sender = event["sender"].str;
297                         if (event["type"].str == "m.room.member") {
298                             seenUserIdInRoom(event["sender"], roomname);
299                             state["rooms"][roomname]["members"][sender] = parseJSON("{}");
300                             continue;
301                         }
302                         if (event["type"].str == "m.room.encryption") {
303                             state["rooms"][roomname]["encrypted"] = true;
304                             // only support megolm
305                             assert (event["content"]["algorithm"].str == "m.megolm.v1.aes-sha2");
306                             writeln(sender, " enabled encryption for ", roomname);
307                             continue;
308                         }
309                         if (event["type"].str == "m.room.encrypted") {
310                             assert(state["rooms"][roomname]["encrypted"].type() == JSON_TYPE.TRUE);
311                             // only support megolm
312                             assert (event["content"]["algorithm"].str == "m.megolm.v1.aes-sha2");
313                             event = decrypt_room(event, state["rooms"][roomname]);
314                         }
315                         onJoinTimelineEvent(roomname, event);
316                     }
317                 }
318                 if ("account_data" in joined_room) {
319                     foreach (event; joined_room["account_data"]["events"].array)
320                         onJoinAccountDataEvent(roomname, event);
321                 }
322                 if ("ephemeral" in joined_room) {
323                     foreach (event; joined_room["ephemeral"]["events"].array)
324                         onEphemeralEvent(roomname, event);
325                 }
326             }
327         }
328     }
329 
330     private JSONValue decrypt_room(JSONValue event, JSONValue roomstate) {
331         auto session_id = event["content"]["session_id"].str;
332         auto cipher = event["content"]["ciphertext"].str;
333         auto sender_key = event["content"]["sender_key"].str;
334         {
335             /* check if session_ids match */
336             auto inbound2 = InboundGroupSession.init(sender_key);
337             //enforce(inbound2.session_id == session_id);
338         }
339         auto sender_id = event["sender"].str;
340         auto sender_dev_id = event["content"]["device_id"].str;
341         if (sender_dev_id !in roomstate["members"][sender_id]) {
342             roomstate["members"][sender_id][sender_dev_id] = parseJSON("{}");
343         }
344         auto dev_info = roomstate["members"][sender_id][sender_dev_id];
345         if ("megolm_sessions" !in state)
346             state["megolm_sessions"] = parseJSON("{}");
347         if (session_id in state["megolm_sessions"]) {
348             auto s = state["megolm_sessions"][session_id];
349             if ("enc_inbound" !in s) {
350                 writeln("TODO Cannot decrypt. Key missing.");
351                 return event;
352             }
353             auto inbound = InboundGroupSession.unpickle(this.key, s["enc_inbound"].str);
354             uint msg_index;
355             auto plain = inbound.decrypt(cipher, &msg_index);
356             dev_info["msg_index"] = msg_index;
357             event["content"] = [
358                 "body": parseJSON(plain)["content"].str,
359                 "msgtype": "m.text" ];
360             event["type"] = "m.room.message";
361             return event;
362         } else { // Unknown session
363             writeln("Unknown session id: ", session_id);
364             state["megolm_sessions"][session_id] = ["sender_identity_key": sender_key, "initiator": sender_id];
365         }
366         // Decryption failed. Return still-encrypted event.
367         return event;
368     }
369 
370     private void seenUserIdInRoom(JSONValue user_id, string room_id) {
371         if (user_id.str !in state["rooms"][room_id]["members"]) {
372             state["rooms"][room_id]["members"][user_id.str] = parseJSON("{}");
373         }
374         if (user_id.str !in state["users"]) {
375             state["users"][user_id.str] = parseJSON("{}");
376         }
377     }
378 
379     abstract public void onInviteRoom(const string name);
380     abstract public void onInviteEvent(const string name, const JSONValue v);
381     abstract public void onLeaveRoom(const string name, const JSONValue v);
382     abstract public void onJoinRoom(const string name, ulong highlight_count, ulong notification_count);
383     abstract public void onLeaveTimelineEvent(const string name, const JSONValue v);
384     abstract public void onLeaveStateEvent(const string name, const JSONValue v);
385     abstract public void onJoinTimelineEvent(const string name, const JSONValue v);
386     abstract public void onJoinStateEvent(const string name, const JSONValue v);
387     abstract public void onJoinAccountDataEvent(const string name, const JSONValue v);
388     abstract public void onSyncAccountDataEvent(const JSONValue v);
389     abstract public void onEphemeralEvent(const string name, const JSONValue v);
390     abstract public void onPresenceEvent(const JSONValue v);
391     abstract public void onAccountDataUpdate(const string type, const string key, const JSONValue value);
392 
393     private string nextTransactionID() {
394         scope(exit) this.tid += 1;
395         return text(this.tid);
396     }
397 
398     private void fetchDeviceKeys(string roomname) {
399         auto q = parseJSON("{\"device_keys\":{}}");
400         foreach (user_id, j; state["rooms"][roomname]["members"].object) {
401             q["device_keys"][user_id] = parseJSON("{}");
402         }
403         string url = server_url ~ "/_matrix/client/unstable/keys/query"
404             ~ "?access_token=" ~ urlEncoded(this.access_token);
405         auto res = rq.post(url, text(q));
406         auto j = parseResponse(res);
407         check_signature(j);
408         foreach(user_id, j2; j["device_keys"].object) {
409             if (user_id !in state["users"])
410                 state["users"][user_id] = parseJSON("{}");
411             foreach(device_id, j3; j2.object) {
412                 check_signature(j3);
413                 // FIXME match user_id-device_id against known information
414                 // FIXME for known devices match ed25519 key
415                 if (device_id !in state["users"][user_id]) {
416                     state["users"][user_id][device_id] = parseJSON("{}");
417                 }
418                 foreach(method, key; j3["keys"].object) {
419                     auto i = method.countUntil(":");
420                     // FIXME what if already in there?
421                     state["users"][user_id][device_id][method[0..i]] = key;
422                 }
423             }
424         }
425     }
426 
427     public void send(string roomname, string msg) {
428         if ("encrypted" in state["rooms"][roomname]) {
429             fetchDeviceKeys(roomname);
430             OutboundGroupSession outbound;
431             if ("enc_outbound" in state["rooms"][roomname]) {
432                 outbound = OutboundGroupSession.unpickle(this.key, state["rooms"][roomname]["enc_outbound"].str);
433             } else {
434                 outbound = new OutboundGroupSession();
435                 state["rooms"][roomname]["enc_outbound"] = outbound.pickle(this.key);
436             }
437             // FIXME check if outbound requires rotation
438             sendSessionKeyAround(roomname, outbound);
439             JSONValue payload = [
440                 "type": "m.text",
441                 "content": msg,
442                 "room_id": roomname
443             ];
444             auto cipher = outbound.encrypt(text(payload));
445             JSONValue content = [
446                 "algorithm": "m.megolm.v1.aes-sha2",
447                 "sender_key": outbound.session_key,
448                 "ciphertext": cipher,
449                 "session_id": outbound.session_id,
450                 "device_id": state["device_id"].str,
451             ];
452             string url = server_url ~ "/_matrix/client/r0/rooms/"
453                 ~ roomname ~ "/send/m.room.encrypted/" ~ nextTransactionID()
454                 ~ "?access_token=" ~ urlEncoded(this.access_token);
455             auto res = rq.exec!"PUT"(url, text(content));
456             auto j = parseResponse(res);
457         } else { /* sending unencrypted */
458             auto content = parseJSON("{\"msgtype\": \"m.text\"}");
459             content["body"] = msg;
460             string url = server_url ~ "/_matrix/client/r0/rooms/" ~ roomname
461                 ~ "/send/m.room.message/" ~ nextTransactionID()
462                 ~ "?access_token=" ~ urlEncoded(this.access_token);
463             auto res = rq.exec!"PUT"(url, text(content));
464             auto j = parseResponse(res);
465         }
466     }
467 
468     private void sendSessionKeyAround(string room_id, OutboundGroupSession outbound) {
469         auto s_id = outbound.session_id;
470         auto s_key = outbound.session_key;
471         auto device_id = state["device_id"].str;
472         /* store these details as an inbound session, just as it would when
473            receiving them via an m.room_key event */
474         {
475             auto inb = InboundGroupSession.init(s_key);
476             assert (inb.session_id == s_id);
477             JSONValue j = [
478                 "session_key": s_key,
479                 "enc_inbound": inb.pickle(this.key),
480             ];
481             j["msg_index"] = 0;
482             auto uid = state["user_id"].str;
483             state["rooms"][room_id]["members"][uid][device_id] = parseJSON("{}");
484             if ("megolm_sessions" !in state)
485                 state["megolm_sessions"] = [s_id: j];
486             else
487                 state["megolm_sessions"][s_id] = j;
488         }
489         createOlmSessions(state["rooms"][room_id]["members"].object.byKey.array);
490         /* send session key to all other devices in the room */
491         JSONValue payload = parseJSON("{}");
492         foreach (user_id, j; state["rooms"][room_id]["members"].object) {
493             payload[user_id] = parseJSON("{}");
494             foreach (string device_id, j2; j.object) {
495                 if (device_id == state["device_id"].str)
496                     continue;
497                 if (device_id !in state["users"][user_id])
498                     continue;
499                 writeln("send session '", s_id, "' key to ", user_id, " ", device_id);
500                 JSONValue j = [
501                     "algorithm": "m.megolm.v1.aes-sha2",
502                     "room_id": room_id,
503                     "session_id": s_id,
504                     "session_key": s_key,
505                 ];
506                 payload[user_id][device_id] = encrypt_for_device(user_id, device_id, text(j));
507             }
508         }
509         JSONValue wrap = ["messages": payload];
510         // see https://github.com/matrix-org/matrix-doc/issues/860
511         sendToDevice(text(wrap), "m.room.encrypted");
512     }
513 
514     private void createOlmSessions(const string[] users) {
515         /* claim one time keys */
516         JSONValue payload = ["one_time_keys": parseJSON("{}")];
517         foreach (user_id, j; state["users"].object) {
518             if (!users.canFind(user_id))
519                 continue;
520             payload["one_time_keys"][user_id] = parseJSON("{}");
521             foreach (dev_id, j2; j.object) {
522                 payload["one_time_keys"][user_id][dev_id] = "signed_curve25519";
523             }
524         }
525         string url = server_url ~ "/_matrix/client/unstable/keys/claim"
526             ~ "?access_token=" ~ urlEncoded(this.access_token);
527         auto res = rq.post(url, text(payload), "application/json");
528         /* create sessions from one time keys */
529         auto j = parseResponse(res);
530         foreach (user_id, j; j["one_time_keys"].object) {
531             foreach (device_id, j2; j.object) {
532                 foreach (s_key_id, j3; j2.object) {
533                     import std.algorithm : startsWith;
534                     assert (s_key_id.startsWith("signed_curve25519:"));
535                     check_signature(j3);
536                     auto dev_key = j3["key"].str;
537                     auto identity_key = state["users"][user_id][device_id]["ed25519"].str;
538                     auto session = Session.create_outbound(this.account, identity_key, dev_key);
539                     state["users"][user_id][device_id]["enc_session"] = session.pickle(this.key);
540                 }
541             }
542         }
543     }
544 
545     private string encrypt_for_device(string user_id, string device_id, string msg) {
546         if (device_id !in state["users"][user_id])
547             throw new Exception("Unknown device "~text(device_id));
548         if ("enc_session" !in state["users"][user_id][device_id])
549             throw new Exception("No session");
550         auto session = Session.unpickle(this.key,
551                 state["users"][user_id][device_id]["enc_session"].str);
552         ulong msg_typ;
553         auto cipher = session.encrypt(msg, msg_typ);
554         return cipher;
555     }
556 
557     private void sendToDevice(string msg, string msg_type) {
558         string url = server_url ~ "/_matrix/client/unstable/sendToDevice/"
559             ~ msg_type ~ "/" ~ nextTransactionID()
560             ~ "?access_token=" ~ urlEncoded(this.access_token);
561         auto res = rq.exec!"PUT"(url, msg);
562         auto j = parseResponse(res);
563     }
564 
565     private string[] devicesOfRoom(string room_id) {
566         string[] ret;
567         foreach (user_id, j; state["rooms"][room_id]["members"].object) {
568             foreach (string device_id, j2; j.object) {
569                 ret ~= device_id;
570             }
571         }
572         return ret;
573     }
574 
575     private void check_signature(JSONValue j) {
576         // FIXME actually implement check
577         /* if signature check fails, mark the failing device as 'evil' */
578     }
579 
580     /** Create a new room on the homeserver
581      *  Returns: id of the room
582      */
583     public string createRoom(RoomPreset p) {
584         JSONValue content = parseJSON("{}");
585         content["preset"] = text(p);
586         string url = server_url ~ "/_matrix/client/r0/createRoom"
587             ~ "?access_token=" ~ urlEncoded(this.access_token);
588         auto payload = text(content);
589         auto res = rq.post(url, payload, "application/json");
590         auto j = parseResponse(res);
591         return j["room_id"].str;
592     }
593 
594     public void invite(string roomid, string userid) {
595         JSONValue content = parseJSON("{}");
596         content["user_id"] = userid;
597         string url = server_url ~ "/_matrix/client/r0/rooms/"
598             ~ roomid ~ "/invite"
599             ~ "?access_token=" ~ urlEncoded(this.access_token);
600         auto payload = text(content);
601         auto res = rq.post(url, payload, "application/json");
602         auto j = parseResponse(res);
603     }
604 
605     /** Enables encryption
606      *  Requires key because we always store stuff locked up.
607      *  Must be logged in, so we know the user id.
608      *  If path exist, then load it and decrypt with key.
609      *  Otherwise create new keys and store them there encrypted with key.
610      **/
611     public void enable_encryption(string key) {
612         this.key = key;
613         if ("encrypted_account" in state) {
614             this.account = Account.unpickle(key, state["encrypted_account"].str);
615         } else {
616             this.account = Account.create();
617             state["encrypted_account"] = account.pickle(key);
618         }
619         /* create payload for publishing keys to homeserver */
620         assert (this.access_token); // must login first!
621         const keys = parseJSON(this.account.identity_keys);
622         const device_id = state["device_id"].str;
623         JSONValue json = [
624             "device_id": device_id,
625             "user_id": state["user_id"].str ];
626         json["algorithms"] = ["m.olm.v1.curve25519-aes-sha2",
627             "m.megolm.v1.aes-sha2"];
628         json["keys"] = [
629             "curve25519:"~device_id: keys["curve25519"].str,
630             "ed25519:"~device_id: keys["ed25519"].str
631         ];
632         sign_json(json);
633         /* actually publish keys */
634         auto payload = text(json);
635         auto res = rq.post(server_url ~ "/_matrix/client/unstable/keys/upload"
636                 ~ "?access_token=" ~ urlEncoded(this.access_token),
637                 payload, "application/json");
638         auto j = parseResponse(res);
639         uploadOneTimeKeys(j);
640     }
641 
642     /** Uploads more one time keys, if necessary */
643     public void uploadOneTimeKeys() {
644         auto res = rq.post(server_url ~ "/_matrix/client/unstable/keys/upload"
645                 ~ "?access_token=" ~ urlEncoded(this.access_token),
646                 "{}", "application/json");
647         auto j = parseResponse(res);
648         uploadOneTimeKeys(j);
649     }
650 
651     private void uploadOneTimeKeys(JSONValue currently) {
652         ulong otkeys_on_server;
653         ulong max_otkeys_on_server = this.account.max_number_of_one_time_keys/2;
654         if ("one_time_key_counts" in currently) {
655             foreach(k,v; currently["one_time_key_counts"].object) {
656                 writeln("counting one time keys", k, v); // TODO
657             }
658         }
659         if (otkeys_on_server >= max_otkeys_on_server)
660             return;
661         /* Generate new keys */
662         auto diff = max_otkeys_on_server - otkeys_on_server;
663         this.account.generate_one_time_keys(diff);
664         auto keys = parseJSON(this.account.one_time_keys());
665         JSONValue allkeys = ["one_time_keys": parseJSON("{}")];
666         foreach(kid,key; keys["curve25519"].object) {
667             JSONValue j = ["key": key];
668             sign_json(j);
669             allkeys["one_time_keys"]["signed_curve25519:"~kid] = j;
670         }
671         /* upload */
672         auto payload = text(allkeys);
673         auto res = rq.post(server_url ~ "/_matrix/client/unstable/keys/upload"
674                 ~ "?access_token=" ~ urlEncoded(this.access_token),
675                 payload, "application/json");
676         auto j = parseResponse(res);
677         this.account.mark_keys_as_published();
678     }
679 
680     private void sign_json(JSONValue j) {
681         /* D creates Canonical JSON as specified by Matrix */
682         auto raw = text(j);
683         auto signature = this.account.sign(raw);
684         auto user_id = state["user_id"].str;
685         auto device_id = state["device_id"].str;
686         j["signatures"] = [user_id: ["ed25519:"~device_id: signature]];
687     }
688 
689     public @property string[] rooms() {
690         string[] ret;
691         foreach (roomname, v; state["rooms"].object)
692             ret ~= roomname;
693         return ret;
694     }
695 }
696 
697 /* Convert a raw response into JSON
698  * and check for a Matrix error */
699 JSONValue parseResponse(Response res) {
700     auto r = res.responseBody;
701     auto j = parseJSON(r, JSONOptions.none);
702     if ("error" in j)
703         throw new MatrixError(
704             j["errcode"].str ~" "~ j["error"].str);
705     return j;
706 }
707 
708 /* When the Matrix server sends an error message */
709 class MatrixError : Exception {
710     public this(string msg) {
711         super(msg);
712     }
713 }