diff --git a/app/Main.hs b/app/Main.hs index 2b87ca658b7f582f4d56e79fe8ba82b372be32ce..47f3151f79d9ff0e41db5bf49066d2dae177d0b1 100644 --- a/app/Main.hs +++ b/app/Main.hs @@ -1,5 +1,29 @@ module Main where +import qualified Data.ByteString as B +import qualified Data.ByteString.Char8 as C8 +import qualified Data.ByteString.Lazy as BL +import qualified Data.Text as T +import Data.Yaml (decodeEither') +import System.Environment (getArgs) +import Tahoe.Announcement (Announcements (..)) +import Tahoe.CHK.Capability (CHK (CHKReader), pCapability) +import Tahoe.Download (announcementToStorageServer, download) +import Text.Megaparsec (parse) + main :: IO () main = do - putStrLn "Hello, Haskell!" + [announcementPath, readCap] <- getArgs + -- Load server announcements + announcementsBytes <- B.readFile announcementPath + let Right (Announcements announcements) = decodeEither' announcementsBytes + + -- Accept & parse read capability + let Right (CHKReader cap) = parse pCapability "<argv>" (T.pack readCap) + + -- Download & decode the shares + result <- download announcements cap announcementToStorageServer + + -- Show the result + putStrLn "Your result:" + either print (C8.putStrLn . BL.toStrict) result diff --git a/cabal.project b/cabal.project index c216d6e9ce25190cc1b5794854ce6eae5b300174..55be17a13a683db9dd1f7743b243c5f56a0f587a 100644 --- a/cabal.project +++ b/cabal.project @@ -2,8 +2,8 @@ packages: . -- These aren't released on hackage yet so we have to grab them -- straight from the vcs host. Probably should do some releases -- soon. - https://whetstone.private.storage/privatestorage/tahoe-chk/-/archive/0.1.0.0/tahoe-chk-0.1.0.0.tar.gz - https://whetstone.private.storage/privatestorage/tahoe-great-black-swamp/-/archive/0.1.0.1/tahoe-great-black-swamp-0.1.0.1.tar.gz + https://whetstone.private.storage/privatestorage/tahoe-chk/-/archive/0.1.0.1/tahoe-chk-0.1.0.1.tar.gz + https://whetstone.private.storage/privatestorage/tahoe-great-black-swamp/-/archive/0.2.0.2/tahoe-great-black-swamp-0.2.0.2.tar.gz package zlib -- Turn on discovery of the underlying zlib using pkg-config. This diff --git a/flake.lock b/flake.lock index b268d888768c5de6920c637dcf97bfc0f341961f..d5833947200194bc4a0e1131fef92513461b13cf 100644 --- a/flake.lock +++ b/flake.lock @@ -255,11 +255,11 @@ "pre-commit-hooks": "pre-commit-hooks_3" }, "locked": { - "lastModified": 1677773826, - "narHash": "sha256-xJmOtHugr4k2zNhP/AF6JdIUnIEyM+TEspLn2n5kloc=", + "lastModified": 1681762240, + "narHash": "sha256-+PLx9xHBvV70dA7Gy/+YTH1w3PcSOrGV0z0rGxts8jU=", "ref": "main", - "rev": "d3a83fdd9563546ca41771186427638e685a2e2b", - "revCount": 9, + "rev": "a51e591b7fdf8881ac0237452691df7b1aceecd3", + "revCount": 10, "type": "git", "url": "https://whetstone.private.storage/jcalderone/hs-flake-utils.git" }, @@ -475,16 +475,16 @@ ] }, "locked": { - "lastModified": 1681998540, - "narHash": "sha256-gtlHidh8+qMUdnTzlxLIGlCMavq4/TFQDmope33Cd2w=", - "ref": "refs/tags/0.1.0.0", - "rev": "799bab0c2b8b5f9cbd98e7dd7d6a77285c87f16b", - "revCount": 340, + "lastModified": 1683552888, + "narHash": "sha256-h9pgP/LYPtUr5CeCAhqt1XJyAqKTnkQxuIygiTulU/U=", + "ref": "refs/tags/0.1.0.1", + "rev": "05aeb5a433b85406ca3c0c313c46299a1026ade0", + "revCount": 344, "type": "git", "url": "https://whetstone.private.storage/PrivateStorage/tahoe-chk" }, "original": { - "ref": "refs/tags/0.1.0.0", + "ref": "refs/tags/0.1.0.1", "type": "git", "url": "https://whetstone.private.storage/PrivateStorage/tahoe-chk" } @@ -496,19 +496,22 @@ "nixpkgs": [ "hs-flake-utils", "nixpkgs" + ], + "tahoe-chk": [ + "tahoe-chk" ] }, "locked": { - "lastModified": 1682446800, - "narHash": "sha256-Vxl4dLpoRp2svWTx0w74m7PeVPMlkSu/XOZAHccOUDs=", - "ref": "refs/tags/0.1.0.1", - "rev": "b81cc3fcdb0107d369a636fdc5a17cf174dff2ea", - "revCount": 143, + "lastModified": 1683553313, + "narHash": "sha256-tXZc8ZDNkHtegoM1HlDUf1Jr5IE04aobZDpnBaBm53w=", + "ref": "refs/tags/0.2.0.2", + "rev": "ab799ee24d7150e13b300b86240433ecdb783577", + "revCount": 187, "type": "git", "url": "https://whetstone.private.storage/PrivateStorage/tahoe-great-black-swamp" }, "original": { - "ref": "refs/tags/0.1.0.1", + "ref": "refs/tags/0.2.0.2", "type": "git", "url": "https://whetstone.private.storage/PrivateStorage/tahoe-great-black-swamp" } diff --git a/flake.nix b/flake.nix index 8c70ef15153e6a444c51dee6bd182192295cb18f..72e7dac3d07880153daef299b550ccd22605fde3 100644 --- a/flake.nix +++ b/flake.nix @@ -8,13 +8,14 @@ hs-flake-utils.url = "git+https://whetstone.private.storage/jcalderone/hs-flake-utils.git?ref=main"; tahoe-chk = { - url = "git+https://whetstone.private.storage/PrivateStorage/tahoe-chk?ref=refs/tags/0.1.0.0"; + url = "git+https://whetstone.private.storage/PrivateStorage/tahoe-chk?ref=refs/tags/0.1.0.1"; inputs.nixpkgs.follows = "hs-flake-utils/nixpkgs"; }; tahoe-great-black-swamp = { - url = "git+https://whetstone.private.storage/PrivateStorage/tahoe-great-black-swamp?ref=refs/tags/0.1.0.1"; + url = "git+https://whetstone.private.storage/PrivateStorage/tahoe-great-black-swamp?ref=refs/tags/0.2.0.2"; inputs.nixpkgs.follows = "hs-flake-utils/nixpkgs"; + inputs.tahoe-chk.follows = "tahoe-chk"; }; }; diff --git a/gbs-downloader.cabal b/gbs-downloader.cabal index 8d377001b6a0f7437ef008f13a615b2108909641..0312e51907c814bf571180adc41e61977864d352 100644 --- a/gbs-downloader.cabal +++ b/gbs-downloader.cabal @@ -1,4 +1,4 @@ -cabal-version: 3.0 +cabal-version: 2.4 -- The cabal-version field refers to the version of the .cabal specification, -- and can be different from the cabal-install (the tool) version and the @@ -63,9 +63,12 @@ common warnings common language -- LANGUAGE extensions used by modules in all targets. default-extensions: + DerivingStrategies + GeneralizedNewtypeDeriving NamedFieldPuns OverloadedStrings RecordWildCards + ScopedTypeVariables TupleSections library @@ -75,7 +78,9 @@ library , language -- Modules exported by the library. - exposed-modules: Tahoe.Download + exposed-modules: + Tahoe.Announcement + Tahoe.Download -- Modules included in this library but not exported. -- other-modules: @@ -85,13 +90,25 @@ library -- Other library packages from which modules are imported. build-depends: - , base ^>=4.14.3.0 + , aeson + , base + , base32 + , base64-bytestring , binary , bytestring + , connection , containers + , data-default-class + , exceptions + , http-client + , http-client-tls + , network-uri + , servant-client + , servant-client-core , tahoe-chk - , tahoe-great-black-swamp + , tahoe-great-black-swamp >=0.2 && <0.3 , text + , yaml -- Directories containing source files. hs-source-dirs: src @@ -116,8 +133,15 @@ executable gbs-download -- Other library packages from which modules are imported. build-depends: - , base ^>=4.14.3.0 + , aeson + , base + , bytestring + , containers , gbs-downloader + , megaparsec + , tahoe-chk + , text + , yaml -- Directories containing source files. hs-source-dirs: app @@ -148,12 +172,13 @@ test-suite gbs-downloader-test -- Test dependencies. build-depends: - , base ^>=4.14.3.0 + , base , base32 , binary , bytestring , containers , crypto-api + , data-default-class , gbs-downloader , hedgehog , tahoe-chk @@ -161,3 +186,4 @@ test-suite gbs-downloader-test , tasty-hedgehog , tasty-hunit , text + , yaml diff --git a/src/Tahoe/Announcement.hs b/src/Tahoe/Announcement.hs new file mode 100644 index 0000000000000000000000000000000000000000..3ad916d4e9c2f7eacb11b4fe707717d996f801ef --- /dev/null +++ b/src/Tahoe/Announcement.hs @@ -0,0 +1,117 @@ +{- | Represent and work with Tahoe-LAFS storage service announcements. + + A storage service announcement includes information about how to find and + authenticate a storage service. They are often exchanged using a pubsub + system orchestrated by an "introducer". Here, we currently support only + reading them from a yaml or json file. +-} +module Tahoe.Announcement ( + URI (..), + URIAuth (..), + StorageServerID, + StorageServerAnnouncement (..), + Announcements (..), + greatBlackSwampURIs, + parseURI', +) where + +import Data.Aeson (FromJSON (parseJSON), ToJSON (toJSON), object, withObject, (.:), (.:?), (.=)) +import qualified Data.ByteString as B +import Data.ByteString.Base32 (decodeBase32Unpadded, encodeBase32Unpadded) +import Data.Default.Class (Default (def)) +import qualified Data.Map.Strict as Map +import Data.Maybe (fromMaybe) +import qualified Data.Text +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Network.URI (URI (..), URIAuth (..), parseURI) + +{- | The unique identifier for a particular storage server, conventionally the + lowercase base32 encoding of some public key controlled by the server. +-} +type StorageServerID = T.Text + +{- | A map of storage server announcements keyed on the unique server + identifier. +-} +newtype Announcements + = Announcements (Map.Map StorageServerID StorageServerAnnouncement) + deriving newtype (Eq, Show) + +-- Support serialization to the ``servers.yaml`` format supported by +-- Tahoe-LAFS. +instance FromJSON Announcements where + parseJSON = withObject "servers.yaml" $ \v -> do + storage <- v .: "storage" + pure $ Announcements storage + +instance ToJSON Announcements where + toJSON (Announcements announcements) = + object + [ "storage" .= announcements + ] + +-- | An announcement from a storage server about its storage service. +data StorageServerAnnouncement = StorageServerAnnouncement + { storageServerAnnouncementFURL :: Maybe T.Text + , storageServerAnnouncementNick :: Maybe T.Text + , storageServerAnnouncementPermutationSeed :: Maybe B.ByteString + } + deriving (Eq, Ord, Show) + +instance Default StorageServerAnnouncement where + def = + StorageServerAnnouncement + { storageServerAnnouncementFURL = Nothing + , storageServerAnnouncementNick = Nothing + , storageServerAnnouncementPermutationSeed = Nothing + } + +-- Support deserialization of a StorageServerAnnouncement from the +-- ``servers.yaml`` format supported by Tahoe-LAFS. +instance FromJSON StorageServerAnnouncement where + parseJSON = withObject "StorageServerAnnouncement" $ \ann -> do + v <- ann .: "ann" + storageServerAnnouncementFURL <- v .:? "anonymous-storage-FURL" + storageServerAnnouncementNick <- v .:? "nickname" + permutationSeed <- v .:? "permutation-seed-base32" + let storageServerAnnouncementPermutationSeed = + case permutationSeed of + Nothing -> Nothing + Just txt -> case decodeBase32Unpadded . encodeUtf8 $ txt of + Left _ -> Nothing + Right ps -> Just ps + + pure StorageServerAnnouncement{..} + +-- And serialization to that format. +instance ToJSON StorageServerAnnouncement where + toJSON StorageServerAnnouncement{..} = + object + [ "ann" + .= object + [ "anonymous-storage-FURL" .= storageServerAnnouncementFURL + , "nickname" .= storageServerAnnouncementNick + , "permutation-seed-base32" + .= (encodeBase32Unpadded <$> storageServerAnnouncementPermutationSeed) + ] + ] + +{- | If possible, get the URI of a Great Black Swamp server from an + announcement. +-} +greatBlackSwampURIs :: StorageServerAnnouncement -> Maybe URI +greatBlackSwampURIs = + parseURI' . fromMaybe "" . storageServerAnnouncementFURL + +{- | Parse a Tahoe-LAFS fURL. For example: + + pb://gnuer2axzoq3ggnn7gjoybmfqsjvaow3@tcp:localhost:46185/sxytycucj5eeunlx6modfazq5byp2hpb + + This *does not* parse NURLs which are the expected way that GBS locations + will be communicated. + + See https://whetstone.private.storage/privatestorage/gbs-downloader/-/issues/6 +-} +parseURI' :: T.Text -> Maybe URI +parseURI' = Network.URI.parseURI . T.unpack . Data.Text.replace "tcp:" "" diff --git a/src/Tahoe/Download.hs b/src/Tahoe/Download.hs index 35d87188f5fb56222b3472f4d82df21e24d8d83f..48b8cd3e43b7a9e7ee7854c4910eb48c9a166a5e 100644 --- a/src/Tahoe/Download.hs +++ b/src/Tahoe/Download.hs @@ -1,19 +1,49 @@ {- | A high-level interface to downloading share data as bytes from storage servers. -} -module Tahoe.Download (LookupServer, DownloadError (..), discoverShares, download) where +module Tahoe.Download ( + LookupServer, + DownloadError (..), + LookupError (..), + DiscoverError (..), + discoverShares, + download, + announcementToStorageServer, +) where -import Data.Binary (decodeOrFail) +import Control.Exception (Exception (displayException), SomeException, throwIO, try) +import Control.Monad.IO.Class (MonadIO (liftIO)) +import Data.Bifunctor (Bifunctor (first)) +import Data.Binary (Word16, decodeOrFail) +import qualified Data.ByteString as B +import Data.ByteString.Base32 (encodeBase32Unpadded) +import qualified Data.ByteString.Base64 as Base64 import qualified Data.ByteString.Lazy as LB -import Data.Either (isRight, rights) +import Data.Either (isRight, partitionEithers) import Data.List (foldl') import qualified Data.Map.Strict as Map import qualified Data.Set as Set +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Network.Connection (TLSSettings (TLSSettingsSimple)) +import Network.HTTP.Client (Manager, ManagerSettings (managerModifyRequest), Request (requestHeaders)) +import Network.HTTP.Client.TLS (mkManagerSettings, newTlsManagerWith) +import Network.URI (URI (..), URIAuth (..)) +import Servant.Client (Scheme (Https), mkClientEnv, runClientM) +import Servant.Client.Core (BaseUrl (BaseUrl, baseUrlHost, baseUrlPath, baseUrlPort, baseUrlScheme)) +import Tahoe.Announcement (StorageServerAnnouncement, greatBlackSwampURIs) import qualified Tahoe.CHK import Tahoe.CHK.Capability (Reader (..), Verifier (..)) import qualified Tahoe.CHK.Encrypt -import Tahoe.CHK.Server (StorageServer (..), StorageServerAnnouncement (..), StorageServerID, URL) +import Tahoe.CHK.Server (StorageServer (..), StorageServerID) import Tahoe.CHK.Types (ShareNum, StorageIndex) +import TahoeLAFS.Storage.API (CBORSet (..), ShareNumber (ShareNumber)) +import TahoeLAFS.Storage.Client (getImmutableShareNumbers, readImmutableShare) +import Text.Read (readMaybe) + +print' :: MonadIO m => String -> m () +-- print' = liftIO . print +print' = const $ pure () {- | An unrecoverable problem arose while attempting to download and/or read some application data. @@ -22,7 +52,7 @@ data DownloadError = -- | The configuration included no candidate servers from which to download. NoConfiguredServers | -- | Across all of the configured servers, none were actually connectable. - NoReachableServers + NoReachableServers [DiscoverError] | -- | Across all of the configured servers, fewer than the required -- number of shares were found. XXX Could split this into the different -- cases - did not locate enough shares, did not download enough shares, @@ -43,66 +73,134 @@ data DownloadError | -- | An attempt was made to download a share but no servers were given for -- the download. NoServers + | -- | An error occurred during share download. + ShareDownloadError String deriving (Eq, Ord, Show) -{- | An problem arose while attempting to discover the shares held on a +{- | A problem arose while attempting to discover the shares held on a particular server. -} data DiscoverError = -- | An announcement did not include a location for a connection attempt. StorageServerLocationUnknown - | StorageServerUnreachable + | -- | An announcement included a location we could not interpret. + StorageServerLocationUnsupported + | StorageServerUnreachable LookupError + | StorageServerCommunicationError String deriving (Eq, Ord, Show) --- TODO The result might need to be in IO in case the URL indicates a --- Tor-based route to the server. In this case we might need to launch a Tor --- daemon or connect to a running Tor daemon or at least set up a new Tor --- circuit. All of which require I/O. But we can always refactor later! -type LookupServer = URL -> Maybe StorageServer +{- | The type of a function that can produce a concrete StorageServer from + that server's announcement. +-} +type LookupServer m = StorageServerAnnouncement -> m (Either LookupError StorageServer) {- | Recover the application data associated with a given capability from the given servers, if possible. -} download :: + MonadIO m => -- | Information about the servers from which to consider downloading shares -- representing the application data. Map.Map StorageServerID StorageServerAnnouncement -> -- | The read capability for the application data. Reader -> -- | Get functions for interacting with a server given its URL. - LookupServer -> + LookupServer m -> -- | Either a description of how the recovery failed or the recovered -- application data. - IO (Either DownloadError LB.ByteString) -download servers cap@Reader{readKey, verifier = Verifier{..}} lookupServer = + m (Either DownloadError LB.ByteString) +download servers cap@Reader{verifier = Verifier{..}} lookupServer = do + print' ("Going to download: " <> show storageIndex) + locationE <- locateShares servers lookupServer storageIndex required + print' "Finished locating shares" + case locationE of + Left err -> do + print' "Got an error locating shares" + pure $ Left err + Right discovered -> do + print' "Found some shares, fetching them" + -- XXX note shares can contain failures + shares <- fetchShares storageIndex discovered + print' "Fetched the shares, decoding them" + s <- decodeShares cap shares + print' "Decoded them" + pure s + +-- | Find out which servers claim to have shares related to a given storage index. +locateShares :: + MonadIO m => + -- | Information about the servers from which to consider downloading shares + -- representing the application data. + Map.Map StorageServerID StorageServerAnnouncement -> + -- | Get functions for interacting with a server given its URL. + LookupServer m -> + -- | The storage index about which to retrieve information. + B.ByteString -> + -- | The number of shares we need to locate. If we cannot find at least + -- this many shares the result will be an error. + Word16 -> + -- | Either an error or a guide to where shares are placed. + m (Either DownloadError [(StorageServer, Set.Set ShareNum)]) +locateShares servers lookupServer storageIndex required = case Map.toList servers of [] -> pure . Left $ NoConfiguredServers serverList -> do + print' "Discovering shares" -- Ask each server for all shares it has. - discovered <- rights <$> mapM (discoverShares lookupServer storageIndex) serverList :: IO [(StorageServer, Set.Set ShareNum)] + ( problems :: [DiscoverError] + , discovered :: [(StorageServer, Set.Set ShareNum)] + ) <- + partitionEithers <$> mapM (discoverShares lookupServer storageIndex) serverList if null discovered - then pure $ Left NoReachableServers + then pure . Left . NoReachableServers $ problems else if (fromIntegral required >) . countDistinctShares $ discovered then pure $ Left NotEnoughShares{notEnoughSharesNeeded = fromIntegral required, notEnoughSharesFound = countDistinctShares discovered} - else do - -- XXX up to here is probably "locateShares". now we move in to "fetchShares". - let sharemap = makeShareMap discovered - -- XXX note shares can contain failures - shares <- mapM (uncurry $ downloadShare storageIndex) (Map.toList sharemap) - -- XXX That was brief. Probably missing some complexity. now we move on to "decodeShares". - -- Filter down to shares we actually got. - let someShares = filter (isRight . snd) shares - fewerShares = filter (isRight . snd) $ (\(sharenum, Right bs) -> (sharenum, decodeOrFail bs)) <$> someShares - onlyDecoded = (\(sharenum, Right (_, _, share)) -> (fromIntegral sharenum, share)) <$> fewerShares - if length onlyDecoded < fromIntegral required - then pure $ Left NotEnoughDecodedShares{notEnoughDecodedSharesNeeded = fromIntegral required, notEnoughDecodedSharesFound = length onlyDecoded} - else do - ciphertext <- Tahoe.CHK.decode cap onlyDecoded - case ciphertext of - Nothing -> pure $ Left ShareDecodingFailed - Just ct -> - pure . Right $ Tahoe.CHK.Encrypt.decrypt readKey ct + else pure $ Right discovered + +{- | Given a guide to where shares for a given storage index are placed, + download them. +-} +fetchShares :: + MonadIO m => + -- | The storage index of the shares to download. + B.ByteString -> + -- | The guide to where shares are placed. + [(StorageServer, Set.Set ShareNum)] -> + m [(ShareNum, Either DownloadError LB.ByteString)] +fetchShares storageIndex discovered = do + print' "Fetching shares" + s <- mapM (uncurry $ downloadShare storageIndex) (Map.toList sharemap) + print' "Fetched shares" + pure s + where + sharemap = makeShareMap discovered + +{- | Given the results of downloading shares related to a given capability, + decode them and decrypt the contents of possible. +-} +decodeShares :: + MonadIO m => + -- | The read capability which allows the contents to be decrypted. + Reader -> + -- | The results of downloading the shares. + [(ShareNum, Either DownloadError LB.ByteString)] -> + m (Either DownloadError LB.ByteString) +decodeShares cap@Reader{readKey, verifier = Verifier{..}} shares = + -- Filter down to shares we actually got. + let someShares = filter (isRight . snd) shares + fewerShares = filter (isRight . snd) $ (\(sharenum, Right bs) -> (sharenum, decodeOrFail bs)) <$> someShares + onlyDecoded = (\(sharenum, Right (_, _, share)) -> (fromIntegral sharenum, share)) <$> fewerShares + in if length onlyDecoded < fromIntegral required + then pure $ Left NotEnoughDecodedShares{notEnoughDecodedSharesNeeded = fromIntegral required, notEnoughDecodedSharesFound = length onlyDecoded} + else do + print' "Interpreted shares, decoding them" + ciphertext <- liftIO $ Tahoe.CHK.decode cap onlyDecoded + print' "Decoded them, might decrypt them" + case ciphertext of + Nothing -> pure $ Left ShareDecodingFailed + Just ct -> + pure . Right $ Tahoe.CHK.Encrypt.decrypt readKey ct {- | Figure the total number of distinct shares reported by all of the servers we asked. @@ -113,16 +211,24 @@ countDistinctShares = Set.size . foldl' Set.union mempty . map snd {- | Ask one server which shares it has related to the storage index in question. -} -discoverShares :: LookupServer -> StorageIndex -> (StorageServerID, StorageServerAnnouncement) -> IO (Either DiscoverError (StorageServer, Set.Set ShareNum)) +discoverShares :: + MonadIO m => + LookupServer m -> + StorageIndex -> + (StorageServerID, StorageServerAnnouncement) -> + m (Either DiscoverError (StorageServer, Set.Set ShareNum)) discoverShares lookupServer storageIndex (_sid, sann) = do - case storageServerAnnouncementFURL sann of - Nothing -> pure $ Left StorageServerLocationUnknown - Just url -> do - let server = lookupServer url - case server of - Nothing -> pure $ Left StorageServerUnreachable - Just ss@StorageServer{storageServerGetBuckets} -> - Right . (ss,) <$> storageServerGetBuckets storageIndex + print' "Looking up server from announcement" + server <- lookupServer sann + print' "Looked it up" + case server of + Left e -> pure . Left . StorageServerUnreachable $ e + Right ss@StorageServer{storageServerGetBuckets} -> do + print' $ "Getting buckets for " <> show storageIndex + buckets <- liftIO $ try (storageServerGetBuckets storageIndex) + let massaged = first (StorageServerCommunicationError . (displayException :: SomeException -> String)) buckets + print' $ "Got them " <> show massaged + pure $ (ss,) <$> massaged {- | Invert the mapping implied by the list of two tuples so that the servers that claim to have a certain share can easily be retrieved. @@ -133,6 +239,7 @@ makeShareMap locations = -- | Download the bytes of a share from one (or more!) of the given servers. downloadShare :: + MonadIO m => -- | The storage index of the share to download. StorageIndex -> -- | The number of the share to download. @@ -144,11 +251,138 @@ downloadShare :: [StorageServer] -> -- | The bytes of the share or some error that was encountered during -- download. - IO (ShareNum, Either DownloadError LB.ByteString) + m (ShareNum, Either DownloadError LB.ByteString) downloadShare _ shareNum [] = pure (shareNum, Left NoServers) downloadShare storageIndex shareNum (s : _) = do -- TODO: There might be more servers. We could try them if this fails. -- On the other hand, we might get bytes but we don't verify them here so -- we might also need retry logic up a level or two from here. - shareBytes <- storageServerRead s storageIndex shareNum - pure (shareNum, Right $ LB.fromStrict shareBytes) + print' $ "Going to download " <> show storageIndex <> " " <> show shareNum + shareBytes <- liftIO $ try (storageServerRead s storageIndex shareNum) + let massaged = first (ShareDownloadError . (displayException :: SomeException -> String)) shareBytes + print' "Downloaded it" + pure (shareNum, LB.fromStrict <$> massaged) + +-- | There was a problem while trying to look up a server from its announcement. +data LookupError + = -- | The server's announced URI was unparseable. + URIParseError StorageServerAnnouncement + | -- | The port integer in the server's URI was unparseable. + PortParseError String + | -- | The structure of the server's URI was unparseable. + AnnouncementStructureUnmatched + deriving (Eq, Ord, Show) + +{- | Interpret the location in an announcement as a Tahoe-LAFS fURL pointed at + a Great Black Swamp server. +-} +announcementToStorageServer :: MonadIO m => StorageServerAnnouncement -> m (Either LookupError StorageServer) +announcementToStorageServer ann = + case greatBlackSwampURIs ann of + Nothing -> pure . Left . URIParseError $ ann + Just uri -> makeServer uri + +makeServer :: MonadIO m => URI -> m (Either LookupError StorageServer) +makeServer + URI + { uriScheme = "pb:" + , uriAuthority = Just URIAuth{uriUserInfo = tubid, uriRegName = host, uriPort = (':' : port)} + , uriPath = ('/' : swissnum) + , uriFragment = "" -- It's a fURL, not a NURL, so there's no fragment. + } = + case readMaybe port of + Nothing -> pure . Left . PortParseError $ port + Just realPort -> do + print' "Going to make a GBS manager" + manager <- liftIO $ newGBSManager tubid swissnum + print' "Made it" + + pure . Right $ wrapGreatBlackSwamp manager host realPort +makeServer _ = pure . Left $ AnnouncementStructureUnmatched + +{- | Create a StorageServer that will speak Great Black Swamp using the given + manager to the server at the given host/port. +-} +wrapGreatBlackSwamp :: Manager -> [Char] -> Int -> StorageServer +wrapGreatBlackSwamp manager host realPort = + StorageServer{..} + where + baseUrl = https host realPort + env = mkClientEnv manager baseUrl + toBase32 = T.unpack . T.toLower . encodeBase32Unpadded + + storageServerID = undefined + + storageServerWrite = undefined + + storageServerRead storageIndex shareNum = do + let clientm = readImmutableShare (toBase32 storageIndex) (ShareNumber $ fromIntegral shareNum) Nothing + print' "Going to read from a server" + res <- runClientM clientm env + print' "Did it" + case res of + Left err -> do + print' "Going to throw a damn IO error" + throwIO err + Right bs -> pure bs + + storageServerGetBuckets storageIndex = do + let clientm = getImmutableShareNumbers (toBase32 storageIndex) + print' "Going to get share numbers" + r <- try $ runClientM clientm env + case r of + Left (err :: SomeException) -> do + print' $ "A PROBLEM ARISES " <> show err + pure mempty + Right res -> do + print' "Got the share numbers" + case res of + Left err -> do + print' "Going to throw another IO error!!" + throwIO err + Right (CBORSet s) -> pure $ Set.map (\(ShareNumber i) -> fromIntegral i) s -- XXX fromIntegral aaaaaaaa!! + +-- | Make an HTTPS URL. +https :: String -> Int -> BaseUrl +https host port = + BaseUrl + { baseUrlScheme = Https + , baseUrlHost = host + , baseUrlPort = port + , baseUrlPath = "" + } + +{- | Make an HTTPS manager for the given SPKI hash and swissnum. + + The SPKI hash is _not_ used to authenticate the server! See + https://whetstone.private.storage/privatestorage/tahoe-great-black-swamp/-/issues/27 +-} +managerSettingsForService :: T.Text -> T.Text -> ManagerSettings +managerSettingsForService _ swissnum = + (mkManagerSettings tlsSettings sockSettings){managerModifyRequest = pure . authorize} + where + tlsSettings = TLSSettingsSimple True True True + sockSettings = Nothing + swissnumBytes = encodeUtf8 swissnum + swissnumBase64 = Base64.encode swissnumBytes + headerCompleteBytes = B.concat ["Tahoe-LAFS ", swissnumBase64] + authorize req = + req + { requestHeaders = + ( "Authorization" + , headerCompleteBytes + ) : + requestHeaders req + } + +-- | Make a manager suitable for use with a Great Black Swamp server. +newGBSManager :: + MonadIO m => + [Char] -> + String -> + m Manager +newGBSManager tubid swissnum = + newTlsManagerWith $ + managerSettingsForService + (T.pack . init $ tubid) + (T.pack swissnum) diff --git a/test/Generators.hs b/test/Generators.hs index 08ba46e3fcbefd45e977c8d35e240821439aab1b..3adc7c662bbccce84fecc99c0cb6251bac5eb1fa 100644 --- a/test/Generators.hs +++ b/test/Generators.hs @@ -1,9 +1,12 @@ module Generators where +import Data.ByteString.Base32 (encodeBase32Unpadded) import Data.Int (Int64) +import qualified Data.Text as T import Hedgehog (MonadGen) import qualified Hedgehog.Gen as Gen import qualified Hedgehog.Range as Range +import Tahoe.Announcement (Announcements (..), StorageServerAnnouncement (..)) import Tahoe.CHK.Types (Parameters (..)) -- | The maximum value an Int64 can represent. @@ -20,3 +23,17 @@ genParameters = do -- easier not to let this value vary and it doesn't hurt anything. let paramHappyShares = 1 pure $ Parameters{paramSegmentSize, paramTotalShares, paramHappyShares, paramRequiredShares} + +genAnnouncements :: MonadGen m => Range.Range Int -> m Announcements +genAnnouncements size = + Announcements <$> Gen.map size ((,) <$> genServerIDs <*> genStorageServerAnnouncements) + +genServerIDs :: MonadGen m => m T.Text +genServerIDs = T.toLower . encodeBase32Unpadded <$> Gen.bytes (Range.singleton 32) + +genStorageServerAnnouncements :: MonadGen m => m StorageServerAnnouncement +genStorageServerAnnouncements = + StorageServerAnnouncement + <$> Gen.maybe (Gen.text (Range.linear 16 32) Gen.ascii) + <*> Gen.maybe (Gen.text (Range.linear 16 32) Gen.ascii) + <*> Gen.maybe (Gen.bytes $ Range.singleton 32) diff --git a/test/Spec.hs b/test/Spec.hs index f2547908dc60bdaab1ccc29623c844c2f6a05fe1..91a9191fd9cb0223b4fb905c9838bf9d612c93df 100644 --- a/test/Spec.hs +++ b/test/Spec.hs @@ -8,23 +8,39 @@ import qualified Data.Binary as Binary import qualified Data.ByteString as B import Data.ByteString.Base32 (encodeBase32Unpadded) import qualified Data.ByteString.Lazy as BL +import Data.Default.Class (Default (def)) import qualified Data.Map.Strict as Map import qualified Data.Set as Set import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Data.Word (Word16) -import Generators (genParameters) -import Hedgehog (MonadGen, diff, forAll, property) +import qualified Data.Yaml as Yaml +import Generators (genAnnouncements, genParameters) +import Hedgehog (MonadGen, diff, forAll, property, tripping) import qualified Hedgehog.Gen as Gen import qualified Hedgehog.Range as Range import System.IO (hSetEncoding, stderr, stdout, utf8) +import Tahoe.Announcement ( + Announcements, + StorageServerAnnouncement (..), + StorageServerID, + URI (..), + URIAuth (..), + parseURI', + ) import qualified Tahoe.CHK import Tahoe.CHK.Capability (Reader (..), Verifier (..)) import qualified Tahoe.CHK.Encrypt -import Tahoe.CHK.Server (StorageServer (..), StorageServerAnnouncement (..), StorageServerID) +import Tahoe.CHK.Server (StorageServer (..)) import Tahoe.CHK.Types (Parameters (..)) import Tahoe.CHK.Upload (getConvergentKey) -import Tahoe.Download (DownloadError (..), LookupServer, download) +import Tahoe.Download ( + DiscoverError (..), + DownloadError (..), + LookupError (..), + LookupServer, + download, + ) import Tahoe.Server (memoryStorageServer) import Test.Tasty (TestTree, defaultMain, testGroup) import Test.Tasty.HUnit (assertEqual, testCase) @@ -33,53 +49,109 @@ import Test.Tasty.Hedgehog (testProperty) data PlacementError = RanOutOfPlacementInfo | RanOutOfServers deriving (Eq, Show) instance Exception PlacementError +{- | Return a new StorageServer like the given one but with a get-buckets + interface that always throws an IO exception. +-} +breakGetBuckets :: Exception e => e -> StorageServer -> StorageServer +breakGetBuckets exc ss = + ss + { storageServerGetBuckets = const $ throwIO exc + } + +{- | Return a new StorageServer like the given one but with a read-share + interface that always throws an IO exception. +-} +breakRead :: Exception e => e -> StorageServer -> StorageServer +breakRead exc ss = + ss + { storageServerRead = \_ _ -> throwIO exc + } + +{- | A completely arbitrary exception that the download implementation can't + know anything specific about. +-} +data BespokeFailure = BespokeFailure deriving (Show) + +instance Exception BespokeFailure + +-- | Make an announcement that's real enough to convince a test. +simpleAnnouncement :: T.Text -> T.Text -> (T.Text, StorageServerAnnouncement) +simpleAnnouncement nick furl = + ( T.concat ["v0-", nick] + , def + { storageServerAnnouncementFURL = Just furl + , storageServerAnnouncementNick = Just nick + } + ) + +{- | Build a lookup function that can look up any server in the given list + from its announcement. +-} +simpleLookup :: Applicative f => [(T.Text, b)] -> StorageServerAnnouncement -> f (Either LookupError b) +simpleLookup [] _ = pure . Left $ AnnouncementStructureUnmatched +simpleLookup ((furl, server) : ss) ann@StorageServerAnnouncement{storageServerAnnouncementFURL} = + if Just furl == storageServerAnnouncementFURL + then pure . pure $ server + else simpleLookup ss ann + tests :: TestTree tests = testGroup "All tests" - [ testCase "no configured servers" $ do - -- If there are no servers then we can't possibly get enough - -- shares to recover the application data. - result <- liftIO $ download mempty (trivialCap 1 1) noServers - assertEqual - "download should fail with no servers" - (Left NoConfiguredServers) - result + [ testCase "Tahoe-LAFS fURLs can be parsed to a structured representation" $ + let tubid = "gnuer2axzoq3ggnn7gjoybmfqsjvaow3" + swissnum = "sxytycucj5eeunlx6modfazq5byp2hpb" + in assertEqual + "The result is as expected" + ( Just + URI + { uriScheme = "pb:" + , uriAuthority = + Just + URIAuth + { uriUserInfo = tubid <> "@" + , uriRegName = "localhost" + , uriPort = ":46185" + } + , uriPath = "/" <> swissnum + , uriQuery = "" + , uriFragment = "" + } + ) + (parseURI' $ T.pack $ "pb://" <> tubid <> "@tcp:localhost:46185/" <> swissnum) + , testProperty "Announcements round-trip through YAML encoding/decoding" $ + property $ do + announcements <- forAll $ genAnnouncements (Range.linear 0 3) + tripping announcements Yaml.encode (Yaml.decodeThrow :: B.ByteString -> Maybe Announcements) + , testCase + "no configured servers" + $ do + -- If there are no servers then we can't possibly get enough + -- shares to recover the application data. + result <- liftIO $ download mempty (trivialCap 1 1) noServers + assertEqual + "download should fail with no servers" + (Left NoConfiguredServers) + result , testCase "no reachable servers" $ do -- If we can't contact any configured server then we can't -- possibly get enough shares to recover the application data. - let anns = + let ann = def{storageServerAnnouncementNick = Just "unreachable"} + anns = Map.fromList - [ - ( "v0-abc123" - , StorageServerAnnouncement - { storageServerAnnouncementFURL = Nothing - , storageServerAnnouncementNick = Just "unreachable" - , storageServerAnnouncementPermutationSeed = Nothing - } - ) + [ ("v0-abc123", ann) ] result <- liftIO $ download anns (trivialCap 1 1) noServers assertEqual "download should fail with no reachable servers" - (Left NoReachableServers) + (Left $ NoReachableServers [StorageServerUnreachable (URIParseError ann)]) result , testCase "not enough shares" $ do -- If we can't recover enough shares from the configured servers -- then we can't possibly get enough shares to recover the -- application data. - let anns = - Map.fromList - [ - ( "v0-abc123" - , StorageServerAnnouncement - { storageServerAnnouncementFURL = Just "somewhere" - , storageServerAnnouncementNick = Just "abc123" - , storageServerAnnouncementPermutationSeed = Nothing - } - ) - ] + let anns = Map.fromList [simpleAnnouncement "abc123" "somewhere"] cap = trivialCap 3 3 -- Two shares exist. @@ -88,10 +160,7 @@ tests = storageServerWrite server (storageIndex . verifier $ cap) 1 0 "Hello world" -- Make the server reachable. - let openServer furl = - if furl == "somewhere" - then pure server - else Nothing + let openServer = simpleLookup [("somewhere", server)] -- Try to download the cap which requires three shares to reconstruct. result <- liftIO $ download anns cap openServer @@ -105,22 +174,8 @@ tests = -- recover the application data. Duplicate shares do us no good. let anns = Map.fromList - [ - ( "v0-abc123" - , StorageServerAnnouncement - { storageServerAnnouncementFURL = Just "somewhere" - , storageServerAnnouncementNick = Just "abc123" - , storageServerAnnouncementPermutationSeed = Nothing - } - ) - , - ( "v0-abc456" - , StorageServerAnnouncement - { storageServerAnnouncementFURL = Just "elsewhere" - , storageServerAnnouncementNick = Just "abc123" - , storageServerAnnouncementPermutationSeed = Nothing - } - ) + [ simpleAnnouncement "abc123" "somewhere" + , simpleAnnouncement "abc456" "elsewhere" ] cap = trivialCap 3 3 @@ -136,11 +191,7 @@ tests = storageServerWrite elsewhere idx 0 offset "Hello world" -- Make the server reachable. - let openServer furl = - case furl of - "somewhere" -> pure somewhere - "elsewhere" -> pure elsewhere - _ -> Nothing + let openServer = simpleLookup [("somewhere", somewhere), ("elsewhere", elsewhere)] -- Try to download the cap which requires three shares to reconstruct. result <- liftIO $ download anns cap openServer @@ -148,6 +199,51 @@ tests = "download should fail with not enough shares" (Left NotEnoughShares{notEnoughSharesNeeded = 3, notEnoughSharesFound = 2}) result + , testCase "IO exceptions from storageServerGetBuckets are handled" $ do + -- An announcement for our server + let anns = Map.fromList [simpleAnnouncement "abc123" "somewhere"] + -- A broken interface to the server + server <- breakGetBuckets BespokeFailure <$> memoryStorageServer + + -- Make the server reachable. + let openServer = simpleLookup [("somewhere", server)] + + -- Something to pretend to try to download + let cap = trivialCap 3 13 + + -- Try to download the cap which requires three shares to reconstruct. + result <- liftIO $ download anns cap openServer + assertEqual + "download should fail with details about unreachable server" + (Left (NoReachableServers [StorageServerCommunicationError "BespokeFailure"])) + result + , testCase "IO exceptions from storageServerRead are handled" $ do + -- An announcement for our server + let anns = Map.fromList [simpleAnnouncement "abc123" "somewhere"] + + -- A broken interface to the server + server <- breakRead BespokeFailure <$> memoryStorageServer + + -- Something to pretend to try to download + let cap = trivialCap 3 13 + + -- Three shares exist + let idx = storageIndex . verifier $ cap + offset = 0 + storageServerWrite server idx 0 offset "Hello world" + storageServerWrite server idx 1 offset "Hello world" + storageServerWrite server idx 2 offset "Hello world" + + -- Make the server reachable. + let openServer = simpleLookup [("somewhere", server)] + + -- Try to download the cap which requires three shares to reconstruct. + + result <- liftIO $ download anns cap openServer + assertEqual + "download should fail with details about unreachable server" + (Left (NotEnoughDecodedShares{notEnoughDecodedSharesNeeded = 3, notEnoughDecodedSharesFound = 0})) + result , testProperty "success" $ property $ do -- If we can recover enough distinct, decodeable shares from the @@ -169,7 +265,6 @@ tests = let numServers = Range.linear 1 (fromIntegral paramTotalShares + 1) serverIDs = Gen.text (Range.singleton 2) Gen.ascii serverIDs' <- forAll $ Gen.set numServers serverIDs - -- Constructor <$> arbitrary <*> arbitrary -- Choose a share distribution. Each element of the resulting -- list tells us how many shares to place on the next server, for @@ -196,15 +291,35 @@ tests = -- Recover the plaintext from the servers. result <- liftIO $ download serverAnnouncements cap lookupServer diff (Right plaintext) (==) result + , testCase "immutable upload/download to using Great Black Swamp" $ do + pure () + -- Consider moving these tests to another module, they're pretty + -- different and there's quite a handful of them. + -- + -- ERROR CASES + -- Server presents incorrect TLS certificate + -- * See https://whetstone.private.storage/privatestorage/tahoe-great-black-swamp/-/issues/27 + -- Server returns error response to our request + -- * https://whetstone.private.storage/privatestorage/gbs-downloader/-/issues/4 + -- Server returns tampered share data + -- * https://whetstone.private.storage/privatestorage/gbs-downloader/-/issues/5 ] where -- A server lookup function that always fails. - noServers _ = Nothing + noServers = pure . Left . URIParseError -- A server lookup function that finds servers already present in a Map. - someServers :: Map.Map StorageServerID StorageServer -> LookupServer - someServers servers = flip Map.lookup servers . parseURL + someServers :: Applicative m => Map.Map StorageServerID StorageServer -> LookupServer m + someServers servers ann = + pure $ case result of + Nothing -> Left AnnouncementStructureUnmatched + Just ss -> Right ss where + result = do + furl <- storageServerAnnouncementFURL ann + let serverId = parseURL furl + Map.lookup serverId servers + -- Exactly match the nonsense makeAnn spits out parseURL = T.take 2 . T.drop 5 @@ -233,10 +348,9 @@ tests = -- server identifier. makeAnn :: StorageServerID -> StorageServerAnnouncement makeAnn sid = - StorageServerAnnouncement + def { storageServerAnnouncementFURL = Just $ "pb://" <> sid <> "/" <> sid , storageServerAnnouncementNick = Just . encodeBase32Unpadded . encodeUtf8 $ sid - , storageServerAnnouncementPermutationSeed = Nothing } -- Generate lists of ints that sum to a given total.