diff --git a/backend/BW.js b/backend/BW.js index 8c96375..2460c8a 100644 --- a/backend/BW.js +++ b/backend/BW.js @@ -21,6 +21,7 @@ import { CipherCreateRequest } from "../../deps/bw/libs/common/src/models/reques import { Cipher } from "../../deps/bw/libs/common/src/models/domain/cipher"; import { CipherData } from "../../deps/bw/libs/common/src/models/data/cipherData"; import { PasswordGenerationService } from "../../deps/bw/libs/common/src/services/passwordGeneration.service"; +import { TotpService } from "../../deps/bw/libs/shared/dist/src/services/totp.service" function sanitize(obj) { return JSON.parse(JSON.stringify(obj)); @@ -84,7 +85,8 @@ export function getServices() { }, crypto: bg.cryptoService, cryptoFunctions: bg.cryptoFunctionService, - passwordGeneration: bg.passwordGenerationService + passwordGeneration: bg.passwordGenerationService, + totpService: bg.totpService, } } @@ -175,6 +177,11 @@ class MainBackground { this.policyService, this.stateService ); + this.totpService = new TotpService( + this.cryptoFunctionService, + this.logService, + this.stateService + ); } async bootstrap() { diff --git a/backend/BW.purs b/backend/BW.purs index 9ffec19..ce20482 100644 --- a/backend/BW.purs +++ b/backend/BW.purs @@ -17,6 +17,11 @@ import Effect (Effect) import Effect.Unsafe (unsafePerformEffect) import Untagged.Union (type (|+|)) +type TotpService + = { getCode :: String -> Promise String + , getTimeInterval :: String -> Int + } + type CryptoService = { makeKey :: Fn4 Password String KDF Int (Promise SymmetricCryptoKey) , hashPassword :: Fn3 Password SymmetricCryptoKey (JNullable HashPurpose) (Promise StringHash) @@ -85,6 +90,7 @@ type Services , getApi :: Urls -> JNullable IdentityTokenResponse -> Promise ApiService , cryptoFunctions :: CryptoFunctions , passwordGeneration :: PasswordGeneration + , totpService :: TotpService } foreign import getServices :: Effect Services diff --git a/backend/BW/Logic.purs b/backend/BW/Logic.purs index 497ede3..a7693b3 100644 --- a/backend/BW/Logic.purs +++ b/backend/BW/Logic.purs @@ -256,6 +256,7 @@ encodeCipher (Bridge.FullCipher { name, cipher, id, favorite, reprompt }) = do ) (unwrap login.uris) username <- encryptNullable login.username + totp <- encryptNullable login.totp pure x { login = @@ -264,7 +265,7 @@ encodeCipher (Bridge.FullCipher { name, cipher, id, favorite, reprompt }) = do , uris: JOpt $ opt uris , username , passwordRevisionDate: jnull - , totp: jnull + , totp: totp , autofillOnPageLoad: JOpt undefined } , type = cipherTypeLogin @@ -326,12 +327,14 @@ decodeCipher cipher = do Just login -> do username <- decryptNullable login.username password <- decryptNullable login.password + totp <- decryptNullable login.totp uris <- fromJOpt [] <$> ((traverse >>> traverse) (_.uri >>> decrypt) login.uris) pure $ Bridge.LoginCipher $ Bridge.Cipher_LoginCipher { username , password , uris: wrap uris + , totp } n | cipherTypeCard == n -> do diff --git a/backend/Bridge.purs b/backend/Bridge.purs index dd3c395..b94b676 100644 --- a/backend/Bridge.purs +++ b/backend/Bridge.purs @@ -362,6 +362,18 @@ derive instance genericCipher_LoginCipher_password_Maybe :: Generic Cipher_Login derive instance eqCipher_LoginCipher_password_Maybe :: Eq Cipher_LoginCipher_password_Maybe derive instance ordCipher_LoginCipher_password_Maybe :: Ord Cipher_LoginCipher_password_Maybe +newtype Cipher_LoginCipher_totp_Maybe = + Cipher_LoginCipher_totp_Maybe (Maybe String) + +derive instance newtypeCipher_LoginCipher_totp_Maybe :: Newtype Cipher_LoginCipher_totp_Maybe _ +instance encodeJsonCipher_LoginCipher_totp_Maybe :: EncodeJson Cipher_LoginCipher_totp_Maybe where + encodeJson = genericEncodeAeson Argonaut.defaultOptions +instance decodeJsonCipher_LoginCipher_totp_Maybe :: DecodeJson Cipher_LoginCipher_totp_Maybe where + decodeJson = genericDecodeAeson Argonaut.defaultOptions +derive instance genericCipher_LoginCipher_totp_Maybe :: Generic Cipher_LoginCipher_totp_Maybe _ +derive instance eqCipher_LoginCipher_totp_Maybe :: Eq Cipher_LoginCipher_totp_Maybe +derive instance ordCipher_LoginCipher_totp_Maybe :: Ord Cipher_LoginCipher_totp_Maybe + newtype Cipher_LoginCipher_uris_List = Cipher_LoginCipher_uris_List (Array String) @@ -389,6 +401,7 @@ derive instance ordCipher_LoginCipher_username_Maybe :: Ord Cipher_LoginCipher_u newtype Cipher_LoginCipher = Cipher_LoginCipher { password :: Cipher_LoginCipher_password_Maybe + , totp :: Cipher_LoginCipher_totp_Maybe , uris :: Cipher_LoginCipher_uris_List , username :: Cipher_LoginCipher_username_Maybe } @@ -458,6 +471,7 @@ data Cmd = | NeedsReset | Open String | RequestCipher String + | RequestTotp String | SendMasterPassword String | UpdateCipher FullCipher @@ -559,6 +573,22 @@ derive instance genericSub_NeedsMasterPassword :: Generic Sub_NeedsMasterPasswor derive instance eqSub_NeedsMasterPassword :: Eq Sub_NeedsMasterPassword derive instance ordSub_NeedsMasterPassword :: Ord Sub_NeedsMasterPassword +newtype Sub_Totp = + Sub_Totp { + code :: String + , interval :: Int + , source :: String + } + +derive instance newtypeSub_Totp :: Newtype Sub_Totp _ +instance encodeJsonSub_Totp :: EncodeJson Sub_Totp where + encodeJson = genericEncodeAeson Argonaut.defaultOptions +instance decodeJsonSub_Totp :: DecodeJson Sub_Totp where + decodeJson = genericDecodeAeson Argonaut.defaultOptions +derive instance genericSub_Totp :: Generic Sub_Totp _ +derive instance eqSub_Totp :: Eq Sub_Totp +derive instance ordSub_Totp :: Ord Sub_Totp + data Sub = CaptchaDone | CipherChanged FullCipher @@ -573,6 +603,7 @@ data Sub = | NeedsMasterPassword Sub_NeedsMasterPassword | RecieveEmail String | Reset + | Totp Sub_Totp | WrongPassword instance encodeJsonSub :: EncodeJson Sub where diff --git a/backend/Main.purs b/backend/Main.purs index 18d566c..14b90a6 100644 --- a/backend/Main.purs +++ b/backend/Main.purs @@ -206,13 +206,24 @@ main = do send $ Bridge.CipherChanged newCipher performSync pure unit - Bridge.DeleteCipher c@(Bridge.FullCipher { id, name }) -> + Bridge.DeleteCipher c@(Bridge.FullCipher { id }) -> runWithDecryptionKey do api <- getAuthedApi liftPromise $ api.deleteCipher id send $ Bridge.CipherDeleted c performSync pure unit + Bridge.RequestTotp totp -> + runWithDecryptionKey do + { totpService } <- askAt (Proxy :: _ "services") + code <- liftPromise $ totpService.getCode totp + let + interval = totpService.getTimeInterval totp + send $ Bridge.Totp + $ Bridge.Sub_Totp + { interval, code, source: totp + } + pure unit processCipher :: forall r. diff --git a/bridge.yaml b/bridge.yaml index f79a2c7..aa43551 100644 --- a/bridge.yaml +++ b/bridge.yaml @@ -20,6 +20,10 @@ Sub: - CipherChanged: FullCipher - GeneratedPassword: String - CipherDeleted: FullCipher +- Totp: + source: String + code: String + interval: Int Cmd: - Init @@ -38,6 +42,7 @@ Cmd: - CreateCipher: FullCipher - GeneratePassword: PasswordGeneratorConfig - DeleteCipher: FullCipher +- RequestTotp: String FullCipher: reprompt: Int @@ -53,6 +58,8 @@ Cipher: Maybe: String password: Maybe: String + totp: + Maybe: String - NoteCipher: String - CardCipher: cardholderName: diff --git a/frontend/Bridge.elm b/frontend/Bridge.elm index d06823a..7849df4 100644 --- a/frontend/Bridge.elm +++ b/frontend/Bridge.elm @@ -383,6 +383,17 @@ jsonEncCipher_LoginCipher_password_Maybe val = (maybeEncode (Json.Encode.string +type alias Cipher_LoginCipher_totp_Maybe = (Maybe String) + +jsonDecCipher_LoginCipher_totp_Maybe : Json.Decode.Decoder ( Cipher_LoginCipher_totp_Maybe ) +jsonDecCipher_LoginCipher_totp_Maybe = + Json.Decode.maybe (Json.Decode.string) + +jsonEncCipher_LoginCipher_totp_Maybe : Cipher_LoginCipher_totp_Maybe -> Value +jsonEncCipher_LoginCipher_totp_Maybe val = (maybeEncode (Json.Encode.string)) val + + + type alias Cipher_LoginCipher_uris_List = (List String) jsonDecCipher_LoginCipher_uris_List : Json.Decode.Decoder ( Cipher_LoginCipher_uris_List ) @@ -407,14 +418,16 @@ jsonEncCipher_LoginCipher_username_Maybe val = (maybeEncode (Json.Encode.string type alias Cipher_LoginCipher = { password: Cipher_LoginCipher_password_Maybe + , totp: Cipher_LoginCipher_totp_Maybe , uris: Cipher_LoginCipher_uris_List , username: Cipher_LoginCipher_username_Maybe } jsonDecCipher_LoginCipher : Json.Decode.Decoder ( Cipher_LoginCipher ) jsonDecCipher_LoginCipher = - Json.Decode.succeed (\ppassword puris pusername -> {password = ppassword, uris = puris, username = pusername}) + Json.Decode.succeed (\ppassword ptotp puris pusername -> {password = ppassword, totp = ptotp, uris = puris, username = pusername}) |> required "password" (jsonDecCipher_LoginCipher_password_Maybe) + |> required "totp" (jsonDecCipher_LoginCipher_totp_Maybe) |> required "uris" (jsonDecCipher_LoginCipher_uris_List) |> required "username" (jsonDecCipher_LoginCipher_username_Maybe) @@ -422,6 +435,7 @@ jsonEncCipher_LoginCipher : Cipher_LoginCipher -> Value jsonEncCipher_LoginCipher val = Json.Encode.object [ ("password", jsonEncCipher_LoginCipher_password_Maybe val.password) + , ("totp", jsonEncCipher_LoginCipher_totp_Maybe val.totp) , ("uris", jsonEncCipher_LoginCipher_uris_List val.uris) , ("username", jsonEncCipher_LoginCipher_username_Maybe val.username) ] @@ -512,6 +526,7 @@ type Cmd = | NeedsReset | Open String | RequestCipher String + | RequestTotp String | SendMasterPassword String | UpdateCipher FullCipher @@ -529,6 +544,7 @@ jsonDecCmd = , ("NeedsReset", Json.Decode.lazy (\_ -> Json.Decode.succeed NeedsReset)) , ("Open", Json.Decode.lazy (\_ -> Json.Decode.map Open (Json.Decode.string))) , ("RequestCipher", Json.Decode.lazy (\_ -> Json.Decode.map RequestCipher (Json.Decode.string))) + , ("RequestTotp", Json.Decode.lazy (\_ -> Json.Decode.map RequestTotp (Json.Decode.string))) , ("SendMasterPassword", Json.Decode.lazy (\_ -> Json.Decode.map SendMasterPassword (Json.Decode.string))) , ("UpdateCipher", Json.Decode.lazy (\_ -> Json.Decode.map UpdateCipher (jsonDecFullCipher))) ] @@ -549,6 +565,7 @@ jsonEncCmd val = NeedsReset -> ("NeedsReset", encodeValue (Json.Encode.list identity [])) Open v1 -> ("Open", encodeValue (Json.Encode.string v1)) RequestCipher v1 -> ("RequestCipher", encodeValue (Json.Encode.string v1)) + RequestTotp v1 -> ("RequestTotp", encodeValue (Json.Encode.string v1)) SendMasterPassword v1 -> ("SendMasterPassword", encodeValue (Json.Encode.string v1)) UpdateCipher v1 -> ("UpdateCipher", encodeValue (jsonEncFullCipher v1)) in encodeSumTaggedObject "tag" "contents" keyval val @@ -700,6 +717,29 @@ jsonEncSub_NeedsMasterPassword val = +type alias Sub_Totp = + { code: String + , interval: Int + , source: String + } + +jsonDecSub_Totp : Json.Decode.Decoder ( Sub_Totp ) +jsonDecSub_Totp = + Json.Decode.succeed (\pcode pinterval psource -> {code = pcode, interval = pinterval, source = psource}) + |> required "code" (Json.Decode.string) + |> required "interval" (Json.Decode.int) + |> required "source" (Json.Decode.string) + +jsonEncSub_Totp : Sub_Totp -> Value +jsonEncSub_Totp val = + Json.Encode.object + [ ("code", Json.Encode.string val.code) + , ("interval", Json.Encode.int val.interval) + , ("source", Json.Encode.string val.source) + ] + + + type Sub = CaptchaDone | CipherChanged FullCipher @@ -714,6 +754,7 @@ type Sub = | NeedsMasterPassword Sub_NeedsMasterPassword | RecieveEmail String | Reset + | Totp Sub_Totp | WrongPassword jsonDecSub : Json.Decode.Decoder ( Sub ) @@ -732,6 +773,7 @@ jsonDecSub = , ("NeedsMasterPassword", Json.Decode.lazy (\_ -> Json.Decode.map NeedsMasterPassword (jsonDecSub_NeedsMasterPassword))) , ("RecieveEmail", Json.Decode.lazy (\_ -> Json.Decode.map RecieveEmail (Json.Decode.string))) , ("Reset", Json.Decode.lazy (\_ -> Json.Decode.succeed Reset)) + , ("Totp", Json.Decode.lazy (\_ -> Json.Decode.map Totp (jsonDecSub_Totp))) , ("WrongPassword", Json.Decode.lazy (\_ -> Json.Decode.succeed WrongPassword)) ] jsonDecObjectSetSub = Set.fromList [] @@ -753,6 +795,7 @@ jsonEncSub val = NeedsMasterPassword v1 -> ("NeedsMasterPassword", encodeValue (jsonEncSub_NeedsMasterPassword v1)) RecieveEmail v1 -> ("RecieveEmail", encodeValue (Json.Encode.string v1)) Reset -> ("Reset", encodeValue (Json.Encode.list identity [])) + Totp v1 -> ("Totp", encodeValue (jsonEncSub_Totp v1)) WrongPassword -> ("WrongPassword", encodeValue (Json.Encode.list identity [])) in encodeSumTaggedObject "tag" "contents" keyval val diff --git a/frontend/GlobalEvents.elm b/frontend/GlobalEvents.elm index ca30c05..4503071 100644 --- a/frontend/GlobalEvents.elm +++ b/frontend/GlobalEvents.elm @@ -6,3 +6,4 @@ import Bridge type Event = UpdateCipher Bridge.FullCipher | GeneratedPassword String + | DecodedTotp Bridge.Sub_Totp diff --git a/frontend/Logic/Cipher.elm b/frontend/Logic/Cipher.elm index 91644a4..b5f1031 100644 --- a/frontend/Logic/Cipher.elm +++ b/frontend/Logic/Cipher.elm @@ -54,9 +54,10 @@ normalizeIdentityCipher { address1, address2, address3, city, company, country, normalizeLoginCipher : Bridge.Cipher_LoginCipher -> Bridge.Cipher_LoginCipher -normalizeLoginCipher { password, uris, username } = +normalizeLoginCipher { password, totp, uris, username } = { password = password , uris = uris |> List.map String.trim |> List.filter String.isEmpty + , totp = normalizeStringMaybe totp , username = username } diff --git a/frontend/Main.elm b/frontend/Main.elm index dcc493b..d717f6b 100644 --- a/frontend/Main.elm +++ b/frontend/Main.elm @@ -64,6 +64,7 @@ type Msg | OpenNewCipherEditPage Bridge.CipherType | WrongPassword | DeleteCipher Bridge.FullCipher + | RequestTotp String type alias Model = @@ -231,9 +232,39 @@ subscriptions model = Bridge.CipherDeleted c -> ShowInfo "Entry deleted" ("The entry “" ++ c.name ++ "” has been successfully deleted.") + + Bridge.Totp totp -> + GlobalEvents.DecodedTotp totp |> FireGlobalEvent ) ] ++ optional (List.isEmpty model.notifications |> not) (Time.every 1000 (\t -> ClearNotification { currentTime = t })) + ++ (model.pageStack + |> Nonempty.toList + |> List.map + (\x -> + case x of + LoginModel m -> + (Login.page loginCallbacks LoginMsg).subscriptions m + + LoadingPage -> + Sub.none + + CiphersModel m -> + (Ciphers.page ciphersCallbacks CiphersMsg).subscriptions m + + EditCipherModel m -> + (EditCipher.page EditCipherMsg).subscriptions m + + MasterPasswordModel m -> + (MasterPassword.page masterPasswordCallbacks MasterPasswordMsg).subscriptions m + + CipherModel m -> + (Cipher.page cipherCallbacks CipherMsg).subscriptions m + + CaptchaModel m -> + (Captcha.page captchaCallbacks CaptchaMsg).subscriptions m + ) + ) ) @@ -557,6 +588,7 @@ update msg model = { uris = [] , username = Nothing , password = Nothing + , totp = Nothing } Bridge.NoteType -> @@ -614,6 +646,9 @@ update msg model = ] ) + RequestTotp totp -> + ( model, FFI.sendBridge (Bridge.RequestTotp totp) ) + loginCallbacks : Login.Callbacks Msg loginCallbacks = @@ -630,7 +665,7 @@ ciphersCallbacks = cipherCallbacks : Cipher.Callbacks Msg cipherCallbacks = - { copy = Copy, open = Open, edit = EditCipher } + { copy = Copy, open = Open, edit = EditCipher, needTotp = RequestTotp } editCipherCallbacks : EditCipher.Callbacks Msg diff --git a/frontend/Pages/Cipher.elm b/frontend/Pages/Cipher.elm index 5a3adab..72605ce 100644 --- a/frontend/Pages/Cipher.elm +++ b/frontend/Pages/Cipher.elm @@ -6,6 +6,7 @@ import Html exposing (..) import Html.Attributes as Attr import Html.Events as Ev import Page exposing (..) +import Time import Utils exposing (..) @@ -13,6 +14,7 @@ type alias Model = { cipher : Bridge.FullCipher , passwordHidden : Bool , cvvHidden : Bool + , decodedTotp : Maybe { code : String, interval : Int } } @@ -27,15 +29,16 @@ type alias Callbacks msg = { copy : String -> msg , open : String -> msg , edit : Bridge.FullCipher -> msg + , needTotp : String -> msg } page : Callbacks emsg -> Page Bridge.FullCipher Model Msg emsg page callbacks liftMsg = - { init = \data -> Tuple.mapSecond (Cmd.map liftMsg) (init data) + { init = \data -> init callbacks data , view = \model -> view model |> List.map (Html.map liftMsg) , update = \msg model -> update callbacks liftMsg msg model - , subscriptions = \model -> subscriptions model |> Sub.map liftMsg + , subscriptions = \model -> subscriptions model callbacks , title = \{ cipher } -> [ text cipher.name @@ -54,18 +57,41 @@ page callbacks liftMsg = model.cipher } + GlobalEvents.DecodedTotp { source, code, interval } -> + case model.cipher.cipher of + Bridge.LoginCipher { totp } -> + if totp == Just source then + { model | decodedTotp = Just { code = code, interval = interval } } + + else + model + + _ -> + model + _ -> model } -init : Bridge.FullCipher -> ( Model, Cmd Msg ) -init cipher = +init : Callbacks emsg -> Bridge.FullCipher -> ( Model, Cmd emsg ) +init { needTotp } cipher = ( { cipher = cipher , passwordHidden = True , cvvHidden = True + , decodedTotp = Nothing } - , Cmd.none + , case cipher.cipher of + Bridge.LoginCipher { totp } -> + case totp of + Just x -> + needTotp x |> pureCmd + + Nothing -> + Cmd.none + + _ -> + Cmd.none ) @@ -85,9 +111,19 @@ update { copy, open } _ msg model = ( Ok model, open uri |> pureCmd ) -subscriptions : Model -> Sub Msg -subscriptions _ = - Sub.none +subscriptions : Model -> Callbacks msg -> Sub msg +subscriptions { cipher } { needTotp } = + case cipher.cipher of + Bridge.LoginCipher { totp } -> + case totp of + Just x -> + Time.every 1000 (always (needTotp x)) + + Nothing -> + Sub.none + + _ -> + Sub.none row : { name : String, value : String, nameIcon : String, icons : List ( String, msg ) } -> Html msg @@ -101,9 +137,9 @@ row { name, value, nameIcon, icons } = view : Model -> List (Html Msg) -view { passwordHidden, cipher, cvvHidden } = +view { passwordHidden, cipher, cvvHidden, decodedTotp } = (case cipher.cipher of - Bridge.LoginCipher { username, password, uris } -> + Bridge.LoginCipher { username, password, uris, totp } -> maybeList username (\x -> row @@ -123,11 +159,36 @@ view { passwordHidden, cipher, cvvHidden } = , icons = [ ( hiddenButtonIcon passwordHidden, TogglePasswordVisiblity ), ( "copy", Copy x ) ] } ) + ++ maybeList totp + (\_ -> + row + { name = "One-time password" + , value = + case decodedTotp of + Nothing -> + "Loading..." + + Just { code } -> + code + , nameIcon = "revisions" + , icons = + case decodedTotp of + Nothing -> + [] + + Just { code } -> + [ ( "copy", Copy code ) ] + } + ) ++ (uris |> List.map (\uri -> row - { name = "URL", value = uri, nameIcon = "get-link", icons = [ ( "external-link", Open uri ), ( "copy", Copy uri ) ] } + { name = "URL" + , value = uri + , nameIcon = "get-link" + , icons = [ ( "external-link", Open uri ), ( "copy", Copy uri ) ] + } ) ) diff --git a/frontend/Pages/EditCipher.elm b/frontend/Pages/EditCipher.elm index da795e3..ff7de0a 100644 --- a/frontend/Pages/EditCipher.elm +++ b/frontend/Pages/EditCipher.elm @@ -81,6 +81,9 @@ page liftMsg = c } } + + _ -> + model } @@ -174,7 +177,7 @@ view { fullCipher, passwordGenerator, callbacks } = ++ (case fullCipher.cipher of Bridge.LoginCipher cipher -> let - { username, password, uris } = + { username, password, uris, totp } = cipher edit c = @@ -203,6 +206,16 @@ view { fullCipher, passwordGenerator, callbacks } = [] , iconButton "restart" OpenGeneratePasword ] + , row + { name = "One-time password" + , nameIcon = "revisions" + , attrs = + [ Attr.type_ "text" + , Attr.value (Maybe.withDefault "" totp) + , Ev.onInput (\x -> edit { cipher | totp = Just x }) + , Attr.attribute "autocomplete" "off" + ] + } ] ++ (uris |> List.indexedMap