on master help sign in
Improve server type safety: use variant types for VerifyAuth/AckMessages, clarify LookupPresence fold
commitd854ebf639ac29bd51a1d44c5399687b837f58fc authoriko <mail@iko.soy> dateSat 11 Apr 2026, 09:40 (6 months ago) parent72c9efe
13 files changed+96−84
client/App.fs+42−46 client/Crypto.fs+19−8 client/Store.fs+1−4 client/bin/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.dll client/bin/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.pdb client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.AssemblyInfo.fs+1−1 client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.AssemblyInfoInputs.cache+1−1 client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.dll client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.pdb client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.sourcelink.json+1−1 client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/ref/Skrepka.dll client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/refint/Skrepka.dll server.knot+31−23
client/App.fs+42−46
@@ -40,8 +40,8 @@ module App =
4040 type Page =
4141 | Setup
4242 | Conversations
43− | Chat of pubkey: string * compose: string
44− | AddContact of pubkey: string * nickname: string
43+ | Chat of pubkey: string
44+ | AddContact
4545 | Settings
4646
4747 type Session = { Url: string; Token: string; Identity: Identity }
@@ -75,7 +75,10 @@ module App =
7575 PollStatus: PollStatus
7676 Messages: Map<string, ChatMessage list>
7777 Error: string option
78− PollCursor: int64 }
78+ PollCursor: int64
79+ Compose: string
80+ ContactPubkey: string
81+ ContactNickname: string }
7982
8083 // ── Msg ──
8184
@@ -173,6 +176,11 @@ module App =
173176 let private saveCmdMsg model =
174177 CmdSaveData { Contacts = model.Contacts; Messages = model.Messages; ServerUrl = Some model.ServerUrl; PollCursor = model.PollCursor }
175178
179+ let private connStatusLabel = function
180+ | NoIdentity | Identified(_, Offline) -> "OFFLINE"
181+ | Identified(_, Connecting) -> "CONNECTING"
182+ | Identified(_, Online _) -> "ONLINE"
183+
176184 // ── Init ──
177185
178186 let init () =
@@ -183,31 +191,25 @@ module App =
183191 Contacts = []
184192 Messages = Map.empty
185193 Error = None
186− PollCursor = 0L },
194+ PollCursor = 0L
195+ Compose = ""
196+ ContactPubkey = ""
197+ ContactNickname = "" },
187198 [ CmdLoadState ]
188199
189200 // ── Update ──
190201
191202 let update msg model =
192203 match msg with
193− | SetPage(Chat(pk, _)) ->
194− { model with Page = Chat(pk, "") }, []
195− | SetPage(AddContact _) ->
196− { model with Page = AddContact("", "") }, []
204+ | SetPage(Chat pk) ->
205+ { model with Page = Chat pk; Compose = "" }, []
206+ | SetPage AddContact ->
207+ { model with Page = AddContact; ContactPubkey = ""; ContactNickname = "" }, []
197208 | SetPage page -> { model with Page = page }, []
198209
199− | SetCompose text ->
200− match model.Page with
201− | Chat(pk, _) -> { model with Page = Chat(pk, text) }, []
202− | _ -> model, []
203− | SetContactPubkey pk ->
204− match model.Page with
205− | AddContact(_, nn) -> { model with Page = AddContact(pk, nn) }, []
206− | _ -> model, []
207− | SetContactNickname nn ->
208− match model.Page with
209− | AddContact(pk, _) -> { model with Page = AddContact(pk, nn) }, []
210− | _ -> model, []
210+ | SetCompose text -> { model with Compose = text }, []
211+ | SetContactPubkey pk -> { model with ContactPubkey = pk }, []
212+ | SetContactNickname nn -> { model with ContactNickname = nn }, []
211213
212214 | GenIdentity -> model, [ CmdGenIdentity ]
213215
@@ -237,20 +239,20 @@ module App =
237239
238240 | DoSend ->
239241 match model.Page, trySession model with
240− | Chat(pk, compose), Some session when compose <> "" ->
242+ | Chat pk, Some session when model.Compose <> "" ->
241243 let id = Guid.NewGuid().ToString()
242244 let outMsg =
243245 { Id = id
244− Body = compose
246+ Body = model.Compose
245247 Timestamp = DateTimeOffset.UtcNow
246248 IsOutgoing = true }
247249
248250 let model' =
249251 { model with
250− Page = Chat(pk, "")
252+ Compose = ""
251253 Messages = model.Messages |> appendMessage pk outMsg }
252254
253− model', [ CmdSend(session, pk, compose, id); saveCmdMsg model' ]
255+ model', [ CmdSend(session, pk, model.Compose, id); saveCmdMsg model' ]
254256 | _ -> model, []
255257
256258 | SendFailed err -> { model with Error = Some err }, []
@@ -272,11 +274,10 @@ module App =
272274 if not incoming.IsEmpty then saveCmdMsg model' ]
273275
274276 | DoSaveContact ->
275− match model.Page with
276− | AddContact(pk, nn) when pk <> "" && nn <> "" ->
277− match tryParsePubkey pk with
277+ if model.ContactPubkey <> "" && model.ContactNickname <> "" then
278+ match tryParsePubkey model.ContactPubkey with
278279 | Some bytes ->
279− let contact: Contact = { Pubkey = Crypto.toHex bytes; Nickname = nn }
280+ let contact: Contact = { Pubkey = Crypto.toHex bytes; Nickname = model.ContactNickname }
280281
281282 let model' =
282283 { model with
@@ -284,8 +285,9 @@ module App =
284285 Page = Conversations }
285286
286287 model', [ saveCmdMsg model' ]
287− | _ -> { model with Error = Some "Invalid public key" }, []
288− | _ -> model, []
288+ | None -> { model with Error = Some "Invalid public key" }, []
289+ else
290+ model, []
289291
290292 | CopyPubKey ->
291293 match model.Auth with
@@ -501,12 +503,6 @@ module App =
501503 )
502504
503505 let private viewConversations model =
504− let connStr =
505− match model.Auth with
506− | NoIdentity | Identified(_, Offline) -> "OFFLINE"
507− | Identified(_, Connecting) -> "CONNECTING"
508− | Identified(_, Online _) -> "ONLINE"
509−
510506 ContentPage(
511507 ScrollView(
512508 (VStack(spacing = 8.) {
@@ -514,11 +510,11 @@ module App =
514510 Label("Conversations")
515511 .font(size = 24.)
516512
517− Button("+", SetPage(AddContact("", "")))
513+ Button("+", SetPage AddContact)
518514 Button("Settings", SetPage Settings)
519515 }
520516
521− Label($"{connStr} | {formatPollStatus model.PollStatus}")
517+ Label($"{connStatusLabel model.Auth} | {formatPollStatus model.PollStatus}")
522518 .font(size = 10.)
523519 .textColor(Colors.DimGray)
524520
@@ -536,13 +532,13 @@ module App =
536532 |> Option.map (fun m -> if m.Body.Length > 40 then m.Body.[..39] + "..." else m.Body)
537533 |> Option.defaultValue ""
538534
539− Button($"{c.Nickname}\n{preview}", SetPage(Chat(c.Pubkey, "")))
535+ Button($"{c.Nickname}\n{preview}", SetPage(Chat c.Pubkey))
540536 })
541537 .padding(20.)
542538 )
543539 )
544540
545− let private viewChat model pk compose =
541+ let private viewChat model pk =
546542 let name = contactName model.Contacts pk
547543 let msgs = messagesFor pk model.Messages
548544
@@ -569,7 +565,7 @@ module App =
569565 .font(size = 14.)
570566
571567 HStack(spacing = 8.) {
572− Entry(compose, SetCompose)
568+ Entry(model.Compose, SetCompose)
573569 .placeholder("Message...")
574570
575571 Button("Send", DoSend)
@@ -578,7 +574,7 @@ module App =
578574 .padding(12.)
579575 )
580576
581− let private viewAddContact model pubkey nickname =
577+ let private viewAddContact model =
582578 ContentPage(
583579 (VStack(spacing = 16.) {
584580 HStack(spacing = 8.) {
@@ -591,11 +587,11 @@ module App =
591587 viewErrorBanner model.Error
592588
593589 Label("Public Key:")
594− Entry(pubkey, SetContactPubkey)
590+ Entry(model.ContactPubkey, SetContactPubkey)
595591 .placeholder("sampel-palnet-...")
596592
597593 Label("Nickname:")
598− Entry(nickname, SetContactNickname)
594+ Entry(model.ContactNickname, SetContactNickname)
599595
600596 Button("Save Contact", DoSaveContact)
601597 .centerHorizontal()
@@ -609,8 +605,8 @@ module App =
609605 | Setup -> viewSetup ()
610606 | Settings -> viewSettings model
611607 | Conversations -> viewConversations model
612− | Chat(pk, compose) -> viewChat model pk compose
613− | AddContact(pubkey, nickname) -> viewAddContact model pubkey nickname
608+ | Chat pk -> viewChat model pk
609+ | AddContact -> viewAddContact model
614610
615611 Application() { Window(page) }
616612
client/Crypto.fs+19−8
@@ -19,9 +19,11 @@ module Crypto =
1919 try Some(Convert.FromHexString(hex))
2020 with :? FormatException -> None
2121
22+ let identityFromPrivKey (privKey: byte[]) : Identity =
23+ { PrivKey = privKey; PubKeyHex = toHex privKey.[32..63] }
24+
2225 let generateIdentity () : Identity =
23− let kp = PublicKeyAuth.GenerateKeyPair()
24− { PrivKey = kp.PrivateKey; PubKeyHex = toHex kp.PublicKey }
26+ PublicKeyAuth.GenerateKeyPair().PrivateKey |> identityFromPrivKey
2527
2628 let signChallenge (privKey: byte[]) (challenge: string) : string =
2729 let message = Encoding.UTF8.GetBytes(challenge)
@@ -32,6 +34,15 @@ module Crypto =
3234 let info = Encoding.UTF8.GetBytes("skrepka-v1")
3335 HKDF.DeriveKey(HashAlgorithmName.SHA256, rawSecret, 32, salt, info)
3436
37+ // Blob layout: [ephPub(32) | nonce(24) | ciphertext | senderPub(32) | signature(64)]
38+ let private ephPubLen = 32
39+ let private nonceLen = 24
40+ let private pubLen = 32
41+ let private sigLen = 64
42+ let private headerLen = ephPubLen + nonceLen
43+ let private trailerLen = pubLen + sigLen
44+ let private minBlobLen = headerLen + 16 + trailerLen // 16 = AEAD tag
45+
3546 let encrypt (senderPrivKey: byte[]) (recipientEd25519Pub: byte[]) (plaintext: string) : byte[] =
3647 let senderPubKey = senderPrivKey.[32..63]
3748 let ephKp = PublicKeyBox.GenerateKeyPair()
@@ -46,15 +57,15 @@ module Crypto =
4657 Array.concat [| ephKp.PublicKey; nonce; ciphertext; senderPubKey; signature |]
4758
4859 let decrypt (recipientPrivKey: byte[]) (blob: byte[]) : (string * string) option =
49− if blob.Length < 168 then
60+ if blob.Length < minBlobLen then
5061 None
5162 else
5263 try
53− let ephPub = blob.[0..31]
54− let nonce = blob.[32..55]
55− let senderPub = blob.[blob.Length - 96 .. blob.Length - 65]
56− let signature = blob.[blob.Length - 64 .. blob.Length - 1]
57− let ciphertext = blob.[56 .. blob.Length - 97]
64+ let ephPub = blob.[.. ephPubLen - 1]
65+ let nonce = blob.[ephPubLen .. headerLen - 1]
66+ let ciphertext = blob.[headerLen .. blob.Length - trailerLen - 1]
67+ let senderPub = blob.[blob.Length - trailerLen .. blob.Length - sigLen - 1]
68+ let signature = blob.[blob.Length - sigLen ..]
5869
5970 if not (PublicKeyAuth.VerifyDetached(signature, ciphertext, senderPub)) then
6071 None
client/Store.fs+1−4
@@ -56,10 +56,7 @@ module Store =
5656 return
5757 privKeyB64
5858 |> Option.ofObj
59− |> Option.map (fun b64 ->
60− let privKey = Convert.FromBase64String b64
61− { Crypto.Identity.PrivKey = privKey
62− Crypto.Identity.PubKeyHex = Crypto.toHex privKey.[32..63] })
59+ |> Option.map (Convert.FromBase64String >> Crypto.identityFromPrivKey)
6360 }
6461
6562 let saveIdentity (identity: Crypto.Identity) =
client/bin/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.dll

Binary file not shown.

client/bin/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.pdb

Binary file not shown.

client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.AssemblyInfo.fs+1−1
@@ -10,7 +10,7 @@ open System.Reflection
1010[<assembly: System.Reflection.AssemblyCompanyAttribute("Skrepka")>]
1111[<assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")>]
1212[<assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")>]
13−[<assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0+a5a5e7c5bac92a6f1d566920ab29663bfc3aad5f")>]
13+[<assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0+72c9efec96879f9b4e2a59c12a3c9c558ede81e5")>]
1414[<assembly: System.Reflection.AssemblyProductAttribute("Skrepka")>]
1515[<assembly: System.Reflection.AssemblyTitleAttribute("Skrepka")>]
1616[<assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")>]
client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.AssemblyInfoInputs.cache+1−1
@@ -1 +1 @@
1−ffbdef438cc5faa5721c5d7ba13b119c013565b8f8cc89fcefa7cd626606ad86
1+955045553a061b0c5f6be727d7c20de629d1894d7ab35d3b2e7f6b75b31c2806
client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.dll

Binary file not shown.

client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.pdb

Binary file not shown.

client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.sourcelink.json+1−1
@@ -1 +1 @@
1−{"documents":{"/Users/iko/Developer/skrepka/*":"https://raw.githubusercontent.com/ilyakooo0/skrepka/a5a5e7c5bac92a6f1d566920ab29663bfc3aad5f/*"}}
1+{"documents":{"/Users/iko/Developer/skrepka/*":"https://raw.githubusercontent.com/ilyakooo0/skrepka/72c9efec96879f9b4e2a59c12a3c9c558ede81e5/*"}}
client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/ref/Skrepka.dll

Binary file not shown.

client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/refint/Skrepka.dll

Binary file not shown.

server.knot+31−23
@@ -103,6 +103,8 @@ type ForwardEntry = {messageId: MessageIdHex, toServer: ServerName, createdAt: T
103103type AckedEntry = {messageId: MessageIdHex, toKey: PubkeyHex, ackedAt: Timestamp, originServer: ServerName}
104104type AuthResult = Authorized {pubkey: PubkeyHex} | Unauthorized {}
105105type SendResult = SendFailed {status: MessageStatus} | SendDone {status: MessageStatus, remoteServers: [ServerName], fromKey: PubkeyHex}
106+type VerifyResult = VerifyFailed {} | VerifyOk {peerServers: [ServerName]}
107+type AckResult = AckUnauthorized {} | AckDone {pendingAcks: [AckedEntry], evictedEntries: [AckedEntry]}
106108
107109
108110-- ============================================
@@ -763,7 +765,7 @@ serve = \req -> case req of
763765 let hasMatch = any challenges (\c -> c.pubkey == pubkey && c.challenge == challenge && c.expiresAt > t)
764766 let sigValid = hasMatch && verify (bytesFromHex pubkey) (textToBytes challenge) (bytesFromHex signature)
765767 if not sigValid
766− then yield {pr: pr, verified: False {}, peerServers: []}
768+ then yield {pr: pr, verify: VerifyFailed {}}
767769 else do
768770 set *challenges = do
769771 c <- challenges
@@ -781,17 +783,18 @@ serve = \req -> case req of
781783 let peerServers = if federationEnabled
782784 then collectPeerServers updatedPresence (\p -> p.server)
783785 else []
784− yield {pr: pr, verified: True {}, peerServers: peerServers}
785− let expiredLocal = if result.verified
786− then do p <- result.pr.expiredLocal; where p.pubkey != pubkey; yield p
787− else result.pr.expiredLocal
788− fork (do
789− when (result.verified && federationEnabled)
790− (sendBatchedGossip result.peerServers [{eventType: GossipOnline {}, pubkey: pubkey, server: serverHostN, timestamp: t, ttl: onlineGossipTtl}] 2)
791− broadcastOfflineGossip t result.pr.peerServers expiredLocal)
792− yield (if result.verified
793− then respond {token: token, expiresAt: expiry}
794− else respond {token: "", expiresAt: 0})
786+ yield {pr: pr, verify: VerifyOk {peerServers: peerServers}}
787+ case result.verify of
788+ VerifyFailed {} -> do
789+ forkBroadcastOffline t result.pr
790+ yield (respond {token: "", expiresAt: 0})
791+ VerifyOk {peerServers} -> do
792+ let expiredLocal = do p <- result.pr.expiredLocal; where p.pubkey != pubkey; yield p
793+ fork (do
794+ when federationEnabled
795+ (sendBatchedGossip peerServers [{eventType: GossipOnline {}, pubkey: pubkey, server: serverHostN, timestamp: t, ttl: onlineGossipTtl}] 2)
796+ broadcastOfflineGossip t result.pr.peerServers expiredLocal)
797+ yield (respond {token: token, expiresAt: expiry})
795798
796799 Poll {cursor, timeout, authorization, respond} -> do
797800 t <- now
@@ -865,7 +868,7 @@ serve = \req -> case req of
865868 auth <- authenticateRequest authorization t
866869 case auth of
867870 Unauthorized {} ->
868− yield {pr: pr, ok: False {}, pendingAcks: [], evictedEntries: []}
871+ yield {pr: pr, ack: AckUnauthorized {}}
869872 Authorized {pubkey} -> do
870873 messages <- *messages
871874 let ackedMessages = do
@@ -882,14 +885,19 @@ serve = \req -> case req of
882885 let evictedEntries = let excessCount = count combinedAckedIds - maxAckedIds in
883886 if excessCount <= 0 then [] else boundedSmallest combinedAckedIds excessCount (\a -> a.ackedAt)
884887 set *ackedIds = diff combinedAckedIds evictedEntries
885− yield {pr: pr, ok: True {}, pendingAcks: ackedFederated, evictedEntries: evictedEntries}
886− fork (do
887− when (result.ok && federationEnabled) (do
888− let originServers = collectPeerServers result.pendingAcks (\a -> a.originServer)
889− sendAcksAndClean result.pendingAcks originServers
890− retryEvictedAcks result.evictedEntries result.pendingAcks)
891− broadcastOfflineGossip t result.pr.peerServers result.pr.expiredLocal)
892− yield (respond {ok: result.ok})
888+ yield {pr: pr, ack: AckDone {pendingAcks: ackedFederated, evictedEntries: evictedEntries}}
889+ case result.ack of
890+ AckDone {pendingAcks, evictedEntries} -> do
891+ fork (do
892+ when federationEnabled (do
893+ let originServers = collectPeerServers pendingAcks (\a -> a.originServer)
894+ sendAcksAndClean pendingAcks originServers
895+ retryEvictedAcks evictedEntries pendingAcks)
896+ broadcastOfflineGossip t result.pr.peerServers result.pr.expiredLocal)
897+ yield (respond {ok: True {}})
898+ AckUnauthorized {} -> do
899+ forkBroadcastOffline t result.pr
900+ yield (respond {ok: False {}})
893901
894902 LookupPresence {pubkey, authorization, respond} -> do
895903 t <- now
@@ -906,8 +914,8 @@ serve = \req -> case req of
906914 where p.pubkey == pubkey && p.expiresAt > t
907915 yield p
908916 let localOnline = any valid (\p -> p.server == serverHostN)
909− let bestServer = (fold (\acc p -> if p.expiresAt > acc.best then {server: p.server, best: p.expiresAt} else acc) {server: "", best: 0} valid).server
910− let foundServer = if localOnline then serverHostN else bestServer
917+ let bestRemote = (fold (\acc p -> if p.server != serverHostN && p.expiresAt > acc.best then {server: p.server, best: p.expiresAt} else acc) {server: "", best: 0} valid).server
918+ let foundServer = if localOnline then serverHostN else bestRemote
911919 yield {pr: pr, authorized: True {}, foundServer: foundServer}
912920 forkBroadcastOffline t result.pr
913921 yield (respond {online: result.foundServer != "", server: result.foundServer, authorized: result.authorized})