1module Main exposing (main)
3{-| Skrepka – Decentralized encrypted messaging client.
5 Built with LynxJS-Elm for native iOS/Android.
6 Implements the Skrepka protocol v0.1 for E2E encrypted messaging.
8 Crypto: Ed25519 signing, X25519 key agreement, XChaCha20-Poly1305 AEAD.
9 Uses raw X25519 shared secret as cipher key (HKDF not yet available).
14import Crypto.Cipher as Cipher
15import Crypto.Ed25519 as Ed
16import Crypto.X25519 as X
17import Dict exposing (Dict)
19import Json.Decode as Decode exposing (Decoder)
20import Json.Encode as Encode
22import Lynx.Attributes as Attr
30import Lynx.Events exposing (onInput, onTap)
32import Set exposing (Set)
37-- ============================================================================
39-- ============================================================================
42main : Program () Model Msg
48 , subscriptions = subscriptions
53-- ============================================================================
55-- ============================================================================
59 { edPriv : Maybe Ed.PrivateKey
60 , edPub : Maybe Ed.PublicKey
61 , xPriv : Maybe X.PrivateKey
62 , xPub : Maybe X.PublicKey
66 , contacts : Dict String Contact
67 , messages : Dict String (List ChatMessage)
68 , seenIds : Set String
71 , addIdentity : String
73 , displayName : String
75 , error : Maybe String
92 , lastMessage : String
98type alias ChatMessage =
107-- ============================================================================
109-- ============================================================================
112init : () -> ( Model, Cmd Msg )
118 , serverUrl = "http://localhost:8080"
120 , screen = SetupScreen
121 , contacts = Dict.empty
122 , messages = Dict.empty
123 , seenIds = Set.empty
134 , Random.generate GotSeed (randomHexString 32)
139-- ============================================================================
141-- ============================================================================
149 | GotChallenge (Result Http.Error ChallengeResponse)
150 | GotVerify (Result Http.Error VerifyResponse)
153 | GotSendEntropy String
154 | GotSendResult (Result Http.Error SendResponse)
156 | PollTick Time.Posix
157 | GotPoll (Result Http.Error PollResponse)
158 | GotAck (Result Http.Error Bool)
161 | GotLookup (Result Http.Error LookupResponse)
167 | UpdateServerUrl String
168 | UpdateAddIdentity String
169 | UpdateAddName String
170 | UpdateDisplayName String
176-- ============================================================================
178-- ============================================================================
181update : Msg -> Model -> ( Model, Cmd Msg )
187 Ed.privateKeyFromHex seedHex
190 X.privateKeyFromHex seedHex
194 , edPub = Maybe.map Ed.getPublicKey edPriv
196 , xPub = Maybe.map X.getPublicKey xPriv
202 ( { model | currentTime = Time.posixToMillis posix // 1000 }, Cmd.none )
204 -- AUTH ----------------------------------------------------------------
209 ( { model | connecting = True, error = Nothing }
210 , requestChallenge model.serverUrl (Ed.publicKeyToHex pub)
214 ( { model | error = Just "No identity keypair" }, Cmd.none )
216 GotChallenge result ->
217 case ( result, model.edPriv ) of
218 ( Ok resp, Just priv ) ->
221 Ed.sign resp.challenge priv
224 , verifyAuth model.serverUrl
225 (Maybe.withDefault "" (Maybe.map Ed.publicKeyToHex model.edPub))
227 (Ed.signatureToHex sig)
231 ( { model | connecting = False, error = Just (httpErrorToString err) }
236 ( { model | connecting = False }, Cmd.none )
241 if resp.token /= "" then
243 | authToken = resp.token
245 , screen = ContactsScreen
253 , error = Just "Authentication failed"
259 ( { model | connecting = False, error = Just (httpErrorToString err) }
263 -- MESSAGING -----------------------------------------------------------
266 if String.isEmpty (String.trim model.draft) then
270 ( { model | sending = True }
271 -- 16 bytes msg ID + 32 bytes ephemeral X25519 key + 24 bytes nonce = 72
272 , Random.generate GotSendEntropy (randomHexString 72)
275 GotSendEntropy entropyHex ->
276 case ( model.edPriv, model.edPub ) of
277 ( Just edPriv, Just edPub ) ->
283 Dict.get recipientEdHex model.contacts
289 String.left 32 entropyHex
292 String.slice 32 96 entropyHex
295 String.slice 96 144 entropyHex
303 [ ( "type", Encode.string "text" )
304 , ( "id", Encode.string msgId )
305 , ( "timestamp", Encode.int timestamp )
306 , ( "body", Encode.string model.draft )
311 encryptMessage edPriv edPub c.xPubHex ephSeedHex nonceHex plaintextJson
316 , timestamp = timestamp
321 Dict.get recipientEdHex model.messages
322 |> Maybe.withDefault []
329 Dict.insert recipientEdHex
330 (existing ++ [ outMsg ])
333 , sendMessageHttp model.serverUrl
343 , error = Just "Encryption failed"
349 ( { model | sending = False, error = Just "Contact not found" }
354 ( { model | sending = False, error = Just "No identity" }, Cmd.none )
356 GotSendResult result ->
359 ( { model | sending = False }, Cmd.none )
362 ( { model | sending = False, error = Just (httpErrorToString err) }
366 -- POLLING -------------------------------------------------------------
371 Time.posixToMillis posix // 1000
373 if model.authToken /= "" then
374 ( { model | currentTime = t }
375 , pollMessages model.serverUrl model.authToken
379 ( { model | currentTime = t }, Cmd.none )
382 case ( result, model.xPriv, model.edPub ) of
383 ( Ok resp, Just xPriv, Just edPub ) ->
386 Ed.publicKeyToHex edPub
388 ( newMessages, newSeenIds, ackIds ) =
389 processEvents xPriv myEdHex resp.events model.seenIds
393 (\( from, chatMsg ) dict ->
396 Dict.get from dict |> Maybe.withDefault []
398 Dict.insert from (existing ++ [ chatMsg ]) dict
405 (\( from, chatMsg ) dict ->
412 | lastMessage = chatMsg.body
413 , lastTime = chatMsg.timestamp
420 , name = String.left 8 from ++ "..."
421 , lastMessage = chatMsg.body
422 , lastTime = chatMsg.timestamp
432 if List.isEmpty ackIds then
436 ackMessages model.serverUrl model.authToken ackIds
439 | messages = updatedMessages
440 , seenIds = newSeenIds
441 , contacts = updatedContacts
446 ( Err (Http.BadStatus 401), _, _ ) ->
449 , screen = SetupScreen
450 , error = Just "Session expired, please reconnect"
461 -- CONTACTS ------------------------------------------------------------
464 if String.isEmpty model.addIdentity then
468 case parseIdentity model.addIdentity of
469 Just ( edHex, xHex ) ->
472 if String.isEmpty model.addName then
473 String.left 8 edHex ++ "..."
488 | contacts = Dict.insert edHex contact model.contacts
491 , screen = ContactsScreen
493 , lookupPresence model.serverUrl edHex
497 ( { model | error = Just "Invalid identity. Expected: <ed25519_hex>.<x25519_hex>" }
506 Dict.update resp.pubkey
507 (Maybe.map (\c -> { c | online = resp.online }))
516 -- NAVIGATION ----------------------------------------------------------
519 ( { model | screen = screen }, Cmd.none )
522 ( { model | screen = ChatScreen, activeChat = edPubHex, draft = "" }, Cmd.none )
524 -- INPUT ---------------------------------------------------------------
527 ( { model | draft = text }, Cmd.none )
529 UpdateServerUrl text ->
530 ( { model | serverUrl = text }, Cmd.none )
532 UpdateAddIdentity text ->
533 ( { model | addIdentity = text }, Cmd.none )
535 UpdateAddName text ->
536 ( { model | addName = text }, Cmd.none )
538 UpdateDisplayName text ->
539 ( { model | displayName = text }, Cmd.none )
541 -- ERROR ---------------------------------------------------------------
544 ( { model | error = Nothing }, Cmd.none )
548-- ============================================================================
550-- ============================================================================
553subscriptions : Model -> Sub Msg
556 [ if model.authToken /= "" then
557 Time.every 3000 PollTick
561 , Time.every 60000 GotTime
566-- ============================================================================
568-- ============================================================================
571view : Model -> Lynx.Node Msg
574 [ Attr.flexDirection Column
576 , Attr.backgroundColor "#0d1117"
578 [ case model.error of
584 , case model.screen of
600-- Error Banner ----------------------------------------------------------------
603errorBanner : String -> Lynx.Node Msg
607 , Attr.backgroundColor "#3d1114"
609 , Attr.paddingBottom 10
610 , Attr.paddingLeft 16
611 , Attr.paddingRight 16
612 , Attr.flexDirection Row
613 , Attr.alignItems Center
615 [ Lynx.text [ Attr.color "#f85149", Attr.fontSize 14, Attr.flex 1 ]
616 [ Lynx.textContent err ]
617 , Lynx.text [ Attr.color "#f85149", Attr.fontSize 16 ]
618 [ Lynx.textContent "x" ]
623-- Setup Screen ----------------------------------------------------------------
626viewSetup : Model -> Lynx.Node Msg
630 case ( model.edPub, model.xPub ) of
631 ( Just edP, Just xP ) ->
632 Ed.publicKeyToHex edP ++ "." ++ X.publicKeyToHex xP
638 [ Attr.flexDirection Column
641 , Attr.justifyContent Center
645 , Attr.fontWeight Bold
646 , Attr.color "#58a6ff"
647 , Attr.textAlign TextCenter
648 , Attr.marginBottom 8
650 [ Lynx.textContent "Skrepka" ]
653 , Attr.color "#8b949e"
654 , Attr.textAlign TextCenter
655 , Attr.marginBottom 32
657 [ Lynx.textContent "Encrypted messaging, no accounts" ]
660 , fieldLabel "Your identity (share this with contacts)"
661 , if String.isEmpty identityStr then
663 [ Attr.color "#8b949e"
665 , Attr.marginBottom 24
667 [ Lynx.textContent "Generating keypair..." ]
671 [ Attr.backgroundColor "#161b22"
672 , Attr.borderRadius 8
674 , Attr.marginBottom 24
676 [ Lynx.text [ Attr.color "#c9d1d9", Attr.fontSize 11 ]
677 [ Lynx.textContent identityStr ]
681 , fieldLabel "Relay server"
683 { onInput = UpdateServerUrl
684 , value = model.serverUrl
685 , placeholder = "http://relay.example.com:8080"
690 , fieldLabel "Display name (optional)"
692 { onInput = UpdateDisplayName
693 , value = model.displayName
694 , placeholder = "Anonymous"
699 , if model.connecting then
700 disabledButton "Connecting..."
702 else if model.edPriv == Nothing then
703 disabledButton "Generating keys..."
706 primaryButton Connect "Connect to Server"
711-- Contacts Screen -------------------------------------------------------------
714viewContacts : Model -> Lynx.Node Msg
717 [ Attr.flexDirection Column, Attr.flex 1 ]
720 [ Attr.flexDirection Row
721 , Attr.alignItems Center
723 , Attr.paddingBottom 16
724 , Attr.paddingLeft 20
725 , Attr.paddingRight 20
726 , Attr.backgroundColor "#161b22"
730 , Attr.fontWeight Bold
731 , Attr.color "#c9d1d9"
734 [ Lynx.textContent "Messages" ]
736 [ onTap (Navigate AddContactScreen)
737 , Attr.paddingLeft 12
738 , Attr.paddingRight 4
740 [ Lynx.text [ Attr.color "#58a6ff", Attr.fontSize 28 ]
741 [ Lynx.textContent "+" ]
746 , if Dict.isEmpty model.contacts then
749 , Attr.alignItems Center
750 , Attr.justifyContent Center
754 [ Attr.color "#8b949e"
756 , Attr.textAlign TextCenter
758 [ Lynx.textContent "No conversations yet" ]
761 [ Attr.color "#8b949e"
763 , Attr.textAlign TextCenter
765 [ Lynx.textContent "Tap + to add a contact" ]
770 [ Attr.flex 1, Attr.scrollDirection Vertical ]
771 (Dict.values model.contacts
772 |> List.sortBy (\c -> negate c.lastTime)
773 |> List.map viewContactRow
777 , case model.edPub of
781 , Attr.paddingBottom 10
782 , Attr.paddingLeft 20
783 , Attr.paddingRight 20
784 , Attr.backgroundColor "#161b22"
785 , Attr.flexDirection Row
786 , Attr.alignItems Center
791 , Attr.borderRadius 4
792 , Attr.backgroundColor "#3fb950"
796 , Lynx.text [ Attr.color "#8b949e", Attr.fontSize 11, Attr.flex 1 ]
797 [ Lynx.textContent (truncateKey (Ed.publicKeyToHex pub)) ]
805viewContactRow : Contact -> Lynx.Node Msg
806viewContactRow contact =
808 [ onTap (OpenChat contact.edPubHex)
809 , Attr.flexDirection Row
810 , Attr.alignItems Center
812 , Attr.paddingBottom 14
813 , Attr.paddingLeft 20
814 , Attr.paddingRight 20
815 , Attr.borderColor "#21262d"
822 , Attr.borderRadius 22
823 , Attr.backgroundColor "#30363d"
824 , Attr.alignItems Center
825 , Attr.justifyContent Center
826 , Attr.marginRight 12
828 [ Lynx.text [ Attr.color "#58a6ff", Attr.fontSize 18, Attr.fontWeight Bold ]
829 [ Lynx.textContent (String.left 1 contact.name |> String.toUpper) ]
832 -- Name + last message
833 , Lynx.view [ Attr.flex 1, Attr.flexDirection Column ]
834 [ Lynx.view [ Attr.flexDirection Row, Attr.alignItems Center ]
836 [ Attr.color "#c9d1d9"
838 , Attr.fontWeight Bold
841 [ Lynx.textContent contact.name ]
842 , if contact.online then
846 , Attr.borderRadius 4
847 , Attr.backgroundColor "#3fb950"
855 , if String.isEmpty contact.lastMessage then
860 [ Attr.color "#8b949e"
864 [ Lynx.textContent (String.left 50 contact.lastMessage) ]
870-- Chat Screen -----------------------------------------------------------------
873viewChat : Model -> Lynx.Node Msg
877 Dict.get model.activeChat model.contacts
879 |> Maybe.withDefault (truncateKey model.activeChat)
882 Dict.get model.activeChat model.messages
883 |> Maybe.withDefault []
886 [ Attr.flexDirection Column, Attr.flex 1 ]
889 [ Attr.flexDirection Row
890 , Attr.alignItems Center
892 , Attr.paddingBottom 12
893 , Attr.paddingLeft 16
894 , Attr.paddingRight 16
895 , Attr.backgroundColor "#161b22"
898 [ onTap (Navigate ContactsScreen)
899 , Attr.paddingRight 12
901 , Attr.paddingBottom 4
903 [ Lynx.text [ Attr.color "#58a6ff", Attr.fontSize 20 ]
904 [ Lynx.textContent "<" ]
907 [ Attr.color "#c9d1d9"
909 , Attr.fontWeight Bold
912 [ Lynx.textContent contactName ]
918 , Attr.scrollDirection Vertical
920 , Attr.paddingBottom 12
921 , Attr.paddingLeft 16
922 , Attr.paddingRight 16
924 (if List.isEmpty chatMessages then
926 [ Attr.alignItems Center
930 [ Attr.color "#8b949e"
932 , Attr.textAlign TextCenter
934 [ Lynx.textContent "No messages yet. Say hello!" ]
939 List.map viewMessage chatMessages
944 [ Attr.flexDirection Row
945 , Attr.alignItems Center
947 , Attr.paddingBottom 8
948 , Attr.paddingLeft 12
949 , Attr.paddingRight 12
950 , Attr.backgroundColor "#161b22"
953 [ onInput UpdateDraft
954 , Attr.value model.draft
955 , Attr.placeholder "Message..."
959 , Attr.color "#c9d1d9"
960 , Attr.backgroundColor "#0d1117"
962 , Attr.borderColor "#30363d"
963 , Attr.borderRadius 20
964 , Attr.paddingLeft 16
970 , Attr.backgroundColor
971 (if model.sending || String.isEmpty (String.trim model.draft) then
977 , Attr.borderRadius 20
980 , Attr.alignItems Center
981 , Attr.justifyContent Center
983 [ Lynx.text [ Attr.color "#ffffff", Attr.fontSize 16, Attr.fontWeight Bold ]
984 [ Lynx.textContent "^" ]
990viewMessage : ChatMessage -> Lynx.Node Msg
993 [ Attr.flexDirection Row
994 , Attr.justifyContent
995 (if chatMsg.outgoing then
1001 , Attr.marginBottom 6
1004 [ Attr.backgroundColor
1005 (if chatMsg.outgoing then
1011 , Attr.borderRadius 16
1012 , Attr.paddingTop 10
1013 , Attr.paddingBottom 10
1014 , Attr.paddingLeft 14
1015 , Attr.paddingRight 14
1018 [ Lynx.text [ Attr.color "#c9d1d9", Attr.fontSize 15 ]
1019 [ Lynx.textContent chatMsg.body ]
1025-- Add Contact Screen ----------------------------------------------------------
1028viewAddContact : Model -> Lynx.Node Msg
1029viewAddContact model =
1031 [ Attr.flexDirection Column, Attr.flex 1 ]
1034 [ Attr.flexDirection Row
1035 , Attr.alignItems Center
1036 , Attr.paddingTop 12
1037 , Attr.paddingBottom 12
1038 , Attr.paddingLeft 16
1039 , Attr.paddingRight 16
1040 , Attr.backgroundColor "#161b22"
1043 [ onTap (Navigate ContactsScreen)
1044 , Attr.paddingRight 12
1046 , Attr.paddingBottom 4
1048 [ Lynx.text [ Attr.color "#58a6ff", Attr.fontSize 20 ]
1049 [ Lynx.textContent "<" ]
1052 [ Attr.color "#c9d1d9"
1054 , Attr.fontWeight Bold
1056 [ Lynx.textContent "Add Contact" ]
1061 [ Attr.padding 24, Attr.flexDirection Column ]
1062 [ fieldLabel "Identity string"
1064 [ Attr.color "#6e7681"
1066 , Attr.marginBottom 8
1068 [ Lynx.textContent "Paste the <ed25519>.<x25519> identity from your contact" ]
1070 { onInput = UpdateAddIdentity
1071 , value = model.addIdentity
1072 , placeholder = "abc123...def456.ghi789...jkl012"
1077 { onInput = UpdateAddName
1078 , value = model.addName
1079 , placeholder = "Display name (optional)"
1082 , primaryButton AddContact "Add Contact"
1088-- ============================================================================
1090-- ============================================================================
1093fieldLabel : String -> Lynx.Node msg
1096 [ Attr.color "#8b949e"
1098 , Attr.marginBottom 6
1100 [ Lynx.textContent text ]
1103textInput : { onInput : String -> msg, value : String, placeholder : String } -> Lynx.Node msg
1106 [ onInput config.onInput
1107 , Attr.value config.value
1108 , Attr.placeholder config.placeholder
1111 , Attr.color "#c9d1d9"
1112 , Attr.backgroundColor "#161b22"
1113 , Attr.borderWidth 1
1114 , Attr.borderColor "#30363d"
1115 , Attr.borderRadius 8
1116 , Attr.paddingLeft 12
1121primaryButton : msg -> String -> Lynx.Node msg
1122primaryButton msg text =
1125 , Attr.backgroundColor "#58a6ff"
1126 , Attr.borderRadius 8
1127 , Attr.paddingTop 14
1128 , Attr.paddingBottom 14
1129 , Attr.alignItems Center
1131 [ Lynx.text [ Attr.color "#ffffff", Attr.fontSize 16, Attr.fontWeight Bold ]
1132 [ Lynx.textContent text ]
1136disabledButton : String -> Lynx.Node msg
1137disabledButton text =
1139 [ Attr.backgroundColor "#30363d"
1140 , Attr.borderRadius 8
1141 , Attr.paddingTop 14
1142 , Attr.paddingBottom 14
1143 , Attr.alignItems Center
1145 [ Lynx.text [ Attr.color "#8b949e", Attr.fontSize 16 ]
1146 [ Lynx.textContent text ]
1150spacer : Int -> Lynx.Node msg
1152 Lynx.view [ Attr.height px ] []
1156-- ============================================================================
1158-- ============================================================================
1161requestChallenge : String -> String -> Cmd Msg
1162requestChallenge serverUrl pubkey =
1164 { url = serverUrl ++ "/auth/challenge"
1167 (Encode.object [ ( "pubkey", Encode.string pubkey ) ])
1168 , expect = Http.expectJson GotChallenge challengeDecoder
1172verifyAuth : String -> String -> String -> String -> Cmd Msg
1173verifyAuth serverUrl pubkey challenge signature =
1175 { url = serverUrl ++ "/auth/verify"
1179 [ ( "pubkey", Encode.string pubkey )
1180 , ( "challenge", Encode.string challenge )
1181 , ( "signature", Encode.string signature )
1184 , expect = Http.expectJson GotVerify verifyDecoder
1188pollMessages : String -> String -> Cmd Msg
1189pollMessages serverUrl token =
1192 , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
1193 , url = serverUrl ++ "/poll"
1194 , body = Http.emptyBody
1195 , expect = Http.expectJson GotPoll pollDecoder
1196 , timeout = Just 10000
1201sendMessageHttp : String -> String -> String -> String -> Int -> Cmd Msg
1202sendMessageHttp serverUrl token to encryptedBlob timestamp =
1205 , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
1206 , url = serverUrl ++ "/messages/"
1210 [ ( "to", Encode.string to )
1211 , ( "encryptedBlob", Encode.string encryptedBlob )
1212 , ( "timestamp", Encode.int timestamp )
1215 , expect = Http.expectJson GotSendResult sendDecoder
1216 , timeout = Just 10000
1221ackMessages : String -> String -> List String -> Cmd Msg
1222ackMessages serverUrl token messageIds =
1225 , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
1226 , url = serverUrl ++ "/messages/ack"
1230 [ ( "messageIds", Encode.list Encode.string messageIds ) ]
1232 , expect = Http.expectJson GotAck (Decode.field "ok" Decode.bool)
1233 , timeout = Just 5000
1238lookupPresence : String -> String -> Cmd Msg
1239lookupPresence serverUrl pubkey =
1241 { url = serverUrl ++ "/lookup/" ++ pubkey
1242 , expect = Http.expectJson GotLookup (lookupDecoder pubkey)
1247-- ============================================================================
1249-- ============================================================================
1252{-| Encrypt a message per Skrepka protocol section 3.
1254Uses X25519 ECDH for key agreement and XChaCha20-Poly1305 for encryption.
1255The raw X25519 shared secret is used directly as the cipher key (HKDF
1256not available; shared secret is already 32 uniformly random bytes).
1259ephemeral\_x25519\_pub (64 hex) ++ nonce (48 hex)
1260++ ciphertext (var) ++ sender\_ed25519\_pub (64 hex) ++ signature (128 hex)
1271encryptMessage edPriv edPub recipientXPubHex ephSeedHex nonceHex plaintextJson =
1272 case ( X.privateKeyFromHex ephSeedHex, X.publicKeyFromHex recipientXPubHex, Cipher.nonce24FromHex nonceHex ) of
1273 ( Just ephPriv, Just recipientXPub, Just nonce ) ->
1276 X.getPublicKey ephPriv
1279 X.publicKeyToHex ephPub
1281 -- X25519 key agreement
1283 X.getSharedSecret ephPriv recipientXPub
1285 case Cipher.keyFromHex sharedSecretHex of
1288 -- Encrypt plaintext
1290 textToHex plaintextJson
1293 Cipher.xchacha20Encrypt key nonce plaintextHex
1295 -- Sign the ciphertext
1297 Ed.sign ciphertextHex edPriv
1300 Ed.publicKeyToHex edPub
1303 Ed.signatureToHex sig
1320{-| Decrypt a message per Skrepka protocol section 3.
1321Returns (senderEdPubHex, plaintextJson) on success.
1323decryptMessage : X.PrivateKey -> String -> Maybe ( String, String )
1324decryptMessage xPriv blob =
1329 if hexLen < 304 then
1330 -- Minimum: 64 + 48 + 0 + 64 + 128 = 304 hex chars
1336 String.slice 0 64 blob
1339 String.slice 64 112 blob
1342 String.slice 112 (hexLen - 192) blob
1345 String.slice (hexLen - 192) (hexLen - 128) blob
1348 String.slice (hexLen - 128) hexLen blob
1350 case ( Ed.publicKeyFromHex senderEdPubHex, Ed.signatureFromHex sigHex ) of
1351 ( Just senderEdPub, Just sig ) ->
1352 -- Verify Ed25519 signature on ciphertext
1353 if not (Ed.verify sig ciphertextHex senderEdPub) then
1357 case ( X.publicKeyFromHex ephPubHex, Cipher.nonce24FromHex nonceHex ) of
1358 ( Just ephPub, Just nonce ) ->
1361 X.getSharedSecret xPriv ephPub
1363 case Cipher.keyFromHex sharedSecretHex of
1365 case Cipher.xchacha20Decrypt key nonce ciphertextHex of
1366 Just plaintextHex ->
1367 Just ( senderEdPubHex, hexToText plaintextHex )
1382{-| Process poll events: decrypt, dedup, collect ACK IDs.
1389 -> ( List ( String, ChatMessage ), Set String, List String )
1390processEvents xPriv myEdHex events seenIds =
1392 (\event ( msgs, seen, acks ) ->
1393 if event.eventType /= "message" then
1394 ( msgs, seen, acks )
1397 case Decode.decodeString envelopeDecoder event.payload of
1399 ( msgs, seen, event.id :: acks )
1402 if envelope.fromKey == myEdHex then
1403 -- Skip our own messages
1404 ( msgs, seen, event.id :: acks )
1407 case decryptMessage xPriv envelope.encryptedBlob of
1409 ( msgs, seen, event.id :: acks )
1411 Just ( senderPub, json ) ->
1412 case Decode.decodeString messagePayloadDecoder json of
1414 ( msgs, seen, event.id :: acks )
1417 if Set.member payload.id seen then
1418 ( msgs, seen, event.id :: acks )
1423 , body = payload.body
1424 , timestamp = payload.timestamp
1429 , Set.insert payload.id seen
1435 |> (\( msgs, seen, acks ) -> ( List.reverse msgs, seen, acks ))
1439-- ============================================================================
1441-- ============================================================================
1444type alias ChallengeResponse =
1445 { challenge : String }
1448challengeDecoder : Decoder ChallengeResponse
1450 Decode.map ChallengeResponse
1451 (Decode.field "challenge" Decode.string)
1454type alias VerifyResponse =
1455 { token : String, expiresAt : Int }
1458verifyDecoder : Decoder VerifyResponse
1460 Decode.map2 VerifyResponse
1461 (Decode.field "token" Decode.string)
1462 (Decode.field "expiresAt" Decode.int)
1465type alias PollEvent =
1467 , eventType : String
1472type alias PollResponse =
1473 { events : List PollEvent
1478pollEventDecoder : Decoder PollEvent
1480 Decode.map3 PollEvent
1481 (Decode.field "id" Decode.string)
1482 (Decode.field "eventType" Decode.string)
1483 (Decode.field "payload" Decode.string)
1486pollDecoder : Decoder PollResponse
1488 Decode.map2 PollResponse
1489 (Decode.field "events" (Decode.list pollEventDecoder))
1490 (Decode.field "cursor" Decode.string)
1493type alias Envelope =
1496 , encryptedBlob : String
1501envelopeDecoder : Decoder Envelope
1503 Decode.map4 Envelope
1504 (Decode.field "toKey" Decode.string)
1505 (Decode.field "fromKey" Decode.string)
1506 (Decode.field "encryptedBlob" Decode.string)
1507 (Decode.field "timestamp" Decode.int)
1510type alias SendResponse =
1512 , messageId : String
1516sendDecoder : Decoder SendResponse
1518 Decode.map2 SendResponse
1519 (Decode.field "status" Decode.string)
1520 (Decode.field "messageId" Decode.string)
1523type alias MessagePayload =
1531messagePayloadDecoder : Decoder MessagePayload
1532messagePayloadDecoder =
1533 Decode.map4 MessagePayload
1534 (Decode.field "type" Decode.string)
1535 (Decode.field "id" Decode.string)
1536 (Decode.field "timestamp" Decode.int)
1537 (Decode.field "body" Decode.string)
1540type alias LookupResponse =
1546lookupDecoder : String -> Decoder LookupResponse
1547lookupDecoder pubkey =
1548 Decode.map (LookupResponse pubkey)
1549 (Decode.field "online" Decode.bool)
1553-- ============================================================================
1555-- ============================================================================
1558{-| Parse identity string "ed25519hex.x25519hex" into (edHex, xHex).
1560parseIdentity : String -> Maybe ( String, String )
1562 case String.split "." str of
1564 if String.length edHex == 64 && String.length xHex == 64 then
1565 Just ( edHex, xHex )
1574truncateKey : String -> String
1576 if String.length key > 16 then
1577 String.left 8 key ++ "..." ++ String.right 8 key
1583httpErrorToString : Http.Error -> String
1584httpErrorToString err =
1592 Http.NetworkError ->
1595 Http.BadStatus code ->
1596 "Server error: " ++ String.fromInt code
1598 Http.BadBody msg_ ->
1599 "Bad response: " ++ msg_
1602{-| Convert a UTF-8 string to hex-encoded bytes. Only handles ASCII/Latin-1.
1604textToHex : String -> String
1608 |> List.concatMap charToHexBytes
1612charToHexBytes : Char -> List Char
1619 [ nibbleToHex (code // 16), nibbleToHex (modBy 16 code) ]
1621 else if code < 2048 then
1628 0x80 + modBy 64 code
1630 [ nibbleToHex (b1 // 16)
1631 , nibbleToHex (modBy 16 b1)
1632 , nibbleToHex (b2 // 16)
1633 , nibbleToHex (modBy 16 b2)
1636 else if code < 65536 then
1640 0xE0 + (code // 4096)
1643 0x80 + modBy 64 (code // 64)
1646 0x80 + modBy 64 code
1648 [ nibbleToHex (b1 // 16)
1649 , nibbleToHex (modBy 16 b1)
1650 , nibbleToHex (b2 // 16)
1651 , nibbleToHex (modBy 16 b2)
1652 , nibbleToHex (b3 // 16)
1653 , nibbleToHex (modBy 16 b3)
1657 -- 4-byte UTF-8 (U+10000 and above: emoji, supplementary planes)
1660 0xF0 + (code // 262144)
1663 0x80 + modBy 64 (code // 4096)
1666 0x80 + modBy 64 (code // 64)
1669 0x80 + modBy 64 code
1671 [ nibbleToHex (b1 // 16)
1672 , nibbleToHex (modBy 16 b1)
1673 , nibbleToHex (b2 // 16)
1674 , nibbleToHex (modBy 16 b2)
1675 , nibbleToHex (b3 // 16)
1676 , nibbleToHex (modBy 16 b3)
1677 , nibbleToHex (b4 // 16)
1678 , nibbleToHex (modBy 16 b4)
1682nibbleToHex : Int -> Char
1734{-| Decode hex-encoded UTF-8 bytes back to a string.
1736hexToText : String -> String
1740 |> Maybe.withDefault ""
1743hexToBytes : String -> List Int
1754 Maybe.map2 (\h l -> h * 16 + l) (hexCharToInt hi) (hexCharToInt lo)
1759pairUp : List a -> List ( a, a )
1763 ( a, b ) :: pairUp rest
1769hexCharToInt : Char -> Maybe Int
1775 if code >= 48 && code <= 57 then
1778 else if code >= 97 && code <= 102 then
1781 else if code >= 65 && code <= 70 then
1788decodeUtf8Bytes : List Int -> Maybe String
1789decodeUtf8Bytes bytes =
1790 decodeUtf8Help bytes []
1791 |> Maybe.map (List.reverse >> String.fromList)
1794decodeUtf8Help : List Int -> List Char -> Maybe (List Char)
1795decodeUtf8Help bytes acc =
1802 decodeUtf8Help rest (Char.fromCode b :: acc)
1804 else if b >= 0xC0 && b < 0xE0 then
1809 (b - 0xC0) * 64 + (b2 - 0x80)
1811 decodeUtf8Help rest2 (Char.fromCode code :: acc)
1816 else if b >= 0xE0 && b < 0xF0 then
1818 b2 :: b3 :: rest3 ->
1821 (b - 0xE0) * 4096 + (b2 - 0x80) * 64 + (b3 - 0x80)
1823 decodeUtf8Help rest3 (Char.fromCode code :: acc)
1828 else if b >= 0xF0 then
1830 b2 :: b3 :: b4 :: rest4 ->
1833 (b - 0xF0) * 262144 + (b2 - 0x80) * 4096 + (b3 - 0x80) * 64 + (b4 - 0x80)
1835 decodeUtf8Help rest4 (Char.fromCode code :: acc)
1844{-| Generate a random hex string of the given byte count.
1846randomHexString : Int -> Random.Generator String
1847randomHexString byteCount =
1848 Random.list (byteCount * 2) randomHexChar
1849 |> Random.map String.fromList
1852randomHexChar : Random.Generator Char
1857 Char.fromCode (n + 48)
1860 Char.fromCode (n - 10 + 97)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list