Skip to content

Commit f422ebf

Browse files
committed
Implement CBOR canonicalisation according to CIP-21
1 parent 4bef964 commit f422ebf

File tree

6 files changed

+219
-36
lines changed

6 files changed

+219
-36
lines changed

cardano-api/cardano-api.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ library
9292
Cardano.Api.Internal.ProtocolParameters
9393
Cardano.Api.Internal.Query
9494
Cardano.Api.Internal.Script
95+
Cardano.Api.Internal.Serialise.Cbor.Canonical
9596
Cardano.Api.Internal.SerialiseLedgerCddl
9697
Cardano.Api.Internal.SerialiseTextEnvelope
9798
Cardano.Api.Internal.Tx.Body

cardano-api/src/Cardano/Api.hs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,7 @@ module Cardano.Api
758758
, readFileTextEnvelopeCddlAnyOf
759759
, deserialiseFromTextEnvelopeCddlAnyOf
760760
, writeTxFileTextEnvelopeCddl
761+
, writeTxFileTextEnvelopeCanonicalCddl
761762
, writeTxWitnessFileTextEnvelopeCddl
762763
, deserialiseByronTxCddl
763764
, serialiseWitnessLedgerCddl
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
{-# LANGUAGE LambdaCase #-}
2+
{-# LANGUAGE TypeFamilies #-}
3+
{-# OPTIONS_GHC -Wno-orphans #-}
4+
5+
module Cardano.Api.Internal.Serialise.Cbor.Canonical
6+
( canonicaliseCborBs
7+
, canonicaliseTerm
8+
)
9+
where
10+
11+
import Cardano.Api.Internal.HasTypeProxy
12+
import Cardano.Api.Internal.Serialise.Cbor
13+
14+
import Cardano.Binary (DecoderError (..))
15+
16+
import Codec.CBOR.Read (deserialiseFromBytes)
17+
import Codec.CBOR.Term
18+
( Term (..)
19+
, decodeTerm
20+
, encodeTerm
21+
)
22+
import Codec.CBOR.Write (toBuilder)
23+
import Control.Monad
24+
import Control.Monad.Except
25+
import Data.Bifunctor (first)
26+
import Data.ByteString qualified as BS
27+
import Data.ByteString.Builder qualified as BSB
28+
import Data.ByteString.Lazy qualified as LBS
29+
import Data.List (sortBy)
30+
import Data.Tuple.Extra (both)
31+
32+
-- | This function implements CBOR canonicalisation (RFC 7049):
33+
--
34+
-- * Map keys are sorted lexicographically
35+
-- * Indefinite-length maps/lists are converted to finite-length maps/lists
36+
-- * The representation of the CBOR major types is as small as possible (provided by "cborg" package)
37+
--
38+
-- This function implements only CBOR canonicalisation from CIP-21. Other requirements from CIP-21 are not implemented.
39+
--
40+
-- 1. CBOR RFC 7049, Canonicalisation description: https://datatracker.ietf.org/doc/html/rfc7049#section-3.9
41+
-- 2. CIP-21: https://github.com/cardano-foundation/CIPs/blob/master/CIP-0021/README.md#canonical-cbor-serialization-format
42+
canonicaliseCborBs :: BS.ByteString -> Either DecoderError BS.ByteString
43+
canonicaliseCborBs originalCborBytes = serialiseToCBOR . canonicaliseTerm <$> deserialiseFromCBOR AsTerm originalCborBytes
44+
45+
decodeTermFromBs
46+
:: LBS.ByteString
47+
-> Either DecoderError Term
48+
decodeTermFromBs input = do
49+
(leftover, result) <-
50+
first (DecoderErrorDeserialiseFailure "Cannot decode Term") $
51+
deserialiseFromBytes decodeTerm input
52+
unless (LBS.null leftover) $ do
53+
throwError $
54+
DecoderErrorLeftover "Invalid CBOR: some bytes were not consumed" (LBS.toStrict leftover)
55+
pure result
56+
57+
-- | This function implements CBOR canonicalisation at the Term level:
58+
--
59+
-- * Map keys are sorted lexicographically
60+
-- * Indefinite-length maps/lists are converted to finite-length maps/lists
61+
canonicaliseTerm :: Term -> Term
62+
canonicaliseTerm = \case
63+
(TMap termPairs) ->
64+
TMap . sortBy compareKeyTerms $ map (both canonicaliseTerm) termPairs
65+
(TMapI termPairs) ->
66+
TMap . sortBy compareKeyTerms $ map (both canonicaliseTerm) termPairs
67+
(TTagged tag term) ->
68+
TTagged tag $ canonicaliseTerm term
69+
(TListI terms) ->
70+
TList terms
71+
term -> term
72+
73+
-- | Implements sorting of CBOR terms for canonicalisation. CBOR terms are compared by lexical order of their
74+
-- bytes representation. We are only sorting the keys of the map here.
75+
-- See: https://datatracker.ietf.org/doc/html/rfc7049#section-3.9
76+
compareKeyTerms
77+
:: (Term, a)
78+
-- ^ (key, value) from a map
79+
-> (Term, a)
80+
-> Ordering
81+
compareKeyTerms (t1, _) (t2, _) = compare (serialiseToCBOR t1) (serialiseToCBOR t2)
82+
83+
instance HasTypeProxy Term where
84+
data AsType Term = AsTerm
85+
proxyToAsType _ = AsTerm
86+
87+
instance SerialiseAsCBOR Term where
88+
serialiseToCBOR = LBS.toStrict . BSB.toLazyByteString . toBuilder . encodeTerm
89+
deserialiseFromCBOR _proxy = decodeTermFromBs . LBS.fromStrict

cardano-api/src/Cardano/Api/Internal/SerialiseLedgerCddl.hs

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ module Cardano.Api.Internal.SerialiseLedgerCddl
2020
, readFileTextEnvelopeCddlAnyOf
2121
, deserialiseFromTextEnvelopeCddlAnyOf
2222
, writeTxFileTextEnvelopeCddl
23+
, writeTxFileTextEnvelopeCanonicalCddl
2324
, writeTxWitnessFileTextEnvelopeCddl
2425
-- Exported for testing
2526
, deserialiseByronTxCddl
@@ -37,13 +38,15 @@ import Cardano.Api.Internal.Error
3738
import Cardano.Api.Internal.HasTypeProxy
3839
import Cardano.Api.Internal.IO
3940
import Cardano.Api.Internal.Pretty
41+
import Cardano.Api.Internal.Serialise.Cbor.Canonical
4042
import Cardano.Api.Internal.SerialiseTextEnvelope
4143
( TextEnvelope (..)
4244
, TextEnvelopeDescr (TextEnvelopeDescr)
4345
, TextEnvelopeError (..)
4446
, TextEnvelopeType (TextEnvelopeType)
4547
, deserialiseFromTextEnvelope
4648
, legacyComparison
49+
, serialiseTextEnvelope
4750
, serialiseToTextEnvelope
4851
)
4952
import Cardano.Api.Internal.Tx.Sign
@@ -55,13 +58,11 @@ import Cardano.Ledger.Binary qualified as CBOR
5558

5659
import Control.Monad.Trans.Except.Extra
5760
( firstExceptT
58-
, handleIOExceptT
5961
, hoistEither
6062
, newExceptT
6163
, runExceptT
6264
)
6365
import Data.Aeson qualified as Aeson
64-
import Data.Aeson.Encode.Pretty (Config (..), defConfig, encodePretty', keyOrder)
6566
import Data.Bifunctor (first)
6667
import Data.ByteString (ByteString)
6768
import Data.ByteString.Lazy qualified as LBS
@@ -134,11 +135,10 @@ writeByronTxFileTextEnvelopeCddl
134135
:: File content Out
135136
-> Byron.ATxAux ByteString
136137
-> IO (Either (FileError ()) ())
137-
writeByronTxFileTextEnvelopeCddl path w =
138-
runExceptT $ do
139-
handleIOExceptT (FileIOError (unFile path)) $ LBS.writeFile (unFile path) txJson
140-
where
141-
txJson = encodePretty' textEnvelopeCddlJSONConfig (serializeByronTx w) <> "\n"
138+
writeByronTxFileTextEnvelopeCddl path =
139+
writeLazyByteStringFile path
140+
. serialiseTextEnvelope
141+
. serializeByronTx
142142

143143
serializeByronTx :: Byron.ATxAux ByteString -> TextEnvelope
144144
serializeByronTx tx =
@@ -211,39 +211,59 @@ deserialiseWitnessLedgerCddl sbe te =
211211
legacyDecoding _ v = v
212212

213213
writeTxFileTextEnvelopeCddl
214-
:: ()
215-
=> ShelleyBasedEra era
214+
:: ShelleyBasedEra era
216215
-> File content Out
217216
-> Tx era
218217
-> IO (Either (FileError ()) ())
219-
writeTxFileTextEnvelopeCddl era path tx =
220-
runExceptT $ do
221-
handleIOExceptT (FileIOError (unFile path)) $ LBS.writeFile (unFile path) txJson
218+
writeTxFileTextEnvelopeCddl sbe path =
219+
writeLazyByteStringFile path
220+
. serialiseTextEnvelope
221+
. serialiseTxToTextEnvelope sbe
222+
223+
-- | Write transaction in the text envelope format. The CBOR will be in canonical format according
224+
-- to RFC 7049. It is also a requirement of CIP-21, which is not fully implemented.
225+
--
226+
-- 1. RFC 7049: https://datatracker.ietf.org/doc/html/rfc7049#section-3.9
227+
-- 2. CIP-21: https://github.com/cardano-foundation/CIPs/blob/master/CIP-0021/README.md#canonical-cbor-serialization-format
228+
writeTxFileTextEnvelopeCanonicalCddl
229+
:: ShelleyBasedEra era
230+
-> File content Out
231+
-> Tx era
232+
-> IO (Either (FileError ()) ())
233+
writeTxFileTextEnvelopeCanonicalCddl sbe path =
234+
writeLazyByteStringFile path
235+
. serialiseTextEnvelope
236+
. canonicaliseTextEnvelopeCbor
237+
. serialiseTxToTextEnvelope sbe
222238
where
223-
txJson = encodePretty' textEnvelopeCddlJSONConfig (serialiseTxLedgerCddl era tx) <> "\n"
239+
canonicaliseTextEnvelopeCbor :: TextEnvelope -> TextEnvelope
240+
canonicaliseTextEnvelopeCbor te = do
241+
let canonicalisedTxBs =
242+
either
243+
( \err ->
244+
error $
245+
"writeTxFileTextEnvelopeCanonicalCddl: Impossible - deserialisation of just serialised bytes failed "
246+
<> show err
247+
)
248+
id
249+
. canonicaliseCborBs
250+
$ teRawCBOR te
251+
te{teRawCBOR = canonicalisedTxBs}
224252

225-
serialiseTxLedgerCddl :: ShelleyBasedEra era -> Tx era -> TextEnvelope
226-
serialiseTxLedgerCddl era' tx' =
227-
shelleyBasedEraConstraints era' $
228-
serialiseToTextEnvelope (Just (TextEnvelopeDescr "Ledger Cddl Format")) tx'
253+
serialiseTxToTextEnvelope :: ShelleyBasedEra era -> Tx era -> TextEnvelope
254+
serialiseTxToTextEnvelope era' tx' =
255+
shelleyBasedEraConstraints era' $ do
256+
serialiseToTextEnvelope (Just "Ledger Cddl Format") tx'
229257

230258
writeTxWitnessFileTextEnvelopeCddl
231259
:: ShelleyBasedEra era
232260
-> File () Out
233261
-> KeyWitness era
234262
-> IO (Either (FileError ()) ())
235-
writeTxWitnessFileTextEnvelopeCddl sbe path w =
236-
runExceptT $ do
237-
handleIOExceptT (FileIOError (unFile path)) $ LBS.writeFile (unFile path) txJson
238-
where
239-
txJson = encodePretty' textEnvelopeCddlJSONConfig (serialiseWitnessLedgerCddl sbe w) <> "\n"
240-
241-
textEnvelopeCddlJSONConfig :: Config
242-
textEnvelopeCddlJSONConfig =
243-
defConfig{confCompare = textEnvelopeCddlJSONKeyOrder}
244-
245-
textEnvelopeCddlJSONKeyOrder :: Text -> Text -> Ordering
246-
textEnvelopeCddlJSONKeyOrder = keyOrder ["type", "description", "cborHex"]
263+
writeTxWitnessFileTextEnvelopeCddl sbe path =
264+
writeLazyByteStringFile path
265+
. serialiseTextEnvelope
266+
. serialiseWitnessLedgerCddl sbe
247267

248268
-- | This GADT allows us to deserialise a tx or key witness without
249269
-- having to provide the era.

cardano-api/src/Cardano/Api/Internal/SerialiseTextEnvelope.hs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ module Cardano.Api.Internal.SerialiseTextEnvelope
2525
, readTextEnvelopeFromFile
2626
, readTextEnvelopeOfTypeFromFile
2727
, textEnvelopeToJSON
28+
, serialiseTextEnvelope
2829
, legacyComparison
2930

3031
-- * Reading one of several key types
@@ -115,11 +116,11 @@ instance FromJSON TextEnvelope where
115116
parseJSONBase16 v =
116117
either fail return . Base16.decode . Text.encodeUtf8 =<< parseJSON v
117118

118-
textEnvelopeJSONConfig :: Config
119-
textEnvelopeJSONConfig = defConfig{confCompare = textEnvelopeJSONKeyOrder}
119+
textEnvelopeJsonConfig :: Config
120+
textEnvelopeJsonConfig = defConfig{confCompare = textEnvelopeJsonKeyOrder}
120121

121-
textEnvelopeJSONKeyOrder :: Text -> Text -> Ordering
122-
textEnvelopeJSONKeyOrder = keyOrder ["type", "description", "cborHex"]
122+
textEnvelopeJsonKeyOrder :: Text -> Text -> Ordering
123+
textEnvelopeJsonKeyOrder = keyOrder ["type", "description", "cborHex"]
123124

124125
textEnvelopeRawCBOR :: TextEnvelope -> ByteString
125126
textEnvelopeRawCBOR = teRawCBOR
@@ -254,7 +255,11 @@ writeFileTextEnvelope outputFile mbDescr a =
254255

255256
textEnvelopeToJSON :: HasTextEnvelope a => Maybe TextEnvelopeDescr -> a -> LBS.ByteString
256257
textEnvelopeToJSON mbDescr a =
257-
encodePretty' textEnvelopeJSONConfig (serialiseToTextEnvelope mbDescr a) <> "\n"
258+
serialiseTextEnvelope $ serialiseToTextEnvelope mbDescr a
259+
260+
-- | Serialise text envelope to pretty JSON
261+
serialiseTextEnvelope :: TextEnvelope -> LBS.ByteString
262+
serialiseTextEnvelope te = encodePretty' textEnvelopeJsonConfig te <> "\n"
258263

259264
readFileTextEnvelope
260265
:: HasTextEnvelope a

cardano-api/test/cardano-api-test/Test/Cardano/Api/CBOR.hs

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,21 @@ where
1212

1313
import Cardano.Api
1414
import Cardano.Api.Internal.Script
15+
import Cardano.Api.Internal.Serialise.Cbor.Canonical (canonicaliseCborBs)
1516
import Cardano.Api.Internal.SerialiseLedgerCddl (cddlTypeToEra)
1617
import Cardano.Api.Internal.SerialiseTextEnvelope (TextEnvelopeDescr (TextEnvelopeDescr))
1718
import Cardano.Api.Shelley (AsType (..))
1819

20+
import Cardano.Binary qualified as CBOR
21+
22+
import Codec.CBOR.Read qualified as CBOR
23+
import Codec.CBOR.Term (Term (..))
24+
import Codec.CBOR.Term qualified as CBOR
1925
import Data.ByteString.Base16 qualified as Base16
26+
import Data.ByteString.Builder qualified as BS
2027
import Data.ByteString.Lazy qualified as LBS
2128
import Data.ByteString.Short qualified as SBS
29+
import Data.List (sortOn)
2230
import Data.Proxy (Proxy (..))
2331
import Data.Text (Text)
2432
import Data.Text qualified as T
@@ -28,7 +36,7 @@ import Test.Gen.Cardano.Api.Typed
2836

2937
import Test.Cardano.Api.Orphans ()
3038

31-
import Hedgehog (Property, forAll, property, tripping)
39+
import Hedgehog (Property, forAll, property, tripping, (===))
3240
import Hedgehog qualified as H
3341
import Hedgehog.Extras qualified as H
3442
import Hedgehog.Gen qualified as Gen
@@ -340,13 +348,72 @@ prop_roundtrip_GovernancePollAnswer_CBOR :: Property
340348
prop_roundtrip_GovernancePollAnswer_CBOR = property $ do
341349
H.trippingCbor AsGovernancePollAnswer =<< forAll genGovernancePollAnswer
342350

351+
-- | Test CBOR canonicalisation (according to RFC 7049, part of CIP-21)
352+
-- We're only testing ordering of the map keys and converting to finite collections here
353+
-- - the smallest representation is implemented in cborg library.
354+
prop_canonicalise_cbor :: Property
355+
prop_canonicalise_cbor = property $ do
356+
let inputMap =
357+
TMapI
358+
[ (TInt 22, TString "d")
359+
, (TInt 11, TString "a")
360+
, (TInt 1, TString "b")
361+
, (TInt 3, TString "c")
362+
, (TInt 2, TString "b")
363+
, (TBytes "aa", TString "e")
364+
, (TBytes "a", TString "f")
365+
, (TBytes "a0", TString "f")
366+
, (TBytes "bc", TString "g")
367+
, (TBytes "b", TString "g")
368+
, (TBytes "bb", TString "h")
369+
, (TBytes "ba", TListI [TString "i", TString "j"])
370+
]
371+
inputMapBs = CBOR.serialize' inputMap
372+
inputMapTerm <- decodeExampleTerm inputMapBs
373+
374+
inputMapCanonicalisedBs <- H.leftFail $ canonicaliseCborBs inputMapBs
375+
376+
inputMapCanonicalisedTerm@(TMap elemTerms) <- decodeExampleTerm inputMapCanonicalisedBs
377+
378+
H.annotate "sanity check that cbor round trip does not change the order"
379+
inputMap === inputMapTerm
380+
381+
H.annotate "Print bytes hex representation of the keys in the map"
382+
H.annotateShow
383+
. sortOn fst
384+
. map (\(e, _) -> (BS.toLazyByteString . BS.byteStringHex $ CBOR.serialize' e, e))
385+
$ elemTerms
386+
387+
H.annotate "Check that expected canonicalised CBOR is equal to the result"
388+
TMap
389+
[ (TInt 1, TString "b")
390+
, (TInt 2, TString "b")
391+
, (TInt 3, TString "c")
392+
, (TInt 11, TString "a")
393+
, (TInt 22, TString "d")
394+
, (TBytes "a", TString "f")
395+
, (TBytes "b", TString "g")
396+
, (TBytes "a0", TString "f")
397+
, (TBytes "aa", TString "e")
398+
, (TBytes "ba", TList [TString "i", TString "j"])
399+
, (TBytes "bb", TString "h")
400+
, (TBytes "bc", TString "g")
401+
]
402+
=== inputMapCanonicalisedTerm
403+
where
404+
decodeExampleTerm bs = do
405+
(leftover, term) <- H.leftFail $ CBOR.deserialiseFromBytes CBOR.decodeTerm (LBS.fromStrict bs)
406+
H.assertWith leftover LBS.null
407+
pure term
408+
343409
-- -----------------------------------------------------------------------------
344410

345411
tests :: TestTree
346412
tests =
347413
testGroup
348414
"Test.Cardano.Api.Typed.CBOR"
349-
[ testProperty "rountrip txbody text envelope" prop_text_envelope_roundtrip_txbody_CBOR
415+
[ testProperty "test canonicalisation of CBOR" prop_canonicalise_cbor
416+
, testProperty "rountrip txbody text envelope" prop_text_envelope_roundtrip_txbody_CBOR
350417
, testProperty "txbody backwards compatibility" prop_txbody_backwards_compatibility
351418
, testProperty "rountrip tx text envelope" prop_text_envelope_roundtrip_tx_CBOR
352419
, testProperty "roundtrip witness CBOR" prop_roundtrip_witness_CBOR

0 commit comments

Comments
 (0)