tahoe-chk-0.2.0.0: test/SpecCHK.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
module SpecCHK (
tests,
testsFromVectors,
) where
import Control.Arrow (
(&&&),
)
import Control.Lens (view)
import Control.Monad.IO.Class (MonadIO (liftIO))
import Crypto.Cipher.AES (AES128)
import qualified Data.Binary as Binary
import Data.ByteArray (convert)
import qualified Data.ByteString as B
import qualified Data.ByteString.Base64 as Base64
import qualified Data.ByteString.Lazy as BL
import Data.Coerce (coerce)
import Data.Either (isLeft)
import Data.Text (
concat,
unpack,
)
import Data.TreeDiff.Class (ToExpr, ediff)
import Data.TreeDiff.Pretty (prettyEditExpr)
import GHC.Generics (Generic)
import Generators (
applyShareBitFlips,
blockBitFlipper,
crypttextTreeLeafBitFlipper,
digests,
fingerprintBitFlipper,
genParameters,
shareTreeLeafBitFlipper,
shares,
)
import Hedgehog (
Property,
annotateShow,
assert,
diff,
forAll,
property,
tripping,
)
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
import Tahoe.CHK (padCiphertext)
import qualified Tahoe.CHK (decode, encode, segmentCiphertext)
import Tahoe.CHK.Capability (Reader, dangerRealShow, pCapability, pReader, verifier)
import Tahoe.CHK.Cipher (Key)
import Tahoe.CHK.Crypto (ciphertextSegmentHash', convergenceSecretLength)
import Tahoe.CHK.Encrypt (encryptLazy)
import Tahoe.CHK.Share (
Share (
_blockSize
),
uriExtension,
)
import Tahoe.CHK.Types (
Parameters (..),
)
import Tahoe.CHK.URIExtension (codecParams)
import Tahoe.CHK.Upload (
UploadResult (..),
Uploadable (..),
adjustSegmentSize,
getConvergentKey,
memoryUploadableWithConvergence,
store,
)
import Tahoe.CHK.Validate (
matchingBlockHashRoot,
validFingerprint,
validSegment,
validShareRootHash,
)
import Tahoe.Server (
nullStorageServer,
)
import Test.Tasty (
TestTree,
testGroup,
)
import Test.Tasty.HUnit (
Assertion,
assertBool,
assertEqual,
assertFailure,
testCase,
)
import Test.Tasty.Hedgehog (testProperty)
import Text.Megaparsec (parse)
import Vectors (
Format (..),
JSONByteString (..),
Sample (..),
TestCase (..),
VectorSpec (..),
WellKnown (..),
loadWellKnownCase,
)
{- | Encrypt and encode some plaintext using some parameters, producing some
shares and a read capability.
A hard-coded convergence secret is used for simplicity and reproducibility.
-}
makeValidShares :: Parameters -> BL.ByteString -> IO ([Share], Reader)
makeValidShares params plaintext = Tahoe.CHK.encode key params (encryptLazy key plaintext)
where
key = getConvergentKey "secret" params plaintext
assertEqual' :: (Generic a, ToExpr a, Eq a) => a -> a -> Assertion
assertEqual' a b = assertBool (show . prettyEditExpr $ ediff a b) (a == b)
-- | Create tests for each case in the test vector specification.
testsFromVectors :: VectorSpec -> TestTree
testsFromVectors vectorSpec =
testGroup
"Vectors"
[ testCap vectorSpec
, testCapabilityParser vectorSpec
]
tests :: TestTree
tests =
testGroup
"CHK"
[ testEncrypt
, testProperty "expand returns the correct number of bytes" prop_expand_length
, testProperty "expand returns bytes containing the template repeated" prop_expand_template
, testProperty "Share round-trips through put / get" prop_share_roundtrip
, testWellKnownShare1of2
, testWellKnownShare2of3
, testWellKnownShare3of10
, testProperty "segmentCiphertext preserves all of the ciphertext" prop_segmentCiphertext_identity
, testProperty "padCiphertext returns a string with a length that is a multiple of the given requiredShares value" prop_paddedCiphertext_boundary
, testProperty "ciphertext round-trips through decode . encode" prop_share_encoding_roundtrip
, testSizes
, testOutOfBoundsShareNumbers
, testProperty "decode signals error if the integrity of the shares is compromised" propIntegrity
, testProperty "validSegment returns False if called with a hash not related to a ciphertext by the ciphertext segment hash function" propInvalidSegment
]
data Described descr b = Described descr b
instance Show descr => Show (Described descr b) where
show (Described descr _) = show descr
{- | Tahoe.CHK.decode returns a Left value if the capability fingerprint does
not equal the URI extension block hash for any share to be decoded.
-}
propIntegrity :: Property
propIntegrity = property $ do
-- First synthesize some intact shares and the associated read capability.
plaintext <- forAll $ BL.fromStrict <$> Gen.bytes (Range.linear 1 1024)
params <- forAll $ fixParams <$> genParameters
valid@(validShares, validCap) <- liftIO $ makeValidShares params plaintext
annotateShow validShares
annotateShow validCap
-- Pick a function to use to screw them up somehow. Wrap them in
-- something Showable for the sake of `forAll`.
let mungers =
[ fingerprintBitFlipper validCap
, shareTreeLeafBitFlipper params validShares
, blockBitFlipper params validShares
, crypttextTreeLeafBitFlipper params validShares
]
munge <- forAll $ Gen.choice mungers
-- Verify that decoding with the screwed up values signals a lack of
-- integrity.
let munged@(mungedShares, mungedCap) = applyShareBitFlips munge valid
annotateShow mungedShares
annotateShow mungedCap
-- Sanity check - something must have changed or the decode _should_
-- succeed (not what we want to test here).
diff valid (/=) munged
-- Show us the difference
annotateShow $ prettyEditExpr (ediff valid munged)
let taggedShares = zip [0 ..] mungedShares
shuffledShares <- forAll $ Gen.shuffle taggedShares
result <- liftIO $ Tahoe.CHK.decode mungedCap shuffledShares
diff True (==) (isLeft result)
testSizes :: TestTree
testSizes =
testCase "the maximum segment size encoded in the UEB equals the actual segment size" $ do
uploadable <- memoryUploadableWithConvergence (B.replicate 32 0x00) (fromIntegral $ BL.length ciphertext) ciphertext params
(shares', _cap) <- Tahoe.CHK.encode (uploadableKey uploadable) params ciphertext
mapM_ (assertEqual "The shareBlockSize reflects the parameters and real ciphertext size" (fromIntegral $ BL.length ciphertext `div` 2) . _blockSize) shares'
mapM_ (assertEqual "The segment size is reduced to the ciphertext size" (fromIntegral $ BL.length ciphertext) . getSegmentSize) shares'
where
getSegmentSize = paramSegmentSize . view (uriExtension . codecParams)
params =
Parameters
{ paramSegmentSize = 100000
, paramTotalShares = 3
, paramHappyShares = 1
, paramRequiredShares = 2
}
ciphertext = BL.pack [1 .. 56]
{- | segmentCiphertext may split up ciphertext but it may not change its
content in any way.
-}
prop_segmentCiphertext_identity :: Property
prop_segmentCiphertext_identity = property $ do
ciphertext <- forAll $ BL.fromStrict <$> Gen.bytes (Range.linear 1 1024)
params <- forAll genParameters
let segments = Tahoe.CHK.segmentCiphertext params ciphertext
recovered = BL.concat segments
diff ciphertext (==) recovered
prop_paddedCiphertext_boundary :: Property
prop_paddedCiphertext_boundary = property $ do
ciphertext <- forAll $ BL.fromStrict <$> Gen.bytes (Range.linear 1 1024)
Parameters{paramRequiredShares} <- forAll genParameters
let padded = padCiphertext paramRequiredShares ciphertext
diff (BL.length padded `mod` fromIntegral paramRequiredShares) (==) 0
{- | Assert that:
* shares of a certain well-known case can be decoded and re-encoded to the same byte sequences
* we can create those same shares and the corresponding capability by re-encoding the same inputs
* we can validate the UEB fingerprint for each share
* we can validate the share tree root hash included in each share
-}
wellKnownCase :: WellKnown -> Assertion
wellKnownCase WellKnown{..} =
do
uploadable <- memoryUploadableWithConvergence wellKnownConvergenceSecret (fromIntegral $ BL.length wellKnownPlaintext) wellKnownPlaintext wellKnownParameters
let ciphertext = encryptLazy (uploadableKey uploadable) wellKnownPlaintext
(shares', cap) <- Tahoe.CHK.encode (uploadableKey uploadable) wellKnownParameters ciphertext
let allValid = replicate (fromIntegral $ paramTotalShares wellKnownParameters) True
let expectedShares = Binary.decode <$> wellKnownShares
encodedShares = Binary.encode <$> shares'
assertEqual' expectedShares shares'
assertEqual' wellKnownShares encodedShares
assertEqual "The cap matches" cap wellKnownCapability
assertEqual "The fingerprint matches" allValid ((validFingerprint . view verifier $ wellKnownCapability) <$> expectedShares)
assertEqual "The block tree root hash matches the proof" allValid (zipWith matchingBlockHashRoot [0 ..] expectedShares)
assertEqual "The share tree root hash is consistent" allValid (validShareRootHash $ zip [0 ..] expectedShares)
pure ()
testWellKnownShare1of2 :: TestTree
testWellKnownShare1of2 = testCase "a known 1-of-2 case encodes as expected" (loadWellKnownCase params cap >>= wellKnownCase)
where
params =
Parameters
{ paramSegmentSize = 8
, paramHappyShares = 1
, paramRequiredShares = 1
, paramTotalShares = 2
}
cap = "URI:CHK:pyv3qypbpk6knq5ozeibenuubq:jh3twlgmxtytwqtzn6jtbsfy2w574ybkcnalurlnlq2snuu3j5da:1:2:56"
testWellKnownShare2of3 :: TestTree
testWellKnownShare2of3 = testCase "a known 2-of-3 case encodes as expected" (loadWellKnownCase params cap >>= wellKnownCase)
where
params =
Parameters
{ paramSegmentSize = 8
, paramHappyShares = 1
, paramRequiredShares = 2
, paramTotalShares = 3
}
cap = "URI:CHK:co4s2wzrwos726nu24ervz2ffu:orrq3znudwnwgcazuc7qbm3prf4a46c3gmboecbror4l2k62jtkq:2:3:56"
testWellKnownShare3of10 :: TestTree
testWellKnownShare3of10 = testCase "a known 3-of-10 case encodes as expected" (loadWellKnownCase params cap >>= wellKnownCase)
where
params =
Parameters
{ paramSegmentSize = 8
, paramHappyShares = 1
, paramRequiredShares = 3
, paramTotalShares = 10
}
cap = "URI:CHK:o4lpfdvt7ib5xei2qhz6ovkz34:uvhgccbgigj4gfqfeyh5g5uogyt7etmlmqnvswqxumm7q3rqh7uq:3:10:56"
prop_share_encoding_roundtrip :: Property
prop_share_encoding_roundtrip = property $ do
convergenceSecret <- forAll $ Gen.bytes (Range.singleton 32)
ciphertext <- forAll $ BL.fromStrict <$> Gen.bytes (Range.linear 1 2048)
params <- forAll $ fixParams <$> genParameters
let key = getConvergentKey convergenceSecret (adjustSegmentSize params (fromIntegral $ BL.length ciphertext)) ciphertext
(shares', cap) <- liftIO $ Tahoe.CHK.encode key params ciphertext
recovered <- liftIO $ Tahoe.CHK.decode cap (zip [0 ..] shares')
diff (Right ciphertext) (==) recovered
-- XXX Our ZFEC bindings are unhappy with k == n. genParameters will
-- happily give us that so adjust k or n if we happen to hit such a case.
fixParams :: Parameters -> Parameters
fixParams p@Parameters{paramRequiredShares = 256, paramTotalShares = 256} = p{paramRequiredShares = 255}
fixParams p@Parameters{paramRequiredShares, paramTotalShares}
| paramRequiredShares == paramRequiredShares = p{paramTotalShares = paramTotalShares + 1}
| otherwise = p
prop_share_roundtrip :: Property
prop_share_roundtrip =
let decode' = ((\(_, _, sh) -> sh) <$>) . Binary.decodeOrFail
in property $ do
share <- forAll shares
tripping share Binary.encode decode'
testEncrypt :: TestTree
testEncrypt =
testGroup
"chkEncrypt"
[ testCase "ciphertext" $ do
assertEqual
"expected convergence key"
"oBcuR/wKdCgCV2GKKXqiNg=="
(Base64.encode $ convert convergenceKey)
let b64ciphertext = Base64.encode (BL.toStrict ciphertext)
assertEqual "known result" knownCorrect b64ciphertext
]
where
-- For all the magic values see
-- allmydata.test.test_upload.FileHandleTests.test_get_encryption_key_convergent
knownCorrect :: B.ByteString
knownCorrect = "Jd2LHCRXozwrEJc="
plaintext :: BL.ByteString
plaintext = "hello world"
ciphertext :: BL.ByteString
ciphertext = encryptLazy convergenceKey plaintext
convergenceKey :: Key AES128
convergenceKey = getConvergentKey convergenceSecret params plaintext
convergenceSecret = B.replicate convergenceSecretLength 0x42
params =
adjustSegmentSize
Parameters
{ paramSegmentSize = 128 * 1024
, paramTotalShares = 10
, paramHappyShares = 5
, paramRequiredShares = 3
}
(fromIntegral $ BL.length plaintext)
{- | Build a test tree that applies a test function to every CHK case in a
test vector.
-}
chkTests ::
-- | A name to give the group of tests.
String ->
-- | A function to call with a CHK test case to get back a test.
(TestCase -> Assertion) ->
-- | The test vector containing CHK test cases.
VectorSpec ->
-- | A test tree with one test per CHK case in the test vector.
TestTree
chkTests name makeOneTest =
testGroup name . map (uncurry ($) . (testCase . unpack . expected &&& makeOneTest)) . filter pickCase . vector
where
pickCase TestCase{format, zfec} = format == CHK && (paramTotalShares zfec > paramRequiredShares zfec && paramTotalShares zfec < 256)
{- | Every CHK case in the test vector can be reproduced by this
implementation.
-}
testCap :: VectorSpec -> TestTree
testCap = chkTests "chkCap" testOneCase
{- | Every CHK capability in the test vector can be parsed and then serialized
back to the same byte string.
-}
testCapabilityParser :: VectorSpec -> TestTree
testCapabilityParser = chkTests "testCapabilityParser" testParseOneCapability
{- | Assert that a specific CHK capability can be parsed and serialized back
to the same byte string.
-}
testParseOneCapability :: TestCase -> Assertion
testParseOneCapability TestCase{expected} = do
serialized <- case parse pCapability "" expected of
Left err -> assertFailure $ show err
Right cap -> pure $ dangerRealShow cap
assertEqual "expected /= serialized" expected serialized
{- | Assert that verify and read capability strings with n/k/size below the
minimum legal or above the maximum legal value are rejected by the parser.
-}
testOutOfBoundsShareNumbers :: TestTree
testOutOfBoundsShareNumbers =
testCase
"out-of-bounds share numbers cause capability string parse errors"
$ mapM_ assertParseFail cases
where
cases =
[ -- Verify caps with n/k/size too small
"URI:CHK-Verifier:yzxcoagbetwet65ltjpbqyli3m:6b7inuiha2xdtgqzd55i6aeggutnxzr6qfwpv2ep5xlln6pgef7a:0:1:56"
, "URI:CHK-Verifier:yzxcoagbetwet65ltjpbqyli3m:6b7inuiha2xdtgqzd55i6aeggutnxzr6qfwpv2ep5xlln6pgef7a:1:0:56"
, "URI:CHK-Verifier:yzxcoagbetwet65ltjpbqyli3m:6b7inuiha2xdtgqzd55i6aeggutnxzr6qfwpv2ep5xlln6pgef7a:1:1:0"
, -- Read caps with n/k/size too small
"URI:CHK:yzxcoagbetwet65ltjpbqyli3m:6b7inuiha2xdtgqzd55i6aeggutnxzr6qfwpv2ep5xlln6pgef7a:0:1:56"
, "URI:CHK:yzxcoagbetwet65ltjpbqyli3m:6b7inuiha2xdtgqzd55i6aeggutnxzr6qfwpv2ep5xlln6pgef7a:1:0:56"
, "URI:CHK:yzxcoagbetwet65ltjpbqyli3m:6b7inuiha2xdtgqzd55i6aeggutnxzr6qfwpv2ep5xlln6pgef7a:1:1:0"
, -- Verify caps with n/k/size too large
"URI:CHK-Verifier:yzxcoagbetwet65ltjpbqyli3m:6b7inuiha2xdtgqzd55i6aeggutnxzr6qfwpv2ep5xlln6pgef7a:257:256:1000"
, "URI:CHK-Verifier:yzxcoagbetwet65ltjpbqyli3m:6b7inuiha2xdtgqzd55i6aeggutnxzr6qfwpv2ep5xlln6pgef7a:256:257:1000"
, "URI:CHK-Verifier:yzxcoagbetwet65ltjpbqyli3m:6b7inuiha2xdtgqzd55i6aeggutnxzr6qfwpv2ep5xlln6pgef7a:256:256:18446744073709551616"
, -- Read caps with n/k/size too large
"URI:CHK:yzxcoagbetwet65ltjpbqyli3m:6b7inuiha2xdtgqzd55i6aeggutnxzr6qfwpv2ep5xlln6pgef7a:257:256:1000"
, "URI:CHK:yzxcoagbetwet65ltjpbqyli3m:6b7inuiha2xdtgqzd55i6aeggutnxzr6qfwpv2ep5xlln6pgef7a:256:257:1000"
, "URI:CHK:yzxcoagbetwet65ltjpbqyli3m:6b7inuiha2xdtgqzd55i6aeggutnxzr6qfwpv2ep5xlln6pgef7a:256:256:18446744073709551616"
]
assertParseFail s =
case parse pCapability "" s of
Left _err -> pure ()
Right cap ->
assertFailure . unpack . Data.Text.concat $
[ "Expected parse failure of "
, s
, " instead got "
, dangerRealShow cap
]
{- | Assert that a specific CHK case can be reproduced by this implementation.
This means we can encode the same plaintext using the same secrets to the
same ciphertext and share layout and that the resulting capability string
is the same byte sequence as given by the test vector.
-}
testOneCase :: TestCase -> Assertion
testOneCase
TestCase
{ convergence
, format = CHK
, sample
, zfec
, expected
} =
do
uploadable <- memoryUploadableWithConvergence (coerce convergence) (fromIntegral $ sampleLength sample) (BL.fromStrict $ expand sample) zfec
upresult <- store [nullStorageServer] uploadable
assertEqual "yes" (parse pReader "" expected) (Right $ uploadResultReadCap upresult)
testOneCase x = error $ "testOneCase got bad input" <> show x
expand :: Sample -> B.ByteString
expand (Sample sampleTemplate sampleLength) =
B.take sampleLength . B.concat $ take sampleLength (replicate n bs)
where
n = (sampleLength `div` B.length bs) + 1
bs = coerce sampleTemplate -- yuck
prop_expand_length :: Property
prop_expand_length =
property $ do
sample <- forAll $ Sample <$> (JSONByteString <$> Gen.bytes (Range.linear 1 16)) <*> Gen.int (Range.linear 1 1000)
diff (sampleLength sample) (==) (B.length $ expand sample)
prop_expand_template :: Property
prop_expand_template =
property $ do
template <- forAll $ Gen.bytes (Range.linear 1 16)
sample <- forAll $ Sample (JSONByteString template) <$> Gen.int (Range.linear 1 1000)
assert $ checkTemplate template (expand sample)
where
checkTemplate :: B.ByteString -> B.ByteString -> Bool
checkTemplate _ "" = True
checkTemplate template expanded =
all (uncurry (==)) (B.zip template expanded)
&& checkTemplate template (B.drop (B.length template) expanded)
propInvalidSegment :: Property
propInvalidSegment = property $ do
ciphertext <- forAll $ Gen.bytes (Range.linear 1 64)
expected <- forAll $ Gen.filterT (ciphertextSegmentHash' ciphertext /=) digests
diff Nothing (==) (validSegment expected ciphertext)