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 +5/−0
- LICENSE +30/−0
- README.md +3/−0
- Setup.hs +2/−0
- src/U2F.hs +278/−0
- tests/test.hs +50/−0
- u2f.cabal +44/−0
+ 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