@@ -29,7 +29,7 @@ module App =
29 type Page =
29 type Page =
31 | Conversations
31 | Conversations
32− | Chat of string
32+ | Chat of pubkey: string
33 | AddContact
33 | AddContact
34 | Settings
34 | Settings
@@ -38,12 +38,15 @@ module App =
38 | Connecting
38 | Connecting
39 | Online of token: string
39 | Online of token: string
41+ type Identity = { PrivKey: byte[]; PubKeyHex: string }
43+ type Session = { Url: string; Token: string; Identity: Identity }
41 // ── Model ──
45 // ── Model ──
43 type Model =
47 type Model =
44 { Page: Page
48 { Page: Page
45− PrivKey: byte[] option
49+ Identity: Identity option
47 ServerUrl: string
50 ServerUrl: string
48 Conn: ConnState
51 Conn: ConnState
49 Contacts: Contact list
52 Contacts: Contact list
@@ -61,7 +64,7 @@ module App =
61 type Msg =
64 type Msg =
62 | Nav of Page
65 | Nav of Page
63 | GenIdentity
66 | GenIdentity
64− | IdentityReady of byte[] * byte[]
67+ | IdentityReady of Identity
65 | SetServerUrl of string
68 | SetServerUrl of string
66 | DoConnect
69 | DoConnect
67 | AuthOk of string
70 | AuthOk of string
@@ -69,12 +72,12 @@ module App =
69 | DoDisconnect
72 | DoDisconnect
70 | SetCompose of string
73 | SetCompose of string
72− | Sent of string * string
75+ | Sent of recipient: string * status: string
73 | SetNewPubkey of string
76 | SetNewPubkey of string
74 | SetNewNickname of string
77 | SetNewNickname of string
75 | DoSaveContact
78 | DoSaveContact
76 | DoDeleteContact of string
79 | DoDeleteContact of string
77− | PollResult of IncomingMessage list * string * uint64
80+ | PollResult of messages: IncomingMessage list * status: string * cursor: uint64
79 | CopyPubKey
82 | CopyPubKey
80 | DismissError
83 | DismissError
@@ -83,9 +86,9 @@ module App =
84 type CmdMsg =
87 type CmdMsg =
85 | CmdGenIdentity
88 | CmdGenIdentity
86− | CmdConnect of url: string * pubHex: string * privKey: byte[]
89+ | CmdConnect of url: string * identity: Identity
87− | CmdSend of url: string * token: string * recipientHex: string * privKey: byte[] * text: string
90+ | CmdSend of session: Session * recipientHex: string * text: string
88− | CmdPoll of url: string * token: string * privKey: byte[] * cursor: uint64
91+ | CmdPoll of session: Session * cursor: uint64
89 | CmdCopyToClipboard of string
92 | CmdCopyToClipboard of string
91 // ── Helpers ──
94 // ── Helpers ──
@@ -112,12 +115,19 @@ module App =
113 contacts @ [ contact ]
116 contacts @ [ contact ]
118+ let private pubKeyHex model =
119+ model.Identity |> Option.map _.PubKeyHex |> Option.defaultValue ""
121+ let private trySession model =
122+ match model.Identity, model.Conn with
123+ | Some id, Online token -> Some { Url = model.ServerUrl; Token = token; Identity = id }
115 // ── Init ──
126 // ── Init ──
117 let init () =
128 let init () =
118 { Page = Setup
129 { Page = Setup
119− PrivKey = None
130+ Identity = None
121 ServerUrl = "http://localhost:8080"
131 ServerUrl = "http://localhost:8080"
122 Conn = Offline
132 Conn = Offline
123 PollStatus = ""
133 PollStatus = ""
@@ -139,26 +149,23 @@ module App =
140 | GenIdentity -> model, [ CmdGenIdentity ]
150 | GenIdentity -> model, [ CmdGenIdentity ]
142− | IdentityReady(priv, pub) ->
152+ | IdentityReady identity ->
145− PubKeyHex = Crypto.toHex pub
146− Page = Settings },
153+ { model with Identity = Some identity; Page = Settings }, []
149 | SetServerUrl url -> { model with ServerUrl = url }, []
155 | SetServerUrl url -> { model with ServerUrl = url }, []
151 | DoConnect ->
157 | DoConnect ->
152− match model.PrivKey with
158+ match model.Identity with
153− | Some priv ->
159+ | Some id ->
154− { model with Conn = Connecting }, [ CmdConnect(model.ServerUrl, model.PubKeyHex, priv) ]
160+ { model with Conn = Connecting }, [ CmdConnect(model.ServerUrl, id) ]
155 | None -> { model with Error = Some "No identity generated" }, []
161 | None -> { model with Error = Some "No identity generated" }, []
157 | AuthOk token ->
163 | AuthOk token ->
158− match model.PrivKey with
164+ match model.Identity with
159− | Some priv ->
165+ | Some id ->
166+ let session = { Url = model.ServerUrl; Token = token; Identity = id }
160 { model with Conn = Online token; Page = Conversations; Error = None },
167 { model with Conn = Online token; Page = Conversations; Error = None },
161− [ CmdPoll(model.ServerUrl, token, priv, model.PollCursor) ]
168+ [ CmdPoll(session, model.PollCursor) ]
162 | None ->
169 | None ->
163 { model with Error = Some "No identity generated" }, []
170 { model with Error = Some "No identity generated" }, []
@@ -169,8 +176,8 @@ module App =
169 | SetCompose text -> { model with ComposeText = text }, []
176 | SetCompose text -> { model with ComposeText = text }, []
171 | DoSend ->
178 | DoSend ->
172− match model.Page, model.Conn, model.PrivKey with
179+ match model.Page, trySession model with
173− | Chat pk, Online token, Some priv when model.ComposeText <> "" ->
180+ | Chat pk, Some session when model.ComposeText <> "" ->
174 let text = model.ComposeText
181 let text = model.ComposeText
175 let outMsg =
182 let outMsg =
176 { Id = Guid.NewGuid().ToString()
183 { Id = Guid.NewGuid().ToString()
@@ -182,7 +189,7 @@ module App =
182 { model with
189 { model with
183 ComposeText = ""
190 ComposeText = ""
184 Messages = model.Messages |> Map.add pk (msgs @ [ outMsg ]) },
191 Messages = model.Messages |> Map.add pk (msgs @ [ outMsg ]) },
185− [ CmdSend(model.ServerUrl, token, pk, priv, text) ]
192+ [ CmdSend(session, pk, text) ]
186 | _ -> model, []
193 | _ -> model, []
188 | Sent(_, status) -> { model with LastSendStatus = status }, []
195 | Sent(_, status) -> { model with LastSendStatus = status }, []
@@ -208,14 +215,14 @@ module App =
208 Messages = m.Messages |> Map.add msg.FromPubkey (msgs @ [ chatMsg ]) })
215 Messages = m.Messages |> Map.add msg.FromPubkey (msgs @ [ chatMsg ]) })
209 { model with PollStatus = status; PollCursor = newCursor }
216 { model with PollStatus = status; PollCursor = newCursor }
211− match model'.Conn, model'.PrivKey with
218+ match trySession model' with
212− | Online token, Some priv -> model', [ CmdPoll(model'.ServerUrl, token, priv, model'.PollCursor) ]
219+ | Some session -> model', [ CmdPoll(session, model'.PollCursor) ]
213− | _ -> model', []
220+ | None -> model', []
215 | DoPoll ->
222 | DoPoll ->
216− match model.Conn, model.PrivKey with
223+ match trySession model with
217− | Online token, Some priv -> model, [ CmdPoll(model.ServerUrl, token, priv, model.PollCursor) ]
224+ | Some session -> model, [ CmdPoll(session, model.PollCursor) ]
218− | _ -> model, []
225+ | None -> model, []
220 | SetNewPubkey pk -> { model with NewPubkey = pk }, []
227 | SetNewPubkey pk -> { model with NewPubkey = pk }, []
221 | SetNewNickname nn -> { model with NewNickname = nn }, []
228 | SetNewNickname nn -> { model with NewNickname = nn }, []
@@ -240,7 +247,10 @@ module App =
240 Contacts = model.Contacts |> List.filter (fun c -> c.Pubkey <> pk) },
247 Contacts = model.Contacts |> List.filter (fun c -> c.Pubkey <> pk) },
251+ match model.Identity with
243− | CopyPubKey -> model, [ CmdCopyToClipboard model.PubKeyHex ]
252+ | Some id -> model, [ CmdCopyToClipboard id.PubKeyHex ]
245 | DismissError -> { model with Error = None }, []
255 | DismissError -> { model with Error = None }, []
@@ -255,20 +265,44 @@ module App =
256 |> ignore)
266 |> ignore)
268+ let private decryptEvent (privKey: byte[]) (payload: JsonElement) =
270+ let blobHex = payload.GetProperty("encryptedBlob").GetString()
271+ let tsEl = payload.GetProperty("timestamp")
273+ if tsEl.ValueKind = JsonValueKind.String then Int64.Parse(tsEl.GetString())
274+ else tsEl.GetInt64()
275+ let blob = Crypto.fromHex blobHex
277+ match Crypto.decrypt privKey blob with
278+ | Some(plaintext, senderHex) ->
279+ let ptDoc = JsonDocument.Parse(plaintext)
280+ let ptRoot = ptDoc.RootElement
282+ if ptRoot.GetProperty("type").GetString() = "text" then
283+ let msgId = ptRoot.GetProperty("id").GetString()
284+ let body = ptRoot.GetProperty("body").GetString()
285+ Ok { FromPubkey = senderHex; Body = body; Timestamp = ts; MessageId = msgId }
287+ Error "unknown type"
288+ | None -> Error "decrypt failed"
290+ Error $"parse: {ex.Message}"
258 let mapCmd cmdMsg =
292 let mapCmd cmdMsg =
259 match cmdMsg with
293 match cmdMsg with
260 | CmdGenIdentity ->
294 | CmdGenIdentity ->
261 asyncCmd (async {
295 asyncCmd (async {
262 let priv, pub = Crypto.generateIdentity ()
296 let priv, pub = Crypto.generateIdentity ()
263− return IdentityReady(priv, pub)
297+ return IdentityReady { PrivKey = priv; PubKeyHex = Crypto.toHex pub }
266− | CmdConnect(url, pubHex, privKey) ->
300+ | CmdConnect(url, identity) ->
267 asyncCmd (async {
301 asyncCmd (async {
269− let! challenge = ApiClient.requestChallenge url pubHex
303+ let! challenge = ApiClient.requestChallenge url identity.PubKeyHex
270− let sigHex = Crypto.signChallenge privKey challenge
304+ let sigHex = Crypto.signChallenge identity.PrivKey challenge
271− let! (token, _) = ApiClient.verify url pubHex challenge sigHex
305+ let! (token, _) = ApiClient.verify url identity.PubKeyHex challenge sigHex
273 if String.IsNullOrEmpty(token) then
307 if String.IsNullOrEmpty(token) then
274 return AuthErr "Authentication rejected by server"
308 return AuthErr "Authentication rejected by server"
@@ -278,10 +312,10 @@ module App =
278 return AuthErr ex.Message
312 return AuthErr ex.Message
281− | CmdSend(url, token, recipientHex, privKey, text) ->
315+ | CmdSend(session, recipientHex, text) ->
282 asyncCmd (async {
316 asyncCmd (async {
284− let pubKey = privKey.[32..63]
318+ let pubKey = session.Identity.PrivKey.[32..63]
285 let recipPub = Crypto.fromHex recipientHex
319 let recipPub = Crypto.fromHex recipientHex
286 let id = Guid.NewGuid().ToString()
320 let id = Guid.NewGuid().ToString()
287 let ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
321 let ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
@@ -289,52 +323,33 @@ module App =
289 let payload =
323 let payload =
290 $"""{{ "type": "text", "id": "{id}", "timestamp": {ts}, "body": {JsonSerializer.Serialize(text)} }}"""
324 $"""{{ "type": "text", "id": "{id}", "timestamp": {ts}, "body": {JsonSerializer.Serialize(text)} }}"""
292− let blob = Crypto.encrypt privKey pubKey recipPub payload
326+ let blob = Crypto.encrypt session.Identity.PrivKey pubKey recipPub payload
293 let blobHex = Crypto.toHex blob
327 let blobHex = Crypto.toHex blob
294− let! (status, msgId) = ApiClient.sendMessage url token recipientHex blobHex ts
328+ let! (status, msgId) = ApiClient.sendMessage session.Url session.Token recipientHex blobHex ts
295 return Sent(recipientHex, $"{status}:{msgId}")
329 return Sent(recipientHex, $"{status}:{msgId}")
296 with ex ->
330 with ex ->
297 return DismissError // TODO: show send errors properly
331 return DismissError // TODO: show send errors properly
300− | CmdPoll(url, token, privKey, cursor) ->
334+ | CmdPoll(session, cursor) ->
301 asyncCmd (async {
335 asyncCmd (async {
303− let! (events, newCursor) = ApiClient.poll url token cursor
337+ let! (events, newCursor) = ApiClient.poll session.Url session.Token cursor
304 let messages = ResizeArray()
338 let messages = ResizeArray()
305 let ackIds = ResizeArray()
339 let ackIds = ResizeArray()
306 let errors = ResizeArray()
340 let errors = ResizeArray()
308 for (evtId, evtType, payload) in events do
342 for (evtId, evtType, payload) in events do
309 if evtType = "message" then
343 if evtType = "message" then
310− try
344+ match decryptEvent session.Identity.PrivKey payload with
311− let blobHex = payload.GetProperty("encryptedBlob").GetString()
345+ | Ok msg ->
312− let tsEl = payload.GetProperty("timestamp")
346+ messages.Add(msg)
314− if tsEl.ValueKind = JsonValueKind.String then
315− Int64.Parse(tsEl.GetString())
318− let blob = Crypto.fromHex blobHex
320− match Crypto.decrypt privKey blob with
321− | Some(plaintext, senderHex) ->
322− let ptDoc = JsonDocument.Parse(plaintext)
323− let ptRoot = ptDoc.RootElement
325− if ptRoot.GetProperty("type").GetString() = "text" then
326− let msgId = ptRoot.GetProperty("id").GetString()
327− let body = ptRoot.GetProperty("body").GetString()
328− messages.Add({ FromPubkey = senderHex; Body = body; Timestamp = ts; MessageId = msgId })
330− ackIds.Add(evtId)
347+ ackIds.Add(evtId)
331− | None ->
348+ | Error err ->
332− errors.Add("decrypt failed")
334− errors.Add($"parse: {ex.Message}")
349+ errors.Add(err)
336 if ackIds.Count > 0 then
351 if ackIds.Count > 0 then
337− do! ApiClient.ackMessages url token (Seq.toList ackIds)
352+ do! ApiClient.ackMessages session.Url session.Token (Seq.toList ackIds)
339 let now = DateTimeOffset.UtcNow.ToString("HH:mm:ss")
354 let now = DateTimeOffset.UtcNow.ToString("HH:mm:ss")
@@ -387,7 +402,7 @@ module App =
388 Label("Your Public Key:")
403 Label("Your Public Key:")
390− Label(model.PubKeyHex)
405+ Label(pubKeyHex model)
391 .font(size = 9.)
406 .font(size = 9.)
393 Button("Copy Public Key", CopyPubKey)
408 Button("Copy Public Key", CopyPubKey)