packages feed

u2f (empty) → 0.1.0.0

raw patch · 7 files changed

+412/−0 lines, 7 filesdep +aesondep +asn1-encodingdep +asn1-typessetup-changed

Dependencies added: aeson, asn1-encoding, asn1-types, base, base64-bytestring, binary, bytestring, cryptohash, cryptonite, either-unwrap, hspec, text, u2f

Files

+ ChangeLog.md view
@@ -0,0 +1,5 @@+# Revision history for u2f++## 0.1.0.0  -- 2016-09-15++* Mass-renamed everything. No changes of value
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright (c) 2016, Eugene Butler++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of Eugene Butler nor the names of other+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,3 @@+# U2F++Haskell library for conveniently processing U2F registration/sign-in requests.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ src/U2F.hs view
@@ -0,0 +1,278 @@+{-# LANGUAGE DeriveGeneric #-}++module U2F where++import GHC.Generics+import Data.Bits++import Data.Either.Unwrap++import Data.ASN1.BinaryEncoding+import Data.ASN1.Types+import qualified Data.ByteString.Lazy.Char8 as LBS+import qualified Data.ByteString.Char8 as BS++import Data.Text.Encoding (encodeUtf8)+import Data.Aeson ((.:), (.:?), decode, FromJSON(..),+  ToJSON(..), Value(..), genericParseJSON, defaultOptions)+import Data.Binary.Get+import Data.Aeson.Types+import Control.Applicative ((<$>), (<*>))+import Data.ASN1.BitArray+import Data.ASN1.Encoding+import Data.ByteString (pack)+import Data.ByteString.Base64.URL (encode, decodeLenient)+import qualified Data.Text as T+import Data.Text.Encoding (encodeUtf8, decodeUtf8)+import Data.List++import qualified Crypto.Hash.SHA256 as SHA256++-- Cryptonite stuff+import Crypto.Error+import Crypto.PubKey.ECC.Types+import qualified Crypto.PubKey.ECC.P256 as P256+import qualified Crypto.PubKey.ECC.ECDSA as ECDSA+import Crypto.Hash.Algorithms++{-+  HOW TO USE+  ==========+  To Register+    - Generate yourself a Request, consisting of your site/service uri, u2f version number, etc, send it to the client.+    - Assuming the client returned a registration response (Registration), parse it with parseRegistration.+    - Use verifyRegistration Request Registration to verify that the Registration is valid. (Challenge bytes match, were signed by key described in cert)+    - Stash the publicKey and keyHandle somewhere, so you can use them for signin. verifyRegistration returns a Request, with added keyHandle, for convenience.+  To Signin+    - Make a Request.+    - Parse whatever signin json you have with parseSignin.+    - Dig out the publicKey for the relevant keyHandle.+    - Verify signin with verifySignin publicKey Request Signin+-}++-- Curve+ourCurve = getCurveByName SEC_p256r1++-- Errors+data U2FError =+  RegistrationParseError |+  RegistrationDataParseError |+  RegistrationCertificateParseError |+  PubKeyParsingError |+  SignatureParseError |+  ChallengeMismatchError |+  FailedVerificationError |+  SigninParseError |+  ClientDataParseError |+  RequestParseError+  deriving (Show, Eq)++data Request = Request {+  appId :: T.Text,+  version :: T.Text,+  challenge :: T.Text,+  keyHandle :: Maybe T.Text+} deriving (Show, Generic, Eq)+instance FromJSON Request+instance ToJSON Request++-- Registration Flow --+data Registration = Registration {+  registration_registrationData :: T.Text,+  registration_challenge :: T.Text,+  registration_version :: Maybe T.Text,+  registration_appId :: T.Text,+  registration_clientData :: T.Text,+  registration_sessionID :: Maybe T.Text+} deriving (Show, Generic)+instance ToJSON Registration where+  toJSON = genericToJSON defaultOptions {+                fieldLabelModifier = Prelude.drop 13 }++instance FromJSON Registration where+  parseJSON = genericParseJSON defaultOptions {+                fieldLabelModifier = Prelude.drop 13 }+++data RegistrationData = RegistrationData {+  registrationData_reserved :: BS.ByteString,+  registrationData_publicKey :: BS.ByteString,+  registrationData_keyHandle :: BS.ByteString,+  registrationData_certificate :: BS.ByteString,+  registrationData_signature :: BS.ByteString+} deriving (Show, Generic)++parseRequest :: String -> Either U2FError Request+parseRequest x = case (Data.Aeson.decode (LBS.pack x) :: Maybe Request) of+  Just request -> Right request+  Nothing -> Left RequestParseError++parseRegistration :: String -> Either U2FError Registration+parseRegistration x = case (Data.Aeson.decode (LBS.pack x) :: Maybe Registration) of+  Just registration -> Right registration+  Nothing -> Left RegistrationParseError++parseRegistrationData :: BS.ByteString -> Either U2FError RegistrationData+parseRegistrationData r = Right $ runGet unpackRegistrationData ( LBS.fromStrict $ decodeLenient r)++getPubKeyFromCertificate :: BS.ByteString -> Either U2FError ECDSA.PublicKey+getPubKeyFromCertificate cert = case (decodeASN1' DER cert) of+  Right certParse -> case (findPubKey certParse) of+    Just key -> Right key+    Nothing -> Left PubKeyParsingError+  Left _ -> Left RegistrationCertificateParseError++findPubKey :: Foldable t => t ASN1 -> Maybe ECDSA.PublicKey+findPubKey parsedCert = case (find pubKeyShape parsedCert) of+  Just (BitString (BitArray len x)) -> parsePublicKey $ BS.tail x+  _ -> Nothing++pubKeyShape :: ASN1 -> Bool+pubKeyShape (BitString (BitArray len _)) = len == 520+pubKeyShape _ = False++getSignatureBase :: BS.ByteString -> BS.ByteString -> BS.ByteString -> BS.ByteString -> BS.ByteString+getSignatureBase appId clientData keyHandle publicKey = sigBase+  where sigBase = BS.concat([BS.pack "\NUL", SHA256.hash(appId), SHA256.hash(decodeLenient clientData), keyHandle, publicKey])++getSignatureBaseFromRegistration :: Registration -> RegistrationData -> Either U2FError BS.ByteString+getSignatureBaseFromRegistration registration registrationData = do+  appId <- pure $ BS.pack $ T.unpack $ registration_appId registration+  clientData <- pure $ BS.pack $ T.unpack $ registration_clientData registration+  keyHandle <- pure $ registrationData_keyHandle registrationData+  publicKey <- pure $ registrationData_publicKey registrationData+  pure $ getSignatureBase appId clientData keyHandle publicKey++verifyRegistration :: Request -> Registration -> Either U2FError Request+verifyRegistration request registration = do+  challengesEqual <- u2fComparator (challenge request) (registration_challenge registration) ChallengeMismatchError+  registrationData <- parseRegistrationData $ encodeUtf8 $ registration_registrationData registration+  pkey <- getPubKeyFromCertificate $ registrationData_certificate registrationData+  signature <- parseSignature $ registrationData_signature registrationData+  signatureBase <- getSignatureBaseFromRegistration registration registrationData+  case (verifySignature signatureBase pkey signature) of+    True -> Right (request {keyHandle = Just $ formatOutputBase64 $ registrationData_keyHandle registrationData})+    False -> Left FailedVerificationError++-- Signin Flow --+data Signin = Signin {+  signin_keyHandle :: T.Text,+  signin_clientData :: T.Text,+  signin_signatureData :: T.Text+} deriving (Show, Generic, Eq)++instance FromJSON Signin where+  parseJSON = genericParseJSON defaultOptions {+                fieldLabelModifier = Prelude.drop 7 }++data ClientData = ClientData {+  clientData_typ :: T.Text,+  clientData_challenge :: T.Text,+  clientData_origin :: T.Text,+  clientData_cid_pubkey :: T.Text+  } deriving (Show, Generic, Eq)++instance FromJSON ClientData where+  parseJSON = genericParseJSON defaultOptions {+                fieldLabelModifier = Prelude.drop 11 }++data SignatureData = SignatureData {+  signatureData_userPresenceFlag :: BS.ByteString,+  signatureData_counter :: BS.ByteString,+  signatureData_signature :: BS.ByteString+} deriving (Show, Generic)++parseSignin :: String -> Either U2FError Signin+parseSignin x = case (Data.Aeson.decode (LBS.pack x) :: Maybe Signin) of+  Just signin -> Right signin+  Nothing -> Left SigninParseError++parseClientData :: BS.ByteString -> Either U2FError ClientData+parseClientData x = case (Data.Aeson.decode (LBS.fromStrict $ decodeLenient x) :: Maybe ClientData) of+  Just clientData -> Right clientData+  Nothing -> Left ClientDataParseError++verifySignin :: BS.ByteString -> Request -> Signin -> Either U2FError Bool+verifySignin savedPubkey request signin = do+  clientData <- parseClientData $ encodeUtf8 $ signin_clientData signin+  challengesEqual <- u2fComparator (challenge request) (clientData_challenge clientData) ChallengeMismatchError+  signatureData <- parseSignatureData $ encodeUtf8 $ signin_signatureData signin+  signature <- parseSignature $ signatureData_signature signatureData+  signatureBase <- getSigninSignatureBase request signin signatureData+  -- So Gross. TODO: write function that checks first byte for compression state, parses each pubkey format+  publicKey <- case (parsePublicKey $ BS.tail $ savedPubkey) of+    Just key -> Right key+    Nothing -> Left PubKeyParsingError+  case (verifySignature signatureBase publicKey signature) of+    True -> Right True+    False -> Left FailedVerificationError++-- Other stuff++parseSignatureData :: BS.ByteString -> Either U2FError SignatureData+parseSignatureData s = Right $ runGet unpackSignatureData ( LBS.fromStrict $ decodeLenient s)++parseSignature :: BS.ByteString -> Either U2FError ECDSA.Signature+parseSignature possibleSig = case (decodeASN1' DER possibleSig) of+  Right ([_, IntVal r, IntVal s, _]) -> Right $ ECDSA.Signature r s+  _ -> Left SignatureParseError++getSigninSignatureBase :: Request -> Signin -> SignatureData -> Either U2FError BS.ByteString+getSigninSignatureBase request signin signatureData = do+  appId <- pure $ encodeUtf8 $ appId request+  userPresenceFlag <- pure $ signatureData_userPresenceFlag signatureData+  counter <- pure $ signatureData_counter signatureData+  clientData <- pure $ encodeUtf8 $ signin_clientData signin+  Right $ BS.concat([SHA256.hash(appId), userPresenceFlag, counter, SHA256.hash(decodeLenient clientData)])++parsePublicKey :: BS.ByteString -> Maybe ECDSA.PublicKey+parsePublicKey keyByteString = case P256.pointFromBinary keyByteString of+  CryptoPassed key -> Just $ ECDSA.PublicKey ourCurve $ Point (fst $ P256.pointToIntegers key) (snd $ P256.pointToIntegers key)+  CryptoFailed err -> Nothing++-- URL-friendly base64 encoding may or may not contain padding. Delete it here+-- https://tools.ietf.org/html/rfc4648#section-3.2+formatOutputBase64 :: BS.ByteString -> T.Text+formatOutputBase64 byteString = T.replace (T.pack "=") (T.pack "") (decodeUtf8 $ encode byteString)++verifySignature :: BS.ByteString -> ECDSA.PublicKey -> ECDSA.Signature -> Bool+verifySignature sigBase pubKey signature = ECDSA.verify Crypto.Hash.Algorithms.SHA256 pubKey signature sigBase++u2fComparator :: (Eq a) => a -> a -> U2FError -> Either U2FError Bool+u2fComparator firstThing secondThing theError = case (firstThing == secondThing) of+    True -> Right True+    False -> Left theError++unpackRegistrationData :: Get RegistrationData+unpackRegistrationData = do+  reserved <- getByteString 1+  publicKey <- getByteString 65+  keyHandleLen <- getWord8+  keyHandle <- getByteString $ fromIntegral keyHandleLen+  cert <- unpackASN1+  sign <- unpackASN1+  return $ RegistrationData reserved publicKey keyHandle cert sign++unpackSignatureData :: Get SignatureData+unpackSignatureData = do+  userPresenceFlag <- getByteString 1+  counter <- getByteString 4+  signature <- unpackASN1+  return $ SignatureData userPresenceFlag counter signature++unpackASN1 :: Get BS.ByteString+unpackASN1 = do+  asnPadding <- getWord8+  asnLen <- getWord8+  if ((.&.) asnLen 128) /= 0+    then do+      firstByte <- getWord8+      secondByte <- getWord8+      let firstLen = (fromIntegral firstByte :: Int)+      let secondLen = (fromIntegral secondByte :: Int)+      let asnLength = (firstLen * 256) + secondLen+      asnBody <- getByteString asnLength+      return $ BS.concat([pack([asnPadding, asnLen, firstByte, secondByte]), asnBody])+    else do+      asnBody <- getByteString (fromIntegral asnLen)+      return $ BS.concat([pack([asnPadding, asnLen]), asnBody])
+ tests/test.hs view
@@ -0,0 +1,50 @@+import Test.Hspec+import Control.Exception (evaluate)+import U2F+import Data.Maybe+import qualified Data.ByteString.Char8 as BS+import qualified Data.Text as T++rawRegRequest = "{\"appId\":\"https://localhost:4000\",\"version\":\"U2F_V2\",\"challenge\":\"7uy5uxqHXeCGg7K1cWmO3bVlr_KY1U5dQkPP_wOvlWg\",\"keyHandle\":\"9Hgjem2ZlnKKuKVnHepmuSzMHnoyCX0idccYCwztgtpXllNSr-xv_oSuQnyVCN8HYa5JgIsawOxhtp0CIIcBGQ\"}"+registrationRequest = Request (T.pack "https://localhost:4000") (T.pack "U2F_V2") (T.pack "7uy5uxqHXeCGg7K1cWmO3bVlr_KY1U5dQkPP_wOvlWg") Nothing+registrationResponse = justRight $ parseRegistration "{\"registrationData\":\"BQQ65jfw5zT6MqdV81UYQ6btGV-dK8wRyxAGuYcFAKvFp1HubXnyI9wG5Vw8zdmE8sjBNK58GNrJ3woQ-e_nnV9kQPR4I3ptmZZyirilZx3qZrkszB56Mgl9InXHGAsM7YLaV5ZTUq_sb_6ErkJ8lQjfB2GuSYCLGsDsYbadAiCHARkwggJEMIIBLqADAgECAgR4wN8OMAsGCSqGSIb3DQEBCzAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowKjEoMCYGA1UEAwwfWXViaWNvIFUyRiBFRSBTZXJpYWwgMjAyNTkwNTkzNDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABLW4cVyD_f4OoVxFd6yFjfSMF2_eh53K9Lg9QNMg8m-t5iX89_XIr9g1GPjbniHsCDsYRYDHF-xKRwuWim-6P2-jOzA5MCIGCSsGAQQBgsQKAgQVMS4zLjYuMS40LjEuNDE0ODIuMS4xMBMGCysGAQQBguUcAgEBBAQDAgUgMAsGCSqGSIb3DQEBCwOCAQEAPvar9kqRawv5lJON3JU04FRAAmhWeKcsQ6er5l2QZf9h9FHOijru2GaJ0ZC5UK8AelTRMe7wb-JrTqe7PjK3kgWl36dgBDRT40r4RMN81KhfjFwthw4KKLK37UQCQf2zeSsgdrDhivqbQy7u_CZYugkFxBskqTxuyLum1W8z6NZT189r1QFUVaJll0D33MUcwDFgnNA-ps3pOZ7KCHYykHY_tMjQD1aQaaElSQBq67BqIaIU5JmYN7Qp6B1-VtM6VJLdOhYcgpOVQIGqfu90nDpWPb3X26OVzEc-RGltQZGFwkN6yDrAZMHL5HIn_3obd8fV6gw2fUX2ML2ZjVmybjBEAiBGwwt4P70-8E1KmmKQBtVQkvi-w16gSYLECB68b8nDNgIgCiRB1ATDXuWQ7m2DfNnsEq3bs3haITTa4ssHWB8PG-0\",\"challenge\":\"7uy5uxqHXeCGg7K1cWmO3bVlr_KY1U5dQkPP_wOvlWg\",\"version\":\"U2F_V2\",\"appId\":\"https://localhost:4000\",\"sessionId\":\"444\",\"clientData\":\"eyJ0eXAiOiJuYXZpZ2F0b3IuaWQuZmluaXNoRW5yb2xsbWVudCIsImNoYWxsZW5nZSI6Ijd1eTV1eHFIWGVDR2c3SzFjV21PM2JWbHJfS1kxVTVkUWtQUF93T3ZsV2ciLCJvcmlnaW4iOiJodHRwczovL2xvY2FsaG9zdDo0MDAwIiwiY2lkX3B1YmtleSI6InVudXNlZCJ9\"}"+registrationGoodSig = BS.pack "0D\STX F\195\vx?\189>\240MJ\154b\144\ACK\213P\146\248\190\195^\160I\130\196\b\RS\188o\201\195\&6\STX \n$A\212\EOT\195^\229\144\238m\131|\217\236\DC2\173\219\179xZ!4\218\226\203\aX\US\SI\ESC\237"+registrationGoodSigBase = BS.pack "\NUL\219\214S\135\176*\251\174\&8J\132g\177\209\ETX\192y\136s/L\136si\RSA\255\140\184/%\129Q\208\243\CAN!\204\&1\202\144j\153\246\&4\171\SOH\251@\165\244%\202=\ETB>\217\160\a\STX!\158\246W\244x#zm\153\150r\138\184\165g\GS\234f\185,\204\RSz2\t}\"u\199\CAN\v\f\237\130\218W\150SR\175\236o\254\132\174B|\149\b\223\aa\174I\128\139\SUB\192\236a\182\157\STX \135\SOH\EM\EOT:\230\&7\240\231\&4\250\&2\167U\243U\CANC\166\237\EM_\157+\204\DC1\203\DLE\ACK\185\135\ENQ\NUL\171\197\167Q\238my\242#\220\ACK\229\\<\205\217\132\242\200\193\&4\174|\CAN\218\201\223\n\DLE\249\239\231\157_d"++signinRequest = Request (T.pack "https://localhost:4000") (T.pack "U2F_V2") (T.pack "7uy5uxqHXeCGg7K1cWmO3bVlr_KY1U5dQkPP_wOvlWg") (Just $ T.pack "9Hgjem2ZlnKKuKVnHepmuSzMHnoyCX0idccYCwztgtpXllNSr-xv_oSuQnyVCN8HYa5JgIsawOxhtp0CIIcBGQ")+signinResponse = justRight $ parseSignin "{\"keyHandle\":\"9Hgjem2ZlnKKuKVnHepmuSzMHnoyCX0idccYCwztgtpXllNSr-xv_oSuQnyVCN8HYa5JgIsawOxhtp0CIIcBGQ\",\"clientData\":\"eyJ0eXAiOiJuYXZpZ2F0b3IuaWQuZ2V0QXNzZXJ0aW9uIiwiY2hhbGxlbmdlIjoiN3V5NXV4cUhYZUNHZzdLMWNXbU8zYlZscl9LWTFVNWRRa1BQX3dPdmxXZyIsIm9yaWdpbiI6Imh0dHBzOi8vbG9jYWxob3N0OjQwMDAiLCJjaWRfcHVia2V5IjoidW51c2VkIn0\",\"signatureData\":\"AQAAAAAwRgIhAI4jLgLewiaFzuyyuaxlToF1OHmkItaOeASUtIStic5HAiEA8X16pOe_Sugk8O8AAh2iQdlx_98cR9UwwCwDmr_bbXo\"}"+signinSavedPubkey = BS.pack "\EOT:\230\&7\240\231\&4\250\&2\167U\243U\CANC\166\237\EM_\157+\204\DC1\203\DLE\ACK\185\135\ENQ\NUL\171\197\167Q\238my\242#\220\ACK\229\\<\205\217\132\242\200\193\&4\174|\CAN\218\201\223\n\DLE\249\239\231\157_d"+justRight (Right x) = x++main :: IO ()+main = hspec $ do+  describe "Request/Response Parsing" $ do+    it "should work for registration requests" $ do+      regRequest <- pure $ justRight $ parseRequest rawRegRequest+      (appId regRequest) `shouldBe` (T.pack "https://localhost:4000")++  describe "Registration Flow" $ do+    it "should check that request and response challenges are equivalent" $ do+      registrationVerificationFail <- return $ do+            registration <- pure $ registrationResponse {registration_challenge = (T.pack "")}+            verifyRegistration registrationRequest registration+      registrationVerificationFail `shouldBe` (Left ChallengeMismatchError)++    it "should validate properly formed request and response" $ do+      registrationVerificationSuccess <- return $ do+            verifyRegistration registrationRequest registrationResponse+      --TODO: Find a better way to test for this+      (registrationVerificationSuccess) `shouldBe` (Right $ justRight registrationVerificationSuccess)++  describe "Signin Flow" $ do+    it "should check that request and response challenges are equivalent" $ do+      signinVerificationFail <- return $ do+            signinRequest <- pure $ signinRequest {challenge = (T.pack "")}+            verifySignin signinSavedPubkey signinRequest signinResponse+      signinVerificationFail `shouldBe` (Left ChallengeMismatchError)++    it "should validate properly formed request and response" $ do+      signinVerificationSuccess <- return $ do+            verifySignin signinSavedPubkey signinRequest signinResponse+      --TODO: Find a better way to test for this+      (signinVerificationSuccess) `shouldBe` (Right $ justRight signinVerificationSuccess)
+ u2f.cabal view
@@ -0,0 +1,44 @@+-- Initial u2f.cabal generated by cabal init.  For further documentation,+-- see http://haskell.org/cabal/users-guide/++name:                u2f+version:             0.1.0.0+synopsis:            Haskell Universal Two Factor helper toolbox library thing+description:         Library useful for server-side U2F Registration and Signin flows.+homepage:            https://github.com/EButlerIV/u2f+license:             BSD3+license-file:        LICENSE+author:              Eugene Butler+maintainer:          eugene@eugene4.com+-- copyright:+category:            Web+build-type:          Simple+extra-source-files:  ChangeLog.md, README.md+cabal-version:       >=1.10++library+  exposed-modules:     U2F+  -- other-modules:+  -- other-extensions:+  build-depends:       base >=4.8 && <4.9+                       , cryptonite >= 0.17+                       , binary >= 0.8.4.0+                       , text+                       , asn1-encoding+                       , asn1-types+                       , base64-bytestring+                       , cryptohash+                       , aeson+                       , bytestring+                       , either-unwrap+  hs-source-dirs:      src+  default-language:    Haskell2010++test-suite test+  type:       exitcode-stdio-1.0+  main-is:    tests/test.hs+  build-depends:  base+                , hspec+                , u2f+                , text+                , bytestring