on eeea40e help sign in
skrepka/client/src/Main.elm
1862 lines 55 kB history raw
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).
11-}
13import Browser
14import Crypto.Cipher as Cipher
15import Crypto.Ed25519 as Ed
16import Crypto.X25519 as X
17import Dict exposing (Dict)
18import Http
19import Json.Decode as Decode exposing (Decoder)
20import Json.Encode as Encode
21import Lynx
22import Lynx.Attributes as Attr
23 exposing
24 ( Alignment(..)
25 , FlexDirection(..)
26 , FontWeight(..)
27 , ScrollDirection(..)
28 , TextAlign(..)
29 )
30import Lynx.Events exposing (onInput, onTap)
31import Random
32import Set exposing (Set)
33import Time
37-- ============================================================================
38-- MAIN
39-- ============================================================================
42main : Program () Model Msg
43main =
44 Browser.element
45 { init = init
46 , view = view
47 , update = update
48 , subscriptions = subscriptions
49 }
53-- ============================================================================
54-- MODEL
55-- ============================================================================
58type alias Model =
59 { edPriv : Maybe Ed.PrivateKey
60 , edPub : Maybe Ed.PublicKey
61 , xPriv : Maybe X.PrivateKey
62 , xPub : Maybe X.PublicKey
63 , serverUrl : String
64 , authToken : String
65 , screen : Screen
66 , contacts : Dict String Contact
67 , messages : Dict String (List ChatMessage)
68 , seenIds : Set String
69 , draft : String
70 , activeChat : String
71 , addIdentity : String
72 , addName : String
73 , displayName : String
74 , currentTime : Int
75 , error : Maybe String
76 , connecting : Bool
77 , sending : Bool
78 }
81type Screen
82 = SetupScreen
83 | ContactsScreen
84 | ChatScreen
85 | AddContactScreen
88type alias Contact =
89 { edPubHex : String
90 , xPubHex : String
91 , name : String
92 , lastMessage : String
93 , lastTime : Int
94 , online : Bool
95 }
98type alias ChatMessage =
99 { id : String
100 , body : String
101 , timestamp : Int
102 , outgoing : Bool
103 }
107-- ============================================================================
108-- INIT
109-- ============================================================================
112init : () -> ( Model, Cmd Msg )
113init _ =
114 ( { edPriv = Nothing
115 , edPub = Nothing
116 , xPriv = Nothing
117 , xPub = Nothing
118 , serverUrl = "http://localhost:8080"
119 , authToken = ""
120 , screen = SetupScreen
121 , contacts = Dict.empty
122 , messages = Dict.empty
123 , seenIds = Set.empty
124 , draft = ""
125 , activeChat = ""
126 , addIdentity = ""
127 , addName = ""
128 , displayName = ""
129 , currentTime = 0
130 , error = Nothing
131 , connecting = False
132 , sending = False
133 }
134 , Random.generate GotSeed (randomHexString 32)
135 )
139-- ============================================================================
140-- MSG
141-- ============================================================================
144type Msg
145 = GotSeed String
146 | GotTime Time.Posix
147 -- Auth
148 | Connect
149 | GotChallenge (Result Http.Error ChallengeResponse)
150 | GotVerify (Result Http.Error VerifyResponse)
151 -- Messaging
152 | PrepareSend
153 | GotSendEntropy String
154 | GotSendResult (Result Http.Error SendResponse)
155 -- Polling
156 | PollTick Time.Posix
157 | GotPoll (Result Http.Error PollResponse)
158 | GotAck (Result Http.Error Bool)
159 -- Contacts
160 | AddContact
161 | GotLookup (Result Http.Error LookupResponse)
162 -- Navigation
163 | Navigate Screen
164 | OpenChat String
165 -- Input
166 | UpdateDraft String
167 | UpdateServerUrl String
168 | UpdateAddIdentity String
169 | UpdateAddName String
170 | UpdateDisplayName String
171 -- Error
172 | DismissError
176-- ============================================================================
177-- UPDATE
178-- ============================================================================
181update : Msg -> Model -> ( Model, Cmd Msg )
182update msg model =
183 case msg of
184 GotSeed seedHex ->
185 let
186 edPriv =
187 Ed.privateKeyFromHex seedHex
189 xPriv =
190 X.privateKeyFromHex seedHex
191 in
192 ( { model
193 | edPriv = edPriv
194 , edPub = Maybe.map Ed.getPublicKey edPriv
195 , xPriv = xPriv
196 , xPub = Maybe.map X.getPublicKey xPriv
197 }
198 , Cmd.none
199 )
201 GotTime posix ->
202 ( { model | currentTime = Time.posixToMillis posix // 1000 }, Cmd.none )
204 -- AUTH ----------------------------------------------------------------
206 Connect ->
207 case model.edPub of
208 Just pub ->
209 ( { model | connecting = True, error = Nothing }
210 , requestChallenge model.serverUrl (Ed.publicKeyToHex pub)
211 )
213 Nothing ->
214 ( { model | error = Just "No identity keypair" }, Cmd.none )
216 GotChallenge result ->
217 case ( result, model.edPriv ) of
218 ( Ok resp, Just priv ) ->
219 let
220 sig =
221 Ed.sign resp.challenge priv
222 in
223 ( model
224 , verifyAuth model.serverUrl
225 (Maybe.withDefault "" (Maybe.map Ed.publicKeyToHex model.edPub))
226 resp.challenge
227 (Ed.signatureToHex sig)
228 )
230 ( Err err, _ ) ->
231 ( { model | connecting = False, error = Just (httpErrorToString err) }
232 , Cmd.none
233 )
235 _ ->
236 ( { model | connecting = False }, Cmd.none )
238 GotVerify result ->
239 case result of
240 Ok resp ->
241 if resp.token /= "" then
242 ( { model
243 | authToken = resp.token
244 , connecting = False
245 , screen = ContactsScreen
246 }
247 , Cmd.none
248 )
250 else
251 ( { model
252 | connecting = False
253 , error = Just "Authentication failed"
254 }
255 , Cmd.none
256 )
258 Err err ->
259 ( { model | connecting = False, error = Just (httpErrorToString err) }
260 , Cmd.none
261 )
263 -- MESSAGING -----------------------------------------------------------
265 PrepareSend ->
266 if String.isEmpty (String.trim model.draft) then
267 ( model, Cmd.none )
269 else
270 ( { model | sending = True }
271 -- 16 bytes msg ID + 32 bytes ephemeral X25519 key + 24 bytes nonce = 72
272 , Random.generate GotSendEntropy (randomHexString 72)
273 )
275 GotSendEntropy entropyHex ->
276 case ( model.edPriv, model.edPub ) of
277 ( Just edPriv, Just edPub ) ->
278 let
279 recipientEdHex =
280 model.activeChat
282 contact =
283 Dict.get recipientEdHex model.contacts
284 in
285 case contact of
286 Just c ->
287 let
288 msgId =
289 String.left 32 entropyHex
291 ephSeedHex =
292 String.slice 32 96 entropyHex
294 nonceHex =
295 String.slice 96 144 entropyHex
297 timestamp =
298 model.currentTime
300 plaintextJson =
301 Encode.encode 0
302 (Encode.object
303 [ ( "type", Encode.string "text" )
304 , ( "id", Encode.string msgId )
305 , ( "timestamp", Encode.int timestamp )
306 , ( "body", Encode.string model.draft )
307 ]
308 )
310 blob =
311 encryptMessage edPriv edPub c.xPubHex ephSeedHex nonceHex plaintextJson
313 outMsg =
314 { id = msgId
315 , body = model.draft
316 , timestamp = timestamp
317 , outgoing = True
318 }
320 existing =
321 Dict.get recipientEdHex model.messages
322 |> Maybe.withDefault []
323 in
324 case blob of
325 Just blobHex ->
326 ( { model
327 | draft = ""
328 , messages =
329 Dict.insert recipientEdHex
330 (existing ++ [ outMsg ])
331 model.messages
332 }
333 , sendMessageHttp model.serverUrl
334 model.authToken
335 recipientEdHex
336 blobHex
337 timestamp
338 )
340 Nothing ->
341 ( { model
342 | sending = False
343 , error = Just "Encryption failed"
344 }
345 , Cmd.none
346 )
348 Nothing ->
349 ( { model | sending = False, error = Just "Contact not found" }
350 , Cmd.none
351 )
353 _ ->
354 ( { model | sending = False, error = Just "No identity" }, Cmd.none )
356 GotSendResult result ->
357 case result of
358 Ok _ ->
359 ( { model | sending = False }, Cmd.none )
361 Err err ->
362 ( { model | sending = False, error = Just (httpErrorToString err) }
363 , Cmd.none
364 )
366 -- POLLING -------------------------------------------------------------
368 PollTick posix ->
369 let
370 t =
371 Time.posixToMillis posix // 1000
372 in
373 if model.authToken /= "" then
374 ( { model | currentTime = t }
375 , pollMessages model.serverUrl model.authToken
376 )
378 else
379 ( { model | currentTime = t }, Cmd.none )
381 GotPoll result ->
382 case ( result, model.xPriv, model.edPub ) of
383 ( Ok resp, Just xPriv, Just edPub ) ->
384 let
385 myEdHex =
386 Ed.publicKeyToHex edPub
388 ( newMessages, newSeenIds, ackIds ) =
389 processEvents xPriv myEdHex resp.events model.seenIds
391 updatedMessages =
392 List.foldl
393 (\( from, chatMsg ) dict ->
394 let
395 existing =
396 Dict.get from dict |> Maybe.withDefault []
397 in
398 Dict.insert from (existing ++ [ chatMsg ]) dict
399 )
400 model.messages
401 newMessages
403 updatedContacts =
404 List.foldl
405 (\( from, chatMsg ) dict ->
406 Dict.update from
407 (\existing ->
408 case existing of
409 Just c ->
410 Just
411 { c
412 | lastMessage = chatMsg.body
413 , lastTime = chatMsg.timestamp
414 }
416 Nothing ->
417 Just
418 { edPubHex = from
419 , xPubHex = ""
420 , name = String.left 8 from ++ "..."
421 , lastMessage = chatMsg.body
422 , lastTime = chatMsg.timestamp
423 , online = True
424 }
425 )
426 dict
427 )
428 model.contacts
429 newMessages
431 ackCmd =
432 if List.isEmpty ackIds then
433 Cmd.none
435 else
436 ackMessages model.serverUrl model.authToken ackIds
437 in
438 ( { model
439 | messages = updatedMessages
440 , seenIds = newSeenIds
441 , contacts = updatedContacts
442 }
443 , ackCmd
444 )
446 ( Err (Http.BadStatus 401), _, _ ) ->
447 ( { model
448 | authToken = ""
449 , screen = SetupScreen
450 , error = Just "Session expired, please reconnect"
451 }
452 , Cmd.none
453 )
455 _ ->
456 ( model, Cmd.none )
458 GotAck _ ->
459 ( model, Cmd.none )
461 -- CONTACTS ------------------------------------------------------------
463 AddContact ->
464 if String.isEmpty model.addIdentity then
465 ( model, Cmd.none )
467 else
468 case parseIdentity model.addIdentity of
469 Just ( edHex, xHex ) ->
470 let
471 name =
472 if String.isEmpty model.addName then
473 String.left 8 edHex ++ "..."
475 else
476 model.addName
478 contact =
479 { edPubHex = edHex
480 , xPubHex = xHex
481 , name = name
482 , lastMessage = ""
483 , lastTime = 0
484 , online = False
485 }
486 in
487 ( { model
488 | contacts = Dict.insert edHex contact model.contacts
489 , addIdentity = ""
490 , addName = ""
491 , screen = ContactsScreen
492 }
493 , lookupPresence model.serverUrl edHex
494 )
496 Nothing ->
497 ( { model | error = Just "Invalid identity. Expected: <ed25519_hex>.<x25519_hex>" }
498 , Cmd.none
499 )
501 GotLookup result ->
502 case result of
503 Ok resp ->
504 ( { model
505 | contacts =
506 Dict.update resp.pubkey
507 (Maybe.map (\c -> { c | online = resp.online }))
508 model.contacts
509 }
510 , Cmd.none
511 )
513 Err _ ->
514 ( model, Cmd.none )
516 -- NAVIGATION ----------------------------------------------------------
518 Navigate screen ->
519 ( { model | screen = screen }, Cmd.none )
521 OpenChat edPubHex ->
522 ( { model | screen = ChatScreen, activeChat = edPubHex, draft = "" }, Cmd.none )
524 -- INPUT ---------------------------------------------------------------
526 UpdateDraft text ->
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 ---------------------------------------------------------------
543 DismissError ->
544 ( { model | error = Nothing }, Cmd.none )
548-- ============================================================================
549-- SUBSCRIPTIONS
550-- ============================================================================
553subscriptions : Model -> Sub Msg
554subscriptions model =
555 Sub.batch
556 [ if model.authToken /= "" then
557 Time.every 3000 PollTick
559 else
560 Sub.none
561 , Time.every 60000 GotTime
562 ]
566-- ============================================================================
567-- VIEW
568-- ============================================================================
571view : Model -> Lynx.Node Msg
572view model =
573 Lynx.view
574 [ Attr.flexDirection Column
575 , Attr.flex 1
576 , Attr.backgroundColor "#0d1117"
577 ]
578 [ case model.error of
579 Just err ->
580 errorBanner err
582 Nothing ->
583 Lynx.view [] []
584 , case model.screen of
585 SetupScreen ->
586 viewSetup model
588 ContactsScreen ->
589 viewContacts model
591 ChatScreen ->
592 viewChat model
594 AddContactScreen ->
595 viewAddContact model
596 ]
600-- Error Banner ----------------------------------------------------------------
603errorBanner : String -> Lynx.Node Msg
604errorBanner err =
605 Lynx.view
606 [ onTap DismissError
607 , Attr.backgroundColor "#3d1114"
608 , Attr.paddingTop 10
609 , Attr.paddingBottom 10
610 , Attr.paddingLeft 16
611 , Attr.paddingRight 16
612 , Attr.flexDirection Row
613 , Attr.alignItems Center
614 ]
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" ]
619 ]
623-- Setup Screen ----------------------------------------------------------------
626viewSetup : Model -> Lynx.Node Msg
627viewSetup model =
628 let
629 identityStr =
630 case ( model.edPub, model.xPub ) of
631 ( Just edP, Just xP ) ->
632 Ed.publicKeyToHex edP ++ "." ++ X.publicKeyToHex xP
634 _ ->
635 ""
636 in
637 Lynx.view
638 [ Attr.flexDirection Column
639 , Attr.flex 1
640 , Attr.padding 24
641 , Attr.justifyContent Center
642 ]
643 [ Lynx.text
644 [ Attr.fontSize 36
645 , Attr.fontWeight Bold
646 , Attr.color "#58a6ff"
647 , Attr.textAlign TextCenter
648 , Attr.marginBottom 8
649 ]
650 [ Lynx.textContent "Skrepka" ]
651 , Lynx.text
652 [ Attr.fontSize 14
653 , Attr.color "#8b949e"
654 , Attr.textAlign TextCenter
655 , Attr.marginBottom 32
656 ]
657 [ Lynx.textContent "Encrypted messaging, no accounts" ]
659 -- Identity
660 , fieldLabel "Your identity (share this with contacts)"
661 , if String.isEmpty identityStr then
662 Lynx.text
663 [ Attr.color "#8b949e"
664 , Attr.fontSize 14
665 , Attr.marginBottom 24
666 ]
667 [ Lynx.textContent "Generating keypair..." ]
669 else
670 Lynx.view
671 [ Attr.backgroundColor "#161b22"
672 , Attr.borderRadius 8
673 , Attr.padding 12
674 , Attr.marginBottom 24
675 ]
676 [ Lynx.text [ Attr.color "#c9d1d9", Attr.fontSize 11 ]
677 [ Lynx.textContent identityStr ]
678 ]
680 -- Server URL
681 , fieldLabel "Relay server"
682 , textInput
683 { onInput = UpdateServerUrl
684 , value = model.serverUrl
685 , placeholder = "http://relay.example.com:8080"
686 }
687 , spacer 24
689 -- Display name
690 , fieldLabel "Display name (optional)"
691 , textInput
692 { onInput = UpdateDisplayName
693 , value = model.displayName
694 , placeholder = "Anonymous"
695 }
696 , spacer 32
698 -- Connect button
699 , if model.connecting then
700 disabledButton "Connecting..."
702 else if model.edPriv == Nothing then
703 disabledButton "Generating keys..."
705 else
706 primaryButton Connect "Connect to Server"
707 ]
711-- Contacts Screen -------------------------------------------------------------
714viewContacts : Model -> Lynx.Node Msg
715viewContacts model =
716 Lynx.view
717 [ Attr.flexDirection Column, Attr.flex 1 ]
718 [ -- Header
719 Lynx.view
720 [ Attr.flexDirection Row
721 , Attr.alignItems Center
722 , Attr.paddingTop 16
723 , Attr.paddingBottom 16
724 , Attr.paddingLeft 20
725 , Attr.paddingRight 20
726 , Attr.backgroundColor "#161b22"
727 ]
728 [ Lynx.text
729 [ Attr.fontSize 20
730 , Attr.fontWeight Bold
731 , Attr.color "#c9d1d9"
732 , Attr.flex 1
733 ]
734 [ Lynx.textContent "Messages" ]
735 , Lynx.view
736 [ onTap (Navigate AddContactScreen)
737 , Attr.paddingLeft 12
738 , Attr.paddingRight 4
739 ]
740 [ Lynx.text [ Attr.color "#58a6ff", Attr.fontSize 28 ]
741 [ Lynx.textContent "+" ]
742 ]
743 ]
745 -- Conversation list
746 , if Dict.isEmpty model.contacts then
747 Lynx.view
748 [ Attr.flex 1
749 , Attr.alignItems Center
750 , Attr.justifyContent Center
751 , Attr.padding 40
752 ]
753 [ Lynx.text
754 [ Attr.color "#8b949e"
755 , Attr.fontSize 16
756 , Attr.textAlign TextCenter
757 ]
758 [ Lynx.textContent "No conversations yet" ]
759 , spacer 8
760 , Lynx.text
761 [ Attr.color "#8b949e"
762 , Attr.fontSize 14
763 , Attr.textAlign TextCenter
764 ]
765 [ Lynx.textContent "Tap + to add a contact" ]
766 ]
768 else
769 Lynx.scrollView
770 [ Attr.flex 1, Attr.scrollDirection Vertical ]
771 (Dict.values model.contacts
772 |> List.sortBy (\c -> negate c.lastTime)
773 |> List.map viewContactRow
774 )
776 -- Identity bar
777 , case model.edPub of
778 Just pub ->
779 Lynx.view
780 [ Attr.paddingTop 10
781 , Attr.paddingBottom 10
782 , Attr.paddingLeft 20
783 , Attr.paddingRight 20
784 , Attr.backgroundColor "#161b22"
785 , Attr.flexDirection Row
786 , Attr.alignItems Center
787 ]
788 [ Lynx.view
789 [ Attr.width 8
790 , Attr.height 8
791 , Attr.borderRadius 4
792 , Attr.backgroundColor "#3fb950"
793 , Attr.marginRight 8
794 ]
795 []
796 , Lynx.text [ Attr.color "#8b949e", Attr.fontSize 11, Attr.flex 1 ]
797 [ Lynx.textContent (truncateKey (Ed.publicKeyToHex pub)) ]
798 ]
800 Nothing ->
801 Lynx.view [] []
802 ]
805viewContactRow : Contact -> Lynx.Node Msg
806viewContactRow contact =
807 Lynx.view
808 [ onTap (OpenChat contact.edPubHex)
809 , Attr.flexDirection Row
810 , Attr.alignItems Center
811 , Attr.paddingTop 14
812 , Attr.paddingBottom 14
813 , Attr.paddingLeft 20
814 , Attr.paddingRight 20
815 , Attr.borderColor "#21262d"
816 , Attr.borderWidth 1
817 ]
818 [ -- Avatar
819 Lynx.view
820 [ Attr.width 44
821 , Attr.height 44
822 , Attr.borderRadius 22
823 , Attr.backgroundColor "#30363d"
824 , Attr.alignItems Center
825 , Attr.justifyContent Center
826 , Attr.marginRight 12
827 ]
828 [ Lynx.text [ Attr.color "#58a6ff", Attr.fontSize 18, Attr.fontWeight Bold ]
829 [ Lynx.textContent (String.left 1 contact.name |> String.toUpper) ]
830 ]
832 -- Name + last message
833 , Lynx.view [ Attr.flex 1, Attr.flexDirection Column ]
834 [ Lynx.view [ Attr.flexDirection Row, Attr.alignItems Center ]
835 [ Lynx.text
836 [ Attr.color "#c9d1d9"
837 , Attr.fontSize 16
838 , Attr.fontWeight Bold
839 , Attr.flex 1
840 ]
841 [ Lynx.textContent contact.name ]
842 , if contact.online then
843 Lynx.view
844 [ Attr.width 8
845 , Attr.height 8
846 , Attr.borderRadius 4
847 , Attr.backgroundColor "#3fb950"
848 , Attr.marginLeft 8
849 ]
850 []
852 else
853 Lynx.view [] []
854 ]
855 , if String.isEmpty contact.lastMessage then
856 Lynx.view [] []
858 else
859 Lynx.text
860 [ Attr.color "#8b949e"
861 , Attr.fontSize 14
862 , Attr.marginTop 2
863 ]
864 [ Lynx.textContent (String.left 50 contact.lastMessage) ]
865 ]
866 ]
870-- Chat Screen -----------------------------------------------------------------
873viewChat : Model -> Lynx.Node Msg
874viewChat model =
875 let
876 contactName =
877 Dict.get model.activeChat model.contacts
878 |> Maybe.map .name
879 |> Maybe.withDefault (truncateKey model.activeChat)
881 chatMessages =
882 Dict.get model.activeChat model.messages
883 |> Maybe.withDefault []
884 in
885 Lynx.view
886 [ Attr.flexDirection Column, Attr.flex 1 ]
887 [ -- Header
888 Lynx.view
889 [ Attr.flexDirection Row
890 , Attr.alignItems Center
891 , Attr.paddingTop 12
892 , Attr.paddingBottom 12
893 , Attr.paddingLeft 16
894 , Attr.paddingRight 16
895 , Attr.backgroundColor "#161b22"
896 ]
897 [ Lynx.view
898 [ onTap (Navigate ContactsScreen)
899 , Attr.paddingRight 12
900 , Attr.paddingTop 4
901 , Attr.paddingBottom 4
902 ]
903 [ Lynx.text [ Attr.color "#58a6ff", Attr.fontSize 20 ]
904 [ Lynx.textContent "<" ]
905 ]
906 , Lynx.text
907 [ Attr.color "#c9d1d9"
908 , Attr.fontSize 18
909 , Attr.fontWeight Bold
910 , Attr.flex 1
911 ]
912 [ Lynx.textContent contactName ]
913 ]
915 -- Messages
916 , Lynx.scrollView
917 [ Attr.flex 1
918 , Attr.scrollDirection Vertical
919 , Attr.paddingTop 12
920 , Attr.paddingBottom 12
921 , Attr.paddingLeft 16
922 , Attr.paddingRight 16
923 ]
924 (if List.isEmpty chatMessages then
925 [ Lynx.view
926 [ Attr.alignItems Center
927 , Attr.paddingTop 80
928 ]
929 [ Lynx.text
930 [ Attr.color "#8b949e"
931 , Attr.fontSize 14
932 , Attr.textAlign TextCenter
933 ]
934 [ Lynx.textContent "No messages yet. Say hello!" ]
935 ]
936 ]
938 else
939 List.map viewMessage chatMessages
940 )
942 -- Input bar
943 , Lynx.view
944 [ Attr.flexDirection Row
945 , Attr.alignItems Center
946 , Attr.paddingTop 8
947 , Attr.paddingBottom 8
948 , Attr.paddingLeft 12
949 , Attr.paddingRight 12
950 , Attr.backgroundColor "#161b22"
951 ]
952 [ Lynx.input
953 [ onInput UpdateDraft
954 , Attr.value model.draft
955 , Attr.placeholder "Message..."
956 , Attr.flex 1
957 , Attr.height 40
958 , Attr.fontSize 16
959 , Attr.color "#c9d1d9"
960 , Attr.backgroundColor "#0d1117"
961 , Attr.borderWidth 1
962 , Attr.borderColor "#30363d"
963 , Attr.borderRadius 20
964 , Attr.paddingLeft 16
965 ]
966 []
967 , Lynx.view
968 [ onTap PrepareSend
969 , Attr.marginLeft 8
970 , Attr.backgroundColor
971 (if model.sending || String.isEmpty (String.trim model.draft) then
972 "#30363d"
974 else
975 "#58a6ff"
976 )
977 , Attr.borderRadius 20
978 , Attr.width 40
979 , Attr.height 40
980 , Attr.alignItems Center
981 , Attr.justifyContent Center
982 ]
983 [ Lynx.text [ Attr.color "#ffffff", Attr.fontSize 16, Attr.fontWeight Bold ]
984 [ Lynx.textContent "^" ]
985 ]
986 ]
987 ]
990viewMessage : ChatMessage -> Lynx.Node Msg
991viewMessage chatMsg =
992 Lynx.view
993 [ Attr.flexDirection Row
994 , Attr.justifyContent
995 (if chatMsg.outgoing then
996 End
998 else
999 Start
1001 , Attr.marginBottom 6
1003 [ Lynx.view
1004 [ Attr.backgroundColor
1005 (if chatMsg.outgoing then
1006 "#1a3a5c"
1008 else
1009 "#21262d"
1011 , Attr.borderRadius 16
1012 , Attr.paddingTop 10
1013 , Attr.paddingBottom 10
1014 , Attr.paddingLeft 14
1015 , Attr.paddingRight 14
1016 , Attr.maxWidth 280
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 =
1030 Lynx.view
1031 [ Attr.flexDirection Column, Attr.flex 1 ]
1032 [ -- Header
1033 Lynx.view
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"
1042 [ Lynx.view
1043 [ onTap (Navigate ContactsScreen)
1044 , Attr.paddingRight 12
1045 , Attr.paddingTop 4
1046 , Attr.paddingBottom 4
1048 [ Lynx.text [ Attr.color "#58a6ff", Attr.fontSize 20 ]
1049 [ Lynx.textContent "<" ]
1051 , Lynx.text
1052 [ Attr.color "#c9d1d9"
1053 , Attr.fontSize 18
1054 , Attr.fontWeight Bold
1056 [ Lynx.textContent "Add Contact" ]
1059 -- Form
1060 , Lynx.view
1061 [ Attr.padding 24, Attr.flexDirection Column ]
1062 [ fieldLabel "Identity string"
1063 , Lynx.text
1064 [ Attr.color "#6e7681"
1065 , Attr.fontSize 12
1066 , Attr.marginBottom 8
1068 [ Lynx.textContent "Paste the <ed25519>.<x25519> identity from your contact" ]
1069 , textInput
1070 { onInput = UpdateAddIdentity
1071 , value = model.addIdentity
1072 , placeholder = "abc123...def456.ghi789...jkl012"
1074 , spacer 20
1075 , fieldLabel "Name"
1076 , textInput
1077 { onInput = UpdateAddName
1078 , value = model.addName
1079 , placeholder = "Display name (optional)"
1081 , spacer 32
1082 , primaryButton AddContact "Add Contact"
1088-- ============================================================================
1089-- UI COMPONENTS
1090-- ============================================================================
1093fieldLabel : String -> Lynx.Node msg
1094fieldLabel text =
1095 Lynx.text
1096 [ Attr.color "#8b949e"
1097 , Attr.fontSize 13
1098 , Attr.marginBottom 6
1100 [ Lynx.textContent text ]
1103textInput : { onInput : String -> msg, value : String, placeholder : String } -> Lynx.Node msg
1104textInput config =
1105 Lynx.input
1106 [ onInput config.onInput
1107 , Attr.value config.value
1108 , Attr.placeholder config.placeholder
1109 , Attr.height 44
1110 , Attr.fontSize 16
1111 , Attr.color "#c9d1d9"
1112 , Attr.backgroundColor "#161b22"
1113 , Attr.borderWidth 1
1114 , Attr.borderColor "#30363d"
1115 , Attr.borderRadius 8
1116 , Attr.paddingLeft 12
1118 []
1121primaryButton : msg -> String -> Lynx.Node msg
1122primaryButton msg text =
1123 Lynx.view
1124 [ onTap msg
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 =
1138 Lynx.view
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
1151spacer px =
1152 Lynx.view [ Attr.height px ] []
1156-- ============================================================================
1157-- HTTP API
1158-- ============================================================================
1161requestChallenge : String -> String -> Cmd Msg
1162requestChallenge serverUrl pubkey =
1163 Http.post
1164 { url = serverUrl ++ "/auth/challenge"
1165 , body =
1166 Http.jsonBody
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 =
1174 Http.post
1175 { url = serverUrl ++ "/auth/verify"
1176 , body =
1177 Http.jsonBody
1178 (Encode.object
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 =
1190 Http.request
1191 { method = "GET"
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
1197 , tracker = Nothing
1201sendMessageHttp : String -> String -> String -> String -> Int -> Cmd Msg
1202sendMessageHttp serverUrl token to encryptedBlob timestamp =
1203 Http.request
1204 { method = "POST"
1205 , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
1206 , url = serverUrl ++ "/messages/"
1207 , body =
1208 Http.jsonBody
1209 (Encode.object
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
1217 , tracker = Nothing
1221ackMessages : String -> String -> List String -> Cmd Msg
1222ackMessages serverUrl token messageIds =
1223 Http.request
1224 { method = "POST"
1225 , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
1226 , url = serverUrl ++ "/messages/ack"
1227 , body =
1228 Http.jsonBody
1229 (Encode.object
1230 [ ( "messageIds", Encode.list Encode.string messageIds ) ]
1232 , expect = Http.expectJson GotAck (Decode.field "ok" Decode.bool)
1233 , timeout = Just 5000
1234 , tracker = Nothing
1238lookupPresence : String -> String -> Cmd Msg
1239lookupPresence serverUrl pubkey =
1240 Http.get
1241 { url = serverUrl ++ "/lookup/" ++ pubkey
1242 , expect = Http.expectJson GotLookup (lookupDecoder pubkey)
1247-- ============================================================================
1248-- PROTOCOL / CRYPTO
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).
1258Blob format (hex):
1259ephemeral\_x25519\_pub (64 hex) ++ nonce (48 hex)
1260++ ciphertext (var) ++ sender\_ed25519\_pub (64 hex) ++ signature (128 hex)
1263encryptMessage :
1264 Ed.PrivateKey
1265 -> Ed.PublicKey
1266 -> String
1267 -> String
1268 -> String
1269 -> String
1270 -> Maybe String
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 ) ->
1274 let
1275 ephPub =
1276 X.getPublicKey ephPriv
1278 ephPubHex =
1279 X.publicKeyToHex ephPub
1281 -- X25519 key agreement
1282 sharedSecretHex =
1283 X.getSharedSecret ephPriv recipientXPub
1284 in
1285 case Cipher.keyFromHex sharedSecretHex of
1286 Just key ->
1287 let
1288 -- Encrypt plaintext
1289 plaintextHex =
1290 textToHex plaintextJson
1292 ciphertextHex =
1293 Cipher.xchacha20Encrypt key nonce plaintextHex
1295 -- Sign the ciphertext
1296 sig =
1297 Ed.sign ciphertextHex edPriv
1299 senderPubHex =
1300 Ed.publicKeyToHex edPub
1302 sigHex =
1303 Ed.signatureToHex sig
1304 in
1305 Just
1306 (ephPubHex
1307 ++ nonceHex
1308 ++ ciphertextHex
1309 ++ senderPubHex
1310 ++ sigHex
1313 Nothing ->
1314 Nothing
1316 _ ->
1317 Nothing
1320{-| Decrypt a message per Skrepka protocol section 3.
1321Returns (senderEdPubHex, plaintextJson) on success.
1323decryptMessage : X.PrivateKey -> String -> Maybe ( String, String )
1324decryptMessage xPriv blob =
1325 let
1326 hexLen =
1327 String.length blob
1328 in
1329 if hexLen < 304 then
1330 -- Minimum: 64 + 48 + 0 + 64 + 128 = 304 hex chars
1331 Nothing
1333 else
1334 let
1335 ephPubHex =
1336 String.slice 0 64 blob
1338 nonceHex =
1339 String.slice 64 112 blob
1341 ciphertextHex =
1342 String.slice 112 (hexLen - 192) blob
1344 senderEdPubHex =
1345 String.slice (hexLen - 192) (hexLen - 128) blob
1347 sigHex =
1348 String.slice (hexLen - 128) hexLen blob
1349 in
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
1354 Nothing
1356 else
1357 case ( X.publicKeyFromHex ephPubHex, Cipher.nonce24FromHex nonceHex ) of
1358 ( Just ephPub, Just nonce ) ->
1359 let
1360 sharedSecretHex =
1361 X.getSharedSecret xPriv ephPub
1362 in
1363 case Cipher.keyFromHex sharedSecretHex of
1364 Just key ->
1365 case Cipher.xchacha20Decrypt key nonce ciphertextHex of
1366 Just plaintextHex ->
1367 Just ( senderEdPubHex, hexToText plaintextHex )
1369 Nothing ->
1370 Nothing
1372 Nothing ->
1373 Nothing
1375 _ ->
1376 Nothing
1378 _ ->
1379 Nothing
1382{-| Process poll events: decrypt, dedup, collect ACK IDs.
1384processEvents :
1385 X.PrivateKey
1386 -> String
1387 -> List PollEvent
1388 -> Set String
1389 -> ( List ( String, ChatMessage ), Set String, List String )
1390processEvents xPriv myEdHex events seenIds =
1391 List.foldl
1392 (\event ( msgs, seen, acks ) ->
1393 if event.eventType /= "message" then
1394 ( msgs, seen, acks )
1396 else
1397 case Decode.decodeString envelopeDecoder event.payload of
1398 Err _ ->
1399 ( msgs, seen, event.id :: acks )
1401 Ok envelope ->
1402 if envelope.fromKey == myEdHex then
1403 -- Skip our own messages
1404 ( msgs, seen, event.id :: acks )
1406 else
1407 case decryptMessage xPriv envelope.encryptedBlob of
1408 Nothing ->
1409 ( msgs, seen, event.id :: acks )
1411 Just ( senderPub, json ) ->
1412 case Decode.decodeString messagePayloadDecoder json of
1413 Err _ ->
1414 ( msgs, seen, event.id :: acks )
1416 Ok payload ->
1417 if Set.member payload.id seen then
1418 ( msgs, seen, event.id :: acks )
1420 else
1421 ( ( senderPub
1422 , { id = payload.id
1423 , body = payload.body
1424 , timestamp = payload.timestamp
1425 , outgoing = False
1428 :: msgs
1429 , Set.insert payload.id seen
1430 , event.id :: acks
1433 ( [], seenIds, [] )
1434 events
1435 |> (\( msgs, seen, acks ) -> ( List.reverse msgs, seen, acks ))
1439-- ============================================================================
1440-- JSON DECODERS
1441-- ============================================================================
1444type alias ChallengeResponse =
1445 { challenge : String }
1448challengeDecoder : Decoder ChallengeResponse
1449challengeDecoder =
1450 Decode.map ChallengeResponse
1451 (Decode.field "challenge" Decode.string)
1454type alias VerifyResponse =
1455 { token : String, expiresAt : Int }
1458verifyDecoder : Decoder VerifyResponse
1459verifyDecoder =
1460 Decode.map2 VerifyResponse
1461 (Decode.field "token" Decode.string)
1462 (Decode.field "expiresAt" Decode.int)
1465type alias PollEvent =
1466 { id : String
1467 , eventType : String
1468 , payload : String
1472type alias PollResponse =
1473 { events : List PollEvent
1474 , cursor : String
1478pollEventDecoder : Decoder PollEvent
1479pollEventDecoder =
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
1487pollDecoder =
1488 Decode.map2 PollResponse
1489 (Decode.field "events" (Decode.list pollEventDecoder))
1490 (Decode.field "cursor" Decode.string)
1493type alias Envelope =
1494 { toKey : String
1495 , fromKey : String
1496 , encryptedBlob : String
1497 , timestamp : Int
1501envelopeDecoder : Decoder Envelope
1502envelopeDecoder =
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 =
1511 { status : String
1512 , messageId : String
1516sendDecoder : Decoder SendResponse
1517sendDecoder =
1518 Decode.map2 SendResponse
1519 (Decode.field "status" Decode.string)
1520 (Decode.field "messageId" Decode.string)
1523type alias MessagePayload =
1524 { type_ : String
1525 , id : String
1526 , timestamp : Int
1527 , body : String
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 =
1541 { pubkey : String
1542 , online : Bool
1546lookupDecoder : String -> Decoder LookupResponse
1547lookupDecoder pubkey =
1548 Decode.map (LookupResponse pubkey)
1549 (Decode.field "online" Decode.bool)
1553-- ============================================================================
1554-- HELPERS
1555-- ============================================================================
1558{-| Parse identity string "ed25519hex.x25519hex" into (edHex, xHex).
1560parseIdentity : String -> Maybe ( String, String )
1561parseIdentity str =
1562 case String.split "." str of
1563 [ edHex, xHex ] ->
1564 if String.length edHex == 64 && String.length xHex == 64 then
1565 Just ( edHex, xHex )
1567 else
1568 Nothing
1570 _ ->
1571 Nothing
1574truncateKey : String -> String
1575truncateKey key =
1576 if String.length key > 16 then
1577 String.left 8 key ++ "..." ++ String.right 8 key
1579 else
1580 key
1583httpErrorToString : Http.Error -> String
1584httpErrorToString err =
1585 case err of
1586 Http.BadUrl url ->
1587 "Bad URL: " ++ url
1589 Http.Timeout ->
1590 "Request timed out"
1592 Http.NetworkError ->
1593 "Network error"
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
1605textToHex str =
1606 str
1607 |> String.toList
1608 |> List.concatMap charToHexBytes
1609 |> String.fromList
1612charToHexBytes : Char -> List Char
1613charToHexBytes c =
1614 let
1615 code =
1616 Char.toCode c
1617 in
1618 if code < 128 then
1619 [ nibbleToHex (code // 16), nibbleToHex (modBy 16 code) ]
1621 else if code < 2048 then
1622 -- 2-byte UTF-8
1623 let
1624 b1 =
1625 0xC0 + (code // 64)
1627 b2 =
1628 0x80 + modBy 64 code
1629 in
1630 [ nibbleToHex (b1 // 16)
1631 , nibbleToHex (modBy 16 b1)
1632 , nibbleToHex (b2 // 16)
1633 , nibbleToHex (modBy 16 b2)
1636 else if code < 65536 then
1637 -- 3-byte UTF-8
1638 let
1639 b1 =
1640 0xE0 + (code // 4096)
1642 b2 =
1643 0x80 + modBy 64 (code // 64)
1645 b3 =
1646 0x80 + modBy 64 code
1647 in
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)
1656 else
1657 -- 4-byte UTF-8 (U+10000 and above: emoji, supplementary planes)
1658 let
1659 b1 =
1660 0xF0 + (code // 262144)
1662 b2 =
1663 0x80 + modBy 64 (code // 4096)
1665 b3 =
1666 0x80 + modBy 64 (code // 64)
1668 b4 =
1669 0x80 + modBy 64 code
1670 in
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
1683nibbleToHex n =
1684 case n of
1685 0 ->
1686 '0'
1688 1 ->
1689 '1'
1691 2 ->
1692 '2'
1694 3 ->
1695 '3'
1697 4 ->
1698 '4'
1700 5 ->
1701 '5'
1703 6 ->
1704 '6'
1706 7 ->
1707 '7'
1709 8 ->
1710 '8'
1712 9 ->
1713 '9'
1715 10 ->
1716 'a'
1718 11 ->
1719 'b'
1721 12 ->
1722 'c'
1724 13 ->
1725 'd'
1727 14 ->
1728 'e'
1730 _ ->
1731 'f'
1734{-| Decode hex-encoded UTF-8 bytes back to a string.
1736hexToText : String -> String
1737hexToText hex =
1738 hexToBytes hex
1739 |> decodeUtf8Bytes
1740 |> Maybe.withDefault ""
1743hexToBytes : String -> List Int
1744hexToBytes hex =
1745 let
1746 chars =
1747 String.toList hex
1749 pairs =
1750 pairUp chars
1751 in
1752 List.filterMap
1753 (\( hi, lo ) ->
1754 Maybe.map2 (\h l -> h * 16 + l) (hexCharToInt hi) (hexCharToInt lo)
1756 pairs
1759pairUp : List a -> List ( a, a )
1760pairUp list =
1761 case list of
1762 a :: b :: rest ->
1763 ( a, b ) :: pairUp rest
1765 _ ->
1766 []
1769hexCharToInt : Char -> Maybe Int
1770hexCharToInt c =
1771 let
1772 code =
1773 Char.toCode c
1774 in
1775 if code >= 48 && code <= 57 then
1776 Just (code - 48)
1778 else if code >= 97 && code <= 102 then
1779 Just (code - 87)
1781 else if code >= 65 && code <= 70 then
1782 Just (code - 55)
1784 else
1785 Nothing
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 =
1796 case bytes of
1797 [] ->
1798 Just acc
1800 b :: rest ->
1801 if b < 128 then
1802 decodeUtf8Help rest (Char.fromCode b :: acc)
1804 else if b >= 0xC0 && b < 0xE0 then
1805 case rest of
1806 b2 :: rest2 ->
1807 let
1808 code =
1809 (b - 0xC0) * 64 + (b2 - 0x80)
1810 in
1811 decodeUtf8Help rest2 (Char.fromCode code :: acc)
1813 _ ->
1814 Nothing
1816 else if b >= 0xE0 && b < 0xF0 then
1817 case rest of
1818 b2 :: b3 :: rest3 ->
1819 let
1820 code =
1821 (b - 0xE0) * 4096 + (b2 - 0x80) * 64 + (b3 - 0x80)
1822 in
1823 decodeUtf8Help rest3 (Char.fromCode code :: acc)
1825 _ ->
1826 Nothing
1828 else if b >= 0xF0 then
1829 case rest of
1830 b2 :: b3 :: b4 :: rest4 ->
1831 let
1832 code =
1833 (b - 0xF0) * 262144 + (b2 - 0x80) * 4096 + (b3 - 0x80) * 64 + (b4 - 0x80)
1834 in
1835 decodeUtf8Help rest4 (Char.fromCode code :: acc)
1837 _ ->
1838 Nothing
1840 else
1841 Nothing
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
1853randomHexChar =
1854 Random.map
1855 (\n ->
1856 if n < 10 then
1857 Char.fromCode (n + 48)
1859 else
1860 Char.fromCode (n - 10 + 97)
1862 (Random.int 0 15)