frasertweedale/hs-jose

decodeCompact . encodeCompact /= pure

brandon-leapyear opened this issue · 23 comments

GHCi Repro:

import Control.Monad.Except
import Crypto.JWT
jwk <- genJWK $ RSAGenParam 256
let header = newJWSHeader ((), RS512)
:{
runExceptT $ do
  token <- signClaims jwk header emptyClaimsSet
  token' <- decodeCompact . encodeCompact $ token
  return $ token == token'
  :: IO (Either JWTError Bool)
:}

The above returns Right True in lts-18.0 and Right False in nightly-2021-07-12

Thank you for the report. Can you please also print the encoded tokens?

This is an old work account. Please reference @brandonchinn178 for all future communication


By virtue of genJWK, it'll be different each time, but here's an example from a run:

eyJhbGciOiJSUzUxMiJ9.e30.bzEhwbJwcrrsFeBU_3TXZfRChZKWyG2vYDbwbAlx6LD4EnSw-lU1zWalOJGVAIWNAWia4JMBSSSkfGO5FXTYvjdYlExo_ikx7XU4cDytWxhoE8yrtId0Eo2y_snPfR0zb9FD7oddTAygwRyeOPMCMUoZ9icvYhFFtZCZP7OKCnsDm4R45UOReyh_zJbgEO2MWGVIQK0vgOpT9fpM96SPVVUKisVa_HcqFNciHkFhdoddQMvXVb53alNaaeijE6S0PWmrOLh-dhX43xAYqozQyFSUi38lfCb-vMQFOGucz9l1WePWZ_sAxve1Q2wiwTWCoIbYNggoa7nR1UieS-jmPA

Notably, when decoding it, I get the following in the nightly snapshot

Right JWS Base64Octets "\160)" Identity (...)

as opposed to lts-18:

Right JWS Base64Octets "{}" Identity (...)

Very strange. I probably won't get around to investigating it until the weekend.

Can you please advise the platform, default character set, etc?

This is an old work account. Please reference @brandonchinn178 for all future communication


Weirdly, it might be some race condition in the applicative FromJSON (JWS Identity p a) implementation.

let Object obj = object [("payload", "e30"), ("protected", "eyJhbGciOiJSUzUxMiJ9"), ("signature", "bzEhwbJwcrrsFeBU_3TXZfRChZKWyG2vYDbwbAlx6LD4EnSw-lU1zWalOJGVAIWNAWia4JMBSSSkfGO5FXTYvjdYlExo_ikx7XU4cDytWxhoE8yrtId0Eo2y_snPfR0zb9FD7oddTAygwRyeOPMCMUoZ9icvYhFFtZCZP7OKCnsDm4R45UOReyh_zJbgEO2MWGVIQK0vgOpT9fpM96SPVVUKisVa_HcqFNciHkFhdoddQMvXVb53alNaaeijE6S0PWmrOLh-dhX43xAYqozQyFSUi38lfCb-vMQFOGucz9l1WePWZ_sAxve1Q2wiwTWCoIbYNggoa7nR1UieS-jmPA")]
parse (\o -> (,) <$> o .: "payload" <*> parseJSON (Object o)) obj :: Result (Base64Octets, Signature () JWSHeader)

Running the second line repeatedly sometimes shows Base64Octets "{}" and sometimes shows Base64Octets "\160)", whereas repeatedly running

parse (\o -> (,) <$> o .: "payload" <*> pure ()) obj' :: Result (Base64Octets, ())

consistently shows Base64Octets "{}".

It's strange; it seems like somehow the parseJSON instance of Signature is affecting how o .: "payload" is parsed?

Can you please advise the platform, default character set, etc?

I'm on Mac OS, using stack ghci. I should be using en_US.UTF-8, but I'm not sure how to verify that

Update: verified my locale:

$ locale
LANG="en_US.UTF-8"
LC_COLLATE="en_US.UTF-8"
LC_CTYPE="en_US.UTF-8"
LC_MESSAGES="en_US.UTF-8"
LC_MONETARY="en_US.UTF-8"
LC_NUMERIC="en_US.UTF-8"
LC_TIME="en_US.UTF-8"
LC_ALL=

This is an old work account. Please reference @brandonchinn178 for all future communication


More data points:
Rewriting with monadic actions doesn't help, still flakily showing both {} and \160):

parse (\o -> (,) <$> o .: "payload" <*> parseJSON (Object o) ) obj :: Result (Base64Octets, Signature () JWSHeader)
parse (\o -> o .: "payload" >>= \p -> parseJSON (Object o) >>= \s -> pure (p, s)) obj :: Result (Base64Octets, Signature () JWSHeader)

But doing the parseJSON first (either applicative or monadic) consistently shows {}:

parse (\o -> (,) <$> parseJSON (Object o) <*> o .: "payload") obj :: Result (Signature () JWSHeader, Base64Octets)
parse (\o -> parseJSON (Object o) >>= \s -> o .: "payload" >>= \p -> pure (p, s)) obj :: Result (Base64Octets, Signature () JWSHeader)

MacOS... what architecture? Is it one of those new ARM CPUs?

@brandon-leapyear what happens if you try:

parse (\o -> (,) <$> o .: "payload" <*> parseJSON (Object o) ) obj
  :: Result (T.Text, Signature () JWSHeader)

Does it give consistent results?

This is an old work account. Please reference @brandonchinn178 for all future communication


Yeah, it's consistently e30

Baffling. I'll try and find time to set up an environment and repro later this week.

This is an old work account. Please reference @brandonchinn178 for all future communication


Got it repro'd here: master...brandon-leapyear:chinn/decode-encode

All the existing tests + the new test passes until switching the compiler to GHC 9. Then it gets two test failures: "JWK round-trip" and the new "JWT compact round-trip" test I wrote. (Not sure where to put the new "JWT compact round-trip" test, or if it should be broken down even more. In any case, none of the existing tests seem to be catching it, so there's some test coverage lacking somewhere). Frustratingly, it seems that the new "JWT compact round-trip" test only fails when run in parallel; it passes when it's the only test running.

It probably has to do with parseB64Url, as I'm noticing the breakages happening around Base64Octets and Base64Integer. The failing test case for JWK seems to decode all the Base64Integer values as 128. Switching from base64-bytestring-1.1.0.0 to base64-bytestring-1.2.0.1 decodes all the Base64Integer values as 64.

This is an old work account. Please reference @brandonchinn178 for all future communication


Ah yes, it's probably parseB64Url. The following results are consistent:

ghci> parse (parseB64Url pure) "e30"
Success "{}"
ghci> (,) <$> parse (parseB64Url pure) "e30" <*> parse (parseB64Url pure) "e30"
Success ("\160I","\160I")

Specifically, base64url:

ghci> preview base64url ("e30" :: ByteString) :: Maybe ByteString 
Just "{}"
ghci> (,) <$> preview base64url ("e30" :: ByteString) <*> preview base64url ("e30" :: ByteString) :: Maybe (ByteString, ByteString)
Just ("\160\137","\160\137")

oddly enough, it changes between lazy and strict bytestring, although i don't think it's relevant here

ghci> preview base64url ("e30" :: ByteString) :: Maybe Lazy.ByteString 
Just "\160\137"
ghci> preview base64url ("e30" :: ByteString) :: Maybe ByteString 
Just "{}"

@brandon-leapyear I am able to reproduce it on Linux with GHC 9.0.1. Still, I don't have much time to devote to analysing this right now. It might take a little while.

@brandon-leapyear it seems the issue is in the base64-bytestring library itself:

ghci> import qualified Data.ByteString.Base64.URL as B64U
ghci> :set -XOverloadedStrings
ghci> emptyObj = "e30" :: B.ByteString
ghci> (,) <$> B64U.decodeUnpadded emptyObj <*> B64U.decodeUnpadded emptyObj :: Either String (B.ByteString, B.ByteString)
Right (" \161","{}")

@brandon-leapyear are you happy for me to push your improvements to the jose test suite?

Did you open an issue in base64-bytestring, or shall I?

@brandon-leapyear I opened it: haskell/base64-bytestring#44

I will leave this ticket open until:

  • I push the test improvements contributed by @brandon-leapyear
  • base64-bytestring merges a fix and cuts a new release
  • I bump jose deps to depend on fixed base64-bytestring and cut a new release.

I've provided a PR in base64-bytestring#46 that I think fixes this. Could you folks confirm that this works on your end?

  1. Add a cabal.project (or stack equivalent) pinning base64-bytestring to 4114eabcc61a44d406583d90fd4bff7bc28aed40:
packages: .

source-repository-package
    type: git
    location: https://github.com/haskell/base64-bytestring.git
    tag: 4114eabcc61a44d406583d90fd4bff7bc28aed40
  1. build the project and confirm the fix works:
...
[18 of 18] Compiling Crypto.JWT       ( src/Crypto/JWT.hs, interpreted )
Ok, 18 modules loaded.
П> import Control.Monad.Except
П> import Crypto.JWT
П> jwk <- genJWK $ RSAGenParam 256

<interactive>:3:1: warning: [-Wname-shadowing]
    This binding for jwk shadows the existing binding
      imported from Crypto.JWT
      (and originally defined in Crypto.JOSE.Header
         at src/Crypto/JOSE/Header.hs:322:3-48)
П> let header = newJWSHeader ((), RS512)

<interactive>:4:5: warning: [-Wname-shadowing]
    This binding for header shadows the existing binding
      imported from Crypto.JWT
      (and originally defined in Crypto.JOSE.JWS
         at src/Crypto/JOSE/JWS.hs:290:1-6)
П> :{
*Crypto.JOSE Control.Monad.Except Crypto.JWT| runExceptT $ do
*Crypto.JOSE Control.Monad.Except Crypto.JWT|   token <- signClaims jwk header emptyClaimsSet
*Crypto.JOSE Control.Monad.Except Crypto.JWT|   token' <- decodeCompact . encodeCompact $ token
*Crypto.JOSE Control.Monad.Except Crypto.JWT|   return $ token == token'
*Crypto.JOSE Control.Monad.Except Crypto.JWT|   :: IO (Either JWTError Bool)
*Crypto.JOSE Control.Monad.Except Crypto.JWT| :}
Right True

If you tell me it's good, I'll have a fix out within 24 hours of you giving the 👍

This is an old work account. Please reference @brandonchinn178 for all future communication


I've been using the fix in haskell/base64-bytestring#45, which works. Just verified that the fix in haskell/base64-bytestring#46 also seems to work.

New release: https://hackage.haskell.org/package/jose-0.8.4.1

Thanks @brandon-leapyear for all your assistance with this issue!