diff --git a/flake.nix b/flake.nix index 7b393e118ddd63bdf4f80c39c38fb8a07e3bc208..f5feba570df0b1b4aec8cd7a77f11c664b788aca 100644 --- a/flake.nix +++ b/flake.nix @@ -58,7 +58,7 @@ text = '' cabal update hackage.haskell.org - cabal build tests + cabal build cabal run tests ''; } diff --git a/make-keypairs/Main.hs b/make-keypairs/Main.hs new file mode 100644 index 0000000000000000000000000000000000000000..c537ae4d3420c2c0ee42f80ac67c4d3cd281cd2b --- /dev/null +++ b/make-keypairs/Main.hs @@ -0,0 +1,27 @@ +module Main where + +import Codec.Crypto.RSA (generateKeyPair) +import Crypto.Random (CryptoRandomGen (newGenIO), SystemRandom) +import Data.ASN1.BinaryEncoding (DER (DER)) +import Data.ASN1.Encoding (ASN1Encoding (encodeASN1)) +import Data.ASN1.Types (ASN1Object (toASN1)) +import qualified Data.ByteString.Lazy as LB + +-- | The size of the keys to generate. +bits :: Int +bits = 2048 + +-- | The number of keys to generate. +count :: Int +count = 5 + +main :: IO () +main = do + g <- newGenIO :: IO SystemRandom + mapM_ (genKey g) [0 .. count - 1] + +genKey :: (Show a, CryptoRandomGen c) => c -> a -> IO () +genKey g n = + let (_, priv, _) = generateKeyPair g bits + bytes = encodeASN1 DER (toASN1 priv []) + in LB.writeFile ("test/data/rsa-privkey-" <> show n <> ".der") bytes diff --git a/src/Tahoe/SDMF/Internal/Share.hs b/src/Tahoe/SDMF/Internal/Share.hs index 392cdf44f22abcd9d6c29884e649d33614570f5d..f4587950a072be0798d3b09a37608e1dfdaf841e 100644 --- a/src/Tahoe/SDMF/Internal/Share.hs +++ b/src/Tahoe/SDMF/Internal/Share.hs @@ -1,13 +1,46 @@ -- | Deal with details related to the structural layout of an SDMF share. module Tahoe.SDMF.Internal.Share where +import Control.Monad (unless) import Crypto.Cipher.AES (AES128) -import Crypto.Types (IV) -import qualified Crypto.Types.PubKey.RSA as RSA +import qualified Crypto.PubKey.RSA.Types as RSA +import Crypto.Types (IV (IV, initializationVector)) +import Data.ASN1.BinaryEncoding (DER (DER)) +import Data.ASN1.Encoding (ASN1Encoding (encodeASN1), decodeASN1') +import Data.ASN1.Types (ASN1Object (fromASN1, toASN1)) +import Data.Binary (Binary (..), Get, getWord8) +import Data.Binary.Get (bytesRead, getByteString, getLazyByteString, getRemainingLazyByteString, getWord16be, getWord32be, getWord64be, isEmpty, isolate) +import Data.Binary.Put (putByteString, putLazyByteString, putWord16be, putWord32be, putWord64be, putWord8) import qualified Data.ByteString as B import qualified Data.ByteString.Lazy as LB -import Data.Word (Word64, Word8) -import Tahoe.CHK.Merkle (MerkleTree) +import Data.Word (Word16, Word64, Word8) +import Data.X509 (PubKey (PubKeyRSA)) +import Tahoe.CHK.Merkle (MerkleTree, leafHashes) + +hashSize :: Int +hashSize = 32 + +newtype HashChain = HashChain + { hashChain :: [(Word16, B.ByteString)] + } + deriving newtype (Eq, Show) + +instance Binary HashChain where + put (HashChain []) = mempty + put (HashChain ((n, h) : c)) = do + putWord16be n + putByteString h + put (HashChain c) + + get = do + empty <- isEmpty + if empty + then pure $ HashChain [] + else do + n <- getWord16be + h <- getByteString hashSize + (HashChain c) <- get + pure $ HashChain ((n, h) : c) {- | Structured representation of a single version SDMF share. @@ -43,7 +76,7 @@ data Share = Share shareSignature :: B.ByteString , -- | The share numbers and shareRootHash values which are required to -- ... something about verification I dunno. XXX - shareHashChain :: [(Word8, B.ByteString)] + shareHashChain :: HashChain , -- | A merkle tree where leaves are the hashes of the blocks in this share. shareBlockHashTree :: MerkleTree , -- | The share data (erasure encoded ciphertext). @@ -51,3 +84,88 @@ data Share = Share , -- | The encrypted 2048 bit "signature" RSA key. shareEncryptedPrivateKey :: B.ByteString } + deriving (Eq, Show) + +instance Binary Share where + put Share{..} = do + putWord8 0 + putWord64be shareSequenceNumber + putByteString shareRootHash + putByteString . initializationVector $ shareIV + putWord8 shareTotalShares + putWord8 shareRequiredShares + putWord64be shareSegmentSize + putWord64be shareDataLength + putWord32be signatureOffset + putWord32be hashChainOffset + putWord32be blockHashTreeOffset + putWord32be shareDataOffset + putWord64be encryptedPrivateKeyOffset + putWord64be eofOffset + putByteString verificationKeyBytes + putByteString shareSignature + put shareHashChain + put shareBlockHashTree + putLazyByteString shareData + putByteString shareEncryptedPrivateKey + where + verificationKeyBytes = LB.toStrict . encodeASN1 DER . flip toASN1 [] . PubKeyRSA $ shareVerificationKey + blockHashTreeBytes = B.concat . leafHashes $ shareBlockHashTree + + -- TODO Compute these from all the putting. + signatureOffset = fromIntegral $ 1 + 8 + hashSize + 16 + 18 + 32 + B.length verificationKeyBytes + hashChainOffset = signatureOffset + fromIntegral (B.length shareSignature) + blockHashTreeOffset = hashChainOffset + fromIntegral (length (hashChain shareHashChain) * (hashSize + 2)) + shareDataOffset = blockHashTreeOffset + fromIntegral (B.length blockHashTreeBytes) + encryptedPrivateKeyOffset = fromIntegral shareDataOffset + fromIntegral (LB.length shareData) + eofOffset = encryptedPrivateKeyOffset + fromIntegral (B.length shareEncryptedPrivateKey) + + get = do + version <- getWord8 + unless (version == 0) (fail $ "Only version 0 is supported; got version " <> show version) + shareSequenceNumber <- getWord64be + shareRootHash <- getByteString 32 + shareIV <- IV <$> getByteString 16 + shareTotalShares <- getWord8 + shareRequiredShares <- getWord8 + shareSegmentSize <- getWord64be + shareDataLength <- getWord64be + signatureOffset <- getWord32be + hashChainOffset <- getWord32be + blockHashTreeOffset <- getWord32be + shareDataOffset <- getWord32be + encryptedPrivateKeyOffset <- getWord64be + eofOffset <- getWord64be + + pos <- bytesRead + shareVerificationKey <- isolate (fromIntegral signatureOffset - fromIntegral pos) getSubjectPublicKeyInfo + + pos <- bytesRead + shareSignature <- getByteString (fromIntegral hashChainOffset - fromIntegral pos) + + pos <- bytesRead + shareHashChain <- isolate (fromIntegral blockHashTreeOffset - fromIntegral pos) get + + pos <- bytesRead + shareBlockHashTree <- isolate (fromIntegral shareDataOffset - fromIntegral pos) get + + pos <- bytesRead + shareData <- getLazyByteString (fromIntegral encryptedPrivateKeyOffset - fromIntegral pos) + + pos <- bytesRead + shareEncryptedPrivateKey <- getByteString (fromIntegral eofOffset - fromIntegral pos) + + empty <- isEmpty + unless empty (fail "Expected end of input but there are more bytes") + + pure Share{..} + +{- | Read an X.509v3-encoded SubjectPublicKeyInfo structure carrying an ASN.1 + DER encoded RSA public key. +-} +getSubjectPublicKeyInfo :: Get RSA.PublicKey +getSubjectPublicKeyInfo = do + verificationKeyBytes <- getRemainingLazyByteString + let (Right asn1s) = decodeASN1' DER . LB.toStrict $ verificationKeyBytes + let (Right (PubKeyRSA pubKey, [])) = fromASN1 asn1s + pure pubKey diff --git a/tahoe-ssk.cabal b/tahoe-ssk.cabal index 742d1498c010fcd77883e54260aaacd88909bb44..e8e79204194a03efb6105a4378fcea6ea50ce25a 100644 --- a/tahoe-ssk.cabal +++ b/tahoe-ssk.cabal @@ -66,25 +66,36 @@ library Tahoe.SDMF.Internal.Share build-depends: + , asn1-encoding + , asn1-types , base + , binary , bytestring , crypto-api , crypto-pubkey-types , cryptonite + , RSA + , x509 -- This dependency isn't ideal. Move common bits out to -- another library. build-depends: tahoe-chk ghc-options: -Wall - default-extensions: OverloadedStrings + default-extensions: + DerivingStrategies + GeneralizedNewtypeDeriving + OverloadedStrings + RecordWildCards + default-language: Haskell2010 test-suite tahoe-ssk-test -- Import common warning flags. - import: warnings + import: warnings -- Base language which the package is written in. - default-language: Haskell2010 + default-language: Haskell2010 + default-extensions: OverloadedStrings -- Modules included in this executable, other than Main. -- other-modules: @@ -93,15 +104,47 @@ test-suite tahoe-ssk-test -- other-extensions: -- The interface type and version of the test suite. - type: exitcode-stdio-1.0 + type: exitcode-stdio-1.0 -- Directories containing source files. - hs-source-dirs: test + hs-source-dirs: test -- The entrypoint to the test suite. - main-is: Main.hs + main-is: Main.hs + other-modules: + Generators + Spec -- Test dependencies. build-depends: - , base ^>=4.14.3.0 + , asn1-encoding + , asn1-types + , base ^>=4.14.3.0 + , binary + , bytestring + , crypto-api + , crypto-pubkey-types + , cryptonite + , hedgehog + , RSA + , tahoe-chk , tahoe-ssk + , tasty + , tasty-hedgehog + , tasty-hunit + , x509 + +-- A helper for generating RSA key pairs for use by the test suite. +executable make-keypairs + import: warnings + default-language: Haskell2010 + main-is: Main.hs + hs-source-dirs: make-keypairs + build-depends: + , asn1-encoding + , asn1-types + , base + , bytestring + , crypto-api + , crypto-pubkey-types + , RSA diff --git a/test/Generators.hs b/test/Generators.hs new file mode 100644 index 0000000000000000000000000000000000000000..916105480e795d564e32d12bbe54d0f7c64ded65 --- /dev/null +++ b/test/Generators.hs @@ -0,0 +1,93 @@ +module Generators where + +import Crypto.Hash (HashAlgorithm (hashDigestSize)) +import Crypto.Hash.Algorithms (SHA256 (SHA256)) +import qualified Crypto.PubKey.RSA.Types as RSA +import Crypto.Types (IV (..)) +import Data.ASN1.BinaryEncoding (DER (DER)) +import Data.ASN1.Encoding (ASN1Decoding (decodeASN1), ASN1Encoding (encodeASN1)) +import Data.ASN1.Types (ASN1Object (fromASN1, toASN1)) +import Data.Bifunctor (Bifunctor (first)) +import qualified Data.ByteString as B +import qualified Data.ByteString.Lazy as LB +import Data.X509 (PrivKey (PrivKeyRSA)) +import GHC.IO.Unsafe (unsafePerformIO) +import Hedgehog (MonadGen) +import qualified Hedgehog.Gen as Gen +import qualified Hedgehog.Range as Range +import Tahoe.CHK.Merkle (MerkleTree (..), makeTreePartial) +import Tahoe.SDMF (Share (..)) +import Tahoe.SDMF.Internal.Share (HashChain (HashChain)) + +rootHashLength :: Int +rootHashLength = 32 + +ivLength :: Int +ivLength = 16 + +signatureLength :: Range.Range Int +signatureLength = Range.linear 250 260 + +{- | Generate SDMF shares. The contents of the share are not necessarily + semantically valid. +-} +shares :: MonadGen m => m Share +shares = + genRSAKeys >>= \keypair -> + Share + <$> Gen.word64 Range.exponentialBounded -- shareSequenceNumber + <*> Gen.bytes (Range.singleton rootHashLength) -- shareRootHash + <*> (IV <$> Gen.bytes (Range.singleton ivLength)) -- shareIV + <*> Gen.word8 Range.exponentialBounded -- shareTotalShares + <*> Gen.word8 Range.exponentialBounded -- shareRequiredShares + <*> Gen.word64 Range.exponentialBounded -- shareSegmentSize + <*> Gen.word64 Range.exponentialBounded -- shareDataLength + <*> pure (RSA.toPublicKey keypair) -- shareVerificationKey + <*> Gen.bytes signatureLength -- shareSignature + <*> shareHashChains -- shareHashChain + <*> merkleTrees (Range.singleton 1) -- shareBlockHashTree + <*> (LB.fromStrict <$> Gen.bytes (Range.exponential 0 1024)) -- shareData + <*> (pure . LB.toStrict . toDER . PrivKeyRSA . RSA.toPrivateKey) keypair -- shareEncryptedPrivateKey + where + toDER = encodeASN1 DER . flip toASN1 [] + +{- | Build RSA key pairs. + + Because the specific bits of the key pair shouldn't make any difference to + any application logic, generating new RSA key pairs is expensive, and + generating new RSA key pairs in a way that makes sense in Hedgehog is + challenging, this implementation just knows a few RSA key pairs already and + will give back one of them. +-} +genRSAKeys :: MonadGen m => m RSA.KeyPair +genRSAKeys = Gen.element (map rsaKeyPair rsaKeyPairBytes) + +-- I'm not sure how to do IO in MonadGen so do the IO up front unsafely (but +-- hopefully not really unsafely). +rsaKeyPairBytes :: [LB.ByteString] +{-# NOINLINE rsaKeyPairBytes #-} +rsaKeyPairBytes = unsafePerformIO $ mapM (\n -> LB.readFile ("test/data/rsa-privkey-" <> show n <> ".der")) [0 .. 4 :: Int] + +rsaKeyPair :: LB.ByteString -> RSA.KeyPair +rsaKeyPair bs = do + let (Right kp) = do + asn1s <- first show (decodeASN1 DER bs) + (r, _) <- fromASN1 asn1s + case r of + PrivKeyRSA pk -> pure $ RSA.KeyPair pk + _ -> error "Expected RSA Private Key" + kp + +merkleTrees :: MonadGen m => Range.Range Int -> m MerkleTree +merkleTrees r = makeTreePartial <$> Gen.list r genHash + +-- | Generate ByteStrings which could be sha256d digests. +genHash :: MonadGen m => m B.ByteString +genHash = Gen.bytes . Range.singleton . hashDigestSize $ SHA256 + +-- | Generate lists of two-tuples of share identifier and share root hash. +shareHashChains :: MonadGen m => m HashChain +shareHashChains = HashChain <$> Gen.list range element + where + range = Range.exponential 1 5 + element = (,) <$> Gen.integral (Range.exponential 0 255) <*> Gen.bytes (Range.singleton 32) diff --git a/test/Main.hs b/test/Main.hs index 3e2059e31f5127521b263b051fc7247772685e1a..2b57e2fa4c1baf15aa4a4fd943a832ee043ffa8a 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -1,4 +1,3 @@ module Main (main) where -main :: IO () -main = putStrLn "Test suite not yet implemented." +import Spec (main) diff --git a/test/Spec.hs b/test/Spec.hs new file mode 100644 index 0000000000000000000000000000000000000000..98914dd558e645bf065f40a11f6e8d49e9212e4d --- /dev/null +++ b/test/Spec.hs @@ -0,0 +1,69 @@ +module Spec where + +import Hedgehog ( + forAll, + property, + tripping, + ) + +import qualified Data.Binary as Binary +import Data.Binary.Get (ByteOffset) +import qualified Data.ByteString.Lazy as LB +import Generators (shareHashChains, shares) +import System.IO (hSetEncoding, stderr, stdout, utf8) +import Tahoe.SDMF (Share) +import Test.Tasty (TestTree, defaultMain, testGroup) +import Test.Tasty.HUnit (assertEqual, testCase) +import Test.Tasty.Hedgehog (testProperty) + +tests :: TestTree +tests = + testGroup + "SSK" + [ testProperty "Hash chain round-trips through bytes" $ + property $ do + hashChain <- forAll shareHashChains + tripping hashChain Binary.encode decode' + , testProperty "Share round-trips through bytes" $ + property $ do + share <- forAll shares + tripping share Binary.encode decode' + , testCase "known-correct serialized shares round-trip though Share" $ + mapM_ knownCorrectRoundTrip [0 :: Int .. 9] + ] + +{- | Load a known-correct SDMF bucket and assert that bytes in the slot it + contains deserializes to a Share and then serializes back to the same bytes +-} +knownCorrectRoundTrip :: Show a => a -> IO () +knownCorrectRoundTrip n = do + -- The files are in "bucket" format. We need to extract the + -- "slot". We do so by stripping a prefix and suffix. To avoid + -- having to parse the prefix, we assert that the suffix is a + -- predictable size. + bucket <- LB.readFile ("test/data/3of10." <> show n) + let withoutPrefix = LB.drop (32 + 20 + 32 + 8 + 8 + 368) bucket + dataSize = LB.length withoutPrefix - 4 + shareData = LB.take dataSize withoutPrefix + suffix = LB.drop dataSize withoutPrefix + + -- Our assumption about the data we're working on... + assertEqual "Cannot account for extra leases" suffix "\0\0\0\0" + + let decoded = decode' shareData + let encoded = (Binary.encode :: Share -> LB.ByteString) <$> decoded + assertEqual "original /= encoded" (Right shareData) encoded + +-- | Like `Binary.Binary.decodeOrFail` but only return the decoded value. +decode' :: Binary.Binary b => LB.ByteString -> Either (LB.ByteString, ByteOffset, String) b +decode' = ((\(_, _, a) -> a) <$>) . Binary.decodeOrFail + +main :: IO () +main = do + -- Hedgehog writes some non-ASCII and the whole test process will die if + -- it can't be encoded. Increase the chances that all of the output can + -- be encoded by forcing the use of UTF-8 (overriding the LANG-based + -- choice normally made). + hSetEncoding stdout utf8 + hSetEncoding stderr utf8 + defaultMain tests diff --git a/test/data/3of10.0 b/test/data/3of10.0 new file mode 100644 index 0000000000000000000000000000000000000000..5c79c79c0bdb7d9c5eef847ee1c3ac7483554df5 Binary files /dev/null and b/test/data/3of10.0 differ diff --git a/test/data/3of10.1 b/test/data/3of10.1 new file mode 100644 index 0000000000000000000000000000000000000000..c90a0cd7ef73fee85d6322af0d8b35e4f27c5de8 Binary files /dev/null and b/test/data/3of10.1 differ diff --git a/test/data/3of10.2 b/test/data/3of10.2 new file mode 100644 index 0000000000000000000000000000000000000000..dfa291410b8b50ca12be8fbe6f81a0bbc5397224 Binary files /dev/null and b/test/data/3of10.2 differ diff --git a/test/data/3of10.3 b/test/data/3of10.3 new file mode 100644 index 0000000000000000000000000000000000000000..a859d241f7a674e8b37a96c27eead10f9979475e Binary files /dev/null and b/test/data/3of10.3 differ diff --git a/test/data/3of10.4 b/test/data/3of10.4 new file mode 100644 index 0000000000000000000000000000000000000000..9e5bd0bab2de7ba7bc12b27eba5e916305b0ffc0 Binary files /dev/null and b/test/data/3of10.4 differ diff --git a/test/data/3of10.5 b/test/data/3of10.5 new file mode 100644 index 0000000000000000000000000000000000000000..c3b19a46d15cef77585f5cee446e0162d3812bc2 Binary files /dev/null and b/test/data/3of10.5 differ diff --git a/test/data/3of10.6 b/test/data/3of10.6 new file mode 100644 index 0000000000000000000000000000000000000000..49236cdb012d8755108e9b86657c33a0eb26f4e7 Binary files /dev/null and b/test/data/3of10.6 differ diff --git a/test/data/3of10.7 b/test/data/3of10.7 new file mode 100644 index 0000000000000000000000000000000000000000..5df7362382c60ec4191d84a31f20569d54e3cc63 Binary files /dev/null and b/test/data/3of10.7 differ diff --git a/test/data/3of10.8 b/test/data/3of10.8 new file mode 100644 index 0000000000000000000000000000000000000000..32baa3014dc7fe9cc073fd459dce4f2418881016 Binary files /dev/null and b/test/data/3of10.8 differ diff --git a/test/data/3of10.9 b/test/data/3of10.9 new file mode 100644 index 0000000000000000000000000000000000000000..fe23f068432473ce9706b970ca707157dfe3ae0f Binary files /dev/null and b/test/data/3of10.9 differ diff --git a/test/data/rsa-privkey-0.der b/test/data/rsa-privkey-0.der new file mode 100644 index 0000000000000000000000000000000000000000..ad64c6304d32d06ba74f9a9234de2cd14a6820ab Binary files /dev/null and b/test/data/rsa-privkey-0.der differ diff --git a/test/data/rsa-privkey-1.der b/test/data/rsa-privkey-1.der new file mode 100644 index 0000000000000000000000000000000000000000..ad64c6304d32d06ba74f9a9234de2cd14a6820ab Binary files /dev/null and b/test/data/rsa-privkey-1.der differ diff --git a/test/data/rsa-privkey-2.der b/test/data/rsa-privkey-2.der new file mode 100644 index 0000000000000000000000000000000000000000..ad64c6304d32d06ba74f9a9234de2cd14a6820ab Binary files /dev/null and b/test/data/rsa-privkey-2.der differ diff --git a/test/data/rsa-privkey-3.der b/test/data/rsa-privkey-3.der new file mode 100644 index 0000000000000000000000000000000000000000..ad64c6304d32d06ba74f9a9234de2cd14a6820ab Binary files /dev/null and b/test/data/rsa-privkey-3.der differ diff --git a/test/data/rsa-privkey-4.der b/test/data/rsa-privkey-4.der new file mode 100644 index 0000000000000000000000000000000000000000..ad64c6304d32d06ba74f9a9234de2cd14a6820ab Binary files /dev/null and b/test/data/rsa-privkey-4.der differ