@@ -29,7 +29,7 @@ module App =
2929 type Page =
3030 | Setup
3131 | Conversations
32− | Chat of string
32+ | Chat of pubkey: string
3333 | AddContact
3434 | Settings
3535
@@ -38,12 +38,15 @@ module App =
3838 | Connecting
3939 | Online of token: string
4040
41+ type Identity = { PrivKey: byte[]; PubKeyHex: string }
42+
43+ type Session = { Url: string; Token: string; Identity: Identity }
44+
4145 // ── Model ──
4246
4347 type Model =
4448 { Page: Page
45− PrivKey: byte[] option
46− PubKeyHex: string
49+ Identity: Identity option
4750 ServerUrl: string
4851 Conn: ConnState
4952 Contacts: Contact list
@@ -61,7 +64,7 @@ module App =
6164 type Msg =
6265 | Nav of Page
6366 | GenIdentity
64− | IdentityReady of byte[] * byte[]
67+ | IdentityReady of Identity
6568 | SetServerUrl of string
6669 | DoConnect
6770 | AuthOk of string
@@ -69,12 +72,12 @@ module App =
6972 | DoDisconnect
7073 | SetCompose of string
7174 | DoSend
72− | Sent of string * string
75+ | Sent of recipient: string * status: string
7376 | SetNewPubkey of string
7477 | SetNewNickname of string
7578 | DoSaveContact
7679 | DoDeleteContact of string
77− | PollResult of IncomingMessage list * string * uint64
80+ | PollResult of messages: IncomingMessage list * status: string * cursor: uint64
7881 | DoPoll
7982 | CopyPubKey
8083 | DismissError
@@ -83,9 +86,9 @@ module App =
8386
8487 type CmdMsg =
8588 | CmdGenIdentity
86− | CmdConnect of url: string * pubHex: string * privKey: byte[]
87− | CmdSend of url: string * token: string * recipientHex: string * privKey: byte[] * text: string
88− | CmdPoll of url: string * token: string * privKey: byte[] * cursor: uint64
89+ | CmdConnect of url: string * identity: Identity
90+ | CmdSend of session: Session * recipientHex: string * text: string
91+ | CmdPoll of session: Session * cursor: uint64
8992 | CmdCopyToClipboard of string
9093
9194 // ── Helpers ──
@@ -112,12 +115,19 @@ module App =
112115 else
113116 contacts @ [ contact ]
114117
118+ let private pubKeyHex model =
119+ model.Identity |> Option.map _.PubKeyHex |> Option.defaultValue ""
120+
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 }
124+ | _ -> None
125+
115126 // ── Init ──
116127
117128 let init () =
118129 { Page = Setup
119− PrivKey = None
120− PubKeyHex = ""
130+ Identity = None
121131 ServerUrl = "http://localhost:8080"
122132 Conn = Offline
123133 PollStatus = ""
@@ -139,26 +149,23 @@ module App =
139149
140150 | GenIdentity -> model, [ CmdGenIdentity ]
141151
142− | IdentityReady(priv, pub) ->
143− { model with
144− PrivKey = Some priv
145− PubKeyHex = Crypto.toHex pub
146− Page = Settings },
147− []
152+ | IdentityReady identity ->
153+ { model with Identity = Some identity; Page = Settings }, []
148154
149155 | SetServerUrl url -> { model with ServerUrl = url }, []
150156
151157 | DoConnect ->
152− match model.PrivKey with
153− | Some priv ->
154− { model with Conn = Connecting }, [ CmdConnect(model.ServerUrl, model.PubKeyHex, priv) ]
158+ match model.Identity with
159+ | Some id ->
160+ { model with Conn = Connecting }, [ CmdConnect(model.ServerUrl, id) ]
155161 | None -> { model with Error = Some "No identity generated" }, []
156162
157163 | AuthOk token ->
158− match model.PrivKey with
159− | Some priv ->
164+ match model.Identity with
165+ | Some id ->
166+ let session = { Url = model.ServerUrl; Token = token; Identity = id }
160167 { model with Conn = Online token; Page = Conversations; Error = None },
161− [ CmdPoll(model.ServerUrl, token, priv, model.PollCursor) ]
168+ [ CmdPoll(session, model.PollCursor) ]
162169 | None ->
163170 { model with Error = Some "No identity generated" }, []
164171
@@ -169,8 +176,8 @@ module App =
169176 | SetCompose text -> { model with ComposeText = text }, []
170177
171178 | DoSend ->
172− match model.Page, model.Conn, model.PrivKey with
173− | Chat pk, Online token, Some priv when model.ComposeText <> "" ->
179+ match model.Page, trySession model with
180+ | Chat pk, Some session when model.ComposeText <> "" ->
174181 let text = model.ComposeText
175182 let outMsg =
176183 { Id = Guid.NewGuid().ToString()
@@ -182,7 +189,7 @@ module App =
182189 { model with
183190 ComposeText = ""
184191 Messages = model.Messages |> Map.add pk (msgs @ [ outMsg ]) },
185− [ CmdSend(model.ServerUrl, token, pk, priv, text) ]
192+ [ CmdSend(session, pk, text) ]
186193 | _ -> model, []
187194
188195 | Sent(_, status) -> { model with LastSendStatus = status }, []
@@ -208,14 +215,14 @@ module App =
208215 Messages = m.Messages |> Map.add msg.FromPubkey (msgs @ [ chatMsg ]) })
209216 { model with PollStatus = status; PollCursor = newCursor }
210217
211− match model'.Conn, model'.PrivKey with
212− | Online token, Some priv -> model', [ CmdPoll(model'.ServerUrl, token, priv, model'.PollCursor) ]
213− | _ -> model', []
218+ match trySession model' with
219+ | Some session -> model', [ CmdPoll(session, model'.PollCursor) ]
220+ | None -> model', []
214221
215222 | DoPoll ->
216− match model.Conn, model.PrivKey with
217− | Online token, Some priv -> model, [ CmdPoll(model.ServerUrl, token, priv, model.PollCursor) ]
218− | _ -> model, []
223+ match trySession model with
224+ | Some session -> model, [ CmdPoll(session, model.PollCursor) ]
225+ | None -> model, []
219226
220227 | SetNewPubkey pk -> { model with NewPubkey = pk }, []
221228 | SetNewNickname nn -> { model with NewNickname = nn }, []
@@ -240,7 +247,10 @@ module App =
240247 Contacts = model.Contacts |> List.filter (fun c -> c.Pubkey <> pk) },
241248 []
242249
243− | CopyPubKey -> model, [ CmdCopyToClipboard model.PubKeyHex ]
250+ | CopyPubKey ->
251+ match model.Identity with
252+ | Some id -> model, [ CmdCopyToClipboard id.PubKeyHex ]
253+ | None -> model, []
244254
245255 | DismissError -> { model with Error = None }, []
246256
@@ -255,20 +265,44 @@ module App =
255265 }))
256266 |> ignore)
257267
268+ let private decryptEvent (privKey: byte[]) (payload: JsonElement) =
269+ try
270+ let blobHex = payload.GetProperty("encryptedBlob").GetString()
271+ let tsEl = payload.GetProperty("timestamp")
272+ let ts =
273+ if tsEl.ValueKind = JsonValueKind.String then Int64.Parse(tsEl.GetString())
274+ else tsEl.GetInt64()
275+ let blob = Crypto.fromHex blobHex
276+
277+ match Crypto.decrypt privKey blob with
278+ | Some(plaintext, senderHex) ->
279+ let ptDoc = JsonDocument.Parse(plaintext)
280+ let ptRoot = ptDoc.RootElement
281+
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 }
286+ else
287+ Error "unknown type"
288+ | None -> Error "decrypt failed"
289+ with ex ->
290+ Error $"parse: {ex.Message}"
291+
258292 let mapCmd cmdMsg =
259293 match cmdMsg with
260294 | CmdGenIdentity ->
261295 asyncCmd (async {
262296 let priv, pub = Crypto.generateIdentity ()
263− return IdentityReady(priv, pub)
297+ return IdentityReady { PrivKey = priv; PubKeyHex = Crypto.toHex pub }
264298 })
265299
266− | CmdConnect(url, pubHex, privKey) ->
300+ | CmdConnect(url, identity) ->
267301 asyncCmd (async {
268302 try
269− let! challenge = ApiClient.requestChallenge url pubHex
270− let sigHex = Crypto.signChallenge privKey challenge
271− let! (token, _) = ApiClient.verify url pubHex challenge sigHex
303+ let! challenge = ApiClient.requestChallenge url identity.PubKeyHex
304+ let sigHex = Crypto.signChallenge identity.PrivKey challenge
305+ let! (token, _) = ApiClient.verify url identity.PubKeyHex challenge sigHex
272306
273307 if String.IsNullOrEmpty(token) then
274308 return AuthErr "Authentication rejected by server"
@@ -278,10 +312,10 @@ module App =
278312 return AuthErr ex.Message
279313 })
280314
281− | CmdSend(url, token, recipientHex, privKey, text) ->
315+ | CmdSend(session, recipientHex, text) ->
282316 asyncCmd (async {
283317 try
284− let pubKey = privKey.[32..63]
318+ let pubKey = session.Identity.PrivKey.[32..63]
285319 let recipPub = Crypto.fromHex recipientHex
286320 let id = Guid.NewGuid().ToString()
287321 let ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
@@ -289,52 +323,33 @@ module App =
289323 let payload =
290324 $"""{{ "type": "text", "id": "{id}", "timestamp": {ts}, "body": {JsonSerializer.Serialize(text)} }}"""
291325
292− let blob = Crypto.encrypt privKey pubKey recipPub payload
326+ let blob = Crypto.encrypt session.Identity.PrivKey pubKey recipPub payload
293327 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
295329 return Sent(recipientHex, $"{status}:{msgId}")
296330 with ex ->
297331 return DismissError // TODO: show send errors properly
298332 })
299333
300− | CmdPoll(url, token, privKey, cursor) ->
334+ | CmdPoll(session, cursor) ->
301335 asyncCmd (async {
302336 try
303− let! (events, newCursor) = ApiClient.poll url token cursor
337+ let! (events, newCursor) = ApiClient.poll session.Url session.Token cursor
304338 let messages = ResizeArray()
305339 let ackIds = ResizeArray()
306340 let errors = ResizeArray()
307341
308342 for (evtId, evtType, payload) in events do
309343 if evtType = "message" then
310− try
311− let blobHex = payload.GetProperty("encryptedBlob").GetString()
312− let tsEl = payload.GetProperty("timestamp")
313− let ts =
314− if tsEl.ValueKind = JsonValueKind.String then
315− Int64.Parse(tsEl.GetString())
316− else
317− tsEl.GetInt64()
318− let blob = Crypto.fromHex blobHex
319−
320− match Crypto.decrypt privKey blob with
321− | Some(plaintext, senderHex) ->
322− let ptDoc = JsonDocument.Parse(plaintext)
323− let ptRoot = ptDoc.RootElement
324−
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 })
329−
330− ackIds.Add(evtId)
331− | None ->
332− errors.Add("decrypt failed")
333− with ex ->
334− errors.Add($"parse: {ex.Message}")
344+ match decryptEvent session.Identity.PrivKey payload with
345+ | Ok msg ->
346+ messages.Add(msg)
347+ ackIds.Add(evtId)
348+ | Error err ->
349+ errors.Add(err)
335350
336351 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)
338353
339354 let now = DateTimeOffset.UtcNow.ToString("HH:mm:ss")
340355
@@ -387,7 +402,7 @@ module App =
387402
388403 Label("Your Public Key:")
389404
390− Label(model.PubKeyHex)
405+ Label(pubKeyHex model)
391406 .font(size = 9.)
392407
393408 Button("Copy Public Key", CopyPubKey)