2019-01-21 16:54:57 +01:00
|
|
|
{- This file is part of Vervis.
|
|
|
|
-
|
|
|
|
- Written in 2019 by fr33domlover <fr33domlover@riseup.net>.
|
|
|
|
-
|
|
|
|
- ♡ Copying is an act of love. Please copy, reuse and share.
|
|
|
|
-
|
|
|
|
- The author(s) have dedicated all copyright and related and neighboring
|
|
|
|
- rights to this software to the public domain worldwide. This software is
|
|
|
|
- distributed without any warranty.
|
|
|
|
-
|
|
|
|
- You should have received a copy of the CC0 Public Domain Dedication along
|
|
|
|
- with this software. If not, see
|
|
|
|
- <http://creativecommons.org/publicdomain/zero/1.0/>.
|
|
|
|
-}
|
|
|
|
|
|
|
|
module Web.ActivityPub
|
|
|
|
( -- * Actor
|
|
|
|
--
|
|
|
|
-- ActivityPub actor document including a public key, with a 'FromJSON'
|
|
|
|
-- instance for fetching and a 'ToJSON' instance for publishing.
|
|
|
|
ActorType (..)
|
|
|
|
, Algorithm (..)
|
|
|
|
, PublicKey (..)
|
|
|
|
, Actor (..)
|
|
|
|
|
|
|
|
-- * Activity
|
|
|
|
--
|
|
|
|
-- Very basic activity document which is just general JSON with some
|
|
|
|
-- basic checks. 'FromJSON' instance for receiving POSTs, and 'ToJSON'
|
|
|
|
-- instance for delivering to other servers.
|
|
|
|
, Activity (..)
|
|
|
|
|
|
|
|
-- * Utilities
|
|
|
|
, provideAP
|
|
|
|
, APGetError (..)
|
|
|
|
, httpGetAP
|
|
|
|
, httpPostAP
|
2019-02-04 00:39:56 +01:00
|
|
|
, fetchKey
|
2019-01-21 16:54:57 +01:00
|
|
|
)
|
|
|
|
where
|
|
|
|
|
|
|
|
import Prelude
|
|
|
|
|
2019-02-04 00:39:56 +01:00
|
|
|
import Control.Applicative ((<|>), optional)
|
|
|
|
import Control.Exception (Exception, displayException, try)
|
2019-01-21 16:54:57 +01:00
|
|
|
import Control.Monad.IO.Class
|
2019-02-04 00:39:56 +01:00
|
|
|
import Control.Monad.Trans.Except
|
2019-01-21 16:54:57 +01:00
|
|
|
import Control.Monad.Trans.Writer (Writer)
|
2019-02-04 00:39:56 +01:00
|
|
|
import Crypto.Error (CryptoFailable (..))
|
2019-01-21 16:54:57 +01:00
|
|
|
import Data.Aeson
|
|
|
|
import Data.Aeson.Types (Parser)
|
2019-02-04 00:39:56 +01:00
|
|
|
import Data.Bifunctor (bimap)
|
2019-01-21 16:54:57 +01:00
|
|
|
import Data.ByteString (ByteString)
|
|
|
|
import Data.List.NonEmpty (NonEmpty)
|
|
|
|
import Data.PEM
|
|
|
|
import Data.Semigroup (Endo)
|
|
|
|
import Data.Text (Text)
|
|
|
|
import Data.Text.Encoding (encodeUtf8, decodeUtf8)
|
|
|
|
import Network.HTTP.Client
|
|
|
|
import Network.HTTP.Client.Conduit.ActivityPub (httpAPEither)
|
|
|
|
import Network.HTTP.Client.Signature (signRequest)
|
|
|
|
import Network.HTTP.Signature (KeyId, Signature)
|
|
|
|
import Network.HTTP.Simple (JSONException)
|
|
|
|
import Network.HTTP.Types.Header (HeaderName, hContentType)
|
|
|
|
import Network.URI
|
|
|
|
import Yesod.Core.Content (ContentType)
|
|
|
|
import Yesod.Core.Handler (ProvidedRep, provideRepType)
|
|
|
|
|
2019-02-04 00:39:56 +01:00
|
|
|
import qualified Crypto.PubKey.Ed25519 as E (PublicKey, publicKey)
|
2019-01-21 16:54:57 +01:00
|
|
|
import qualified Data.HashMap.Strict as M (lookup)
|
|
|
|
import qualified Data.Text as T (unpack)
|
|
|
|
import qualified Data.Vector as V (fromList)
|
|
|
|
|
2019-02-03 12:01:36 +01:00
|
|
|
import Data.Aeson.Local
|
2019-01-21 16:54:57 +01:00
|
|
|
|
|
|
|
as2context :: Text
|
|
|
|
as2context = "https://www.w3.org/ns/activitystreams"
|
|
|
|
|
|
|
|
actorContext :: Value
|
|
|
|
actorContext = Array $ V.fromList
|
|
|
|
[ String as2context
|
|
|
|
, String "https://w3id.org/security/v1"
|
|
|
|
]
|
|
|
|
|
|
|
|
data ActorType = ActorTypePerson | ActorTypeOther Text
|
|
|
|
|
|
|
|
instance FromJSON ActorType where
|
|
|
|
parseJSON = withText "ActorType" $ \ t ->
|
|
|
|
pure $ case t of
|
|
|
|
"Person" -> ActorTypePerson
|
|
|
|
_ -> ActorTypeOther t
|
|
|
|
|
|
|
|
instance ToJSON ActorType where
|
|
|
|
toJSON = error "toJSON ActorType"
|
|
|
|
toEncoding at =
|
|
|
|
toEncoding $ case at of
|
|
|
|
ActorTypePerson -> "Person"
|
|
|
|
ActorTypeOther t -> t
|
|
|
|
|
|
|
|
data Algorithm = AlgorithmEd25519 | AlgorithmOther Text
|
|
|
|
|
|
|
|
instance FromJSON Algorithm where
|
|
|
|
parseJSON = withText "Algorithm" $ \ t ->
|
|
|
|
pure $ if t == frg <> "ed25519"
|
|
|
|
then AlgorithmEd25519
|
|
|
|
else AlgorithmOther t
|
|
|
|
|
|
|
|
instance ToJSON Algorithm where
|
|
|
|
toJSON = error "toJSON Algorithm"
|
|
|
|
toEncoding algo =
|
|
|
|
toEncoding $ case algo of
|
|
|
|
AlgorithmEd25519 -> frg <> "ed25519"
|
|
|
|
AlgorithmOther t -> t
|
|
|
|
|
|
|
|
data PublicKey = PublicKey
|
2019-02-03 12:12:18 +01:00
|
|
|
{ publicKeyId :: URI
|
|
|
|
, publicKeyOwner :: URI
|
|
|
|
, publicKeyPem :: PEM
|
|
|
|
, publicKeyAlgo :: Maybe Algorithm
|
|
|
|
, publicKeyShared :: Bool
|
2019-01-21 16:54:57 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
instance FromJSON PublicKey where
|
2019-02-04 00:39:56 +01:00
|
|
|
parseJSON = withObject "PublicKey" $ \ o -> do
|
|
|
|
mtyp <- optional $ o .: "@type" <|> o .: "type"
|
|
|
|
case mtyp of
|
|
|
|
Nothing -> return ()
|
|
|
|
Just t ->
|
|
|
|
if t == ("Key" :: Text)
|
|
|
|
then return ()
|
|
|
|
else fail "PublicKey @type isn't Key"
|
2019-01-21 16:54:57 +01:00
|
|
|
PublicKey
|
2019-02-03 12:01:36 +01:00
|
|
|
<$> (parseHttpsURI =<< o .: "id")
|
|
|
|
<*> (parseHttpsURI =<< o .: "owner")
|
2019-01-21 16:54:57 +01:00
|
|
|
<*> (parsePEM =<< o .: "publicKeyPem")
|
|
|
|
<*> o .:? (frg <> "algorithm")
|
2019-02-03 12:12:18 +01:00
|
|
|
<*> o .:? (frg <> "shared") .!= False
|
2019-01-21 16:54:57 +01:00
|
|
|
where
|
|
|
|
parsePEM t =
|
|
|
|
case pemParseBS $ encodeUtf8 t of
|
|
|
|
Left e -> fail $ "PEM parsing failed: " ++ e
|
|
|
|
Right xs ->
|
|
|
|
case xs of
|
|
|
|
[] -> fail "Empty PEM"
|
|
|
|
[x] -> pure x
|
|
|
|
_ -> fail "Multiple PEM sections"
|
|
|
|
|
|
|
|
instance ToJSON PublicKey where
|
|
|
|
toJSON = error "toJSON PublicKey"
|
2019-02-03 12:12:18 +01:00
|
|
|
toEncoding (PublicKey id_ owner pem malgo shared) =
|
2019-01-21 16:54:57 +01:00
|
|
|
pairs
|
2019-02-03 12:12:18 +01:00
|
|
|
$ "id" .= renderURI id_
|
|
|
|
<> "owner" .= renderURI owner
|
|
|
|
<> "publicKeyPem" .= decodeUtf8 (pemWriteBS pem)
|
|
|
|
<> (frg <> "algorithm") .=? malgo
|
|
|
|
<> (frg <> "shared") .= shared
|
2019-01-21 16:54:57 +01:00
|
|
|
|
|
|
|
data Actor = Actor
|
|
|
|
{ actorId :: URI
|
|
|
|
, actorType :: ActorType
|
|
|
|
, actorUsername :: Text
|
|
|
|
, actorInbox :: URI
|
|
|
|
, actorPublicKey :: PublicKey
|
|
|
|
}
|
|
|
|
|
|
|
|
instance FromJSON Actor where
|
|
|
|
parseJSON = withObject "Actor" $ \ o ->
|
|
|
|
Actor
|
2019-02-03 12:01:36 +01:00
|
|
|
<$> (parseHttpsURI =<< o .: "id")
|
2019-01-21 16:54:57 +01:00
|
|
|
<*> o .: "type"
|
|
|
|
<*> o .: "preferredUsername"
|
2019-02-03 12:01:36 +01:00
|
|
|
<*> (parseHttpsURI =<< o .: "inbox")
|
2019-01-21 16:54:57 +01:00
|
|
|
<*> o .: "publicKey"
|
|
|
|
|
|
|
|
instance ToJSON Actor where
|
|
|
|
toJSON = error "toJSON Actor"
|
|
|
|
toEncoding (Actor id_ typ username inbox pkey) =
|
|
|
|
pairs
|
|
|
|
$ "@context" .= actorContext
|
|
|
|
<> "id" .= renderURI id_
|
|
|
|
<> "type" .= typ
|
|
|
|
<> "preferredUsername" .= username
|
|
|
|
<> "inbox" .= renderURI inbox
|
|
|
|
<> "publicKey" .= pkey
|
|
|
|
|
|
|
|
-- | This may seem trivial, but it exists for a good reason: In the 'FromJSON'
|
|
|
|
-- instance we perform sanity checks. We just don't need to remember the fields
|
|
|
|
-- after checking, so we don't unnecessarily add them as fields. We just keep
|
|
|
|
-- the _to_ field, which tells us who the target actor is (we currently support
|
|
|
|
-- only the _to_ field, and it has to be a single URI, and that URI has to be
|
|
|
|
-- an actor, not a collection). The 'Object' we keep is simply for encoding
|
|
|
|
-- back to JSON. I suppose that's actually silly, we could just keep the actual
|
|
|
|
-- ByteString, but I guess it's okay for now, and it happens to guarantee the
|
|
|
|
-- JSON we POST has no extra whitespace.
|
|
|
|
data Activity = Activity
|
|
|
|
{ activityTo :: URI
|
|
|
|
, activityJSON :: Object
|
|
|
|
}
|
|
|
|
|
|
|
|
instance FromJSON Activity where
|
|
|
|
parseJSON = withObject "Activity" $ \ o -> do
|
|
|
|
c <- o .: "@context"
|
|
|
|
if c == as2context
|
|
|
|
then return ()
|
|
|
|
else fail "@context isn't the AS2 context URI"
|
|
|
|
case M.lookup "id" o of
|
|
|
|
Nothing -> return ()
|
|
|
|
Just _ -> fail "id is provided; let the server set it"
|
|
|
|
case M.lookup "type" o of
|
|
|
|
Nothing -> fail "Activity type missing"
|
|
|
|
Just (String _) -> return ()
|
|
|
|
Just _ -> fail "Activity type isn't a string"
|
|
|
|
case M.lookup "actor" o of
|
|
|
|
Nothing -> return ()
|
|
|
|
Just _ -> fail "actor is provided; let the server set it"
|
|
|
|
mto <- case M.lookup "object" o of
|
|
|
|
Nothing -> return Nothing
|
|
|
|
Just v -> case v of
|
|
|
|
String _ -> return Nothing
|
|
|
|
Object obj -> do
|
|
|
|
case M.lookup "id" obj of
|
|
|
|
Nothing -> return ()
|
|
|
|
Just _ -> fail "object's id is provided; let the server set it"
|
|
|
|
case M.lookup "type" obj of
|
|
|
|
Nothing -> fail "Activity object type missing"
|
|
|
|
Just (String _) -> return ()
|
|
|
|
Just _ -> fail "Activity object type isn't a string"
|
|
|
|
case M.lookup "actor" o <|> M.lookup "attributedTo" o of
|
|
|
|
Nothing -> return ()
|
|
|
|
Just _ -> fail "attribution is provided; let the server set it"
|
|
|
|
obj .:? "to"
|
|
|
|
_ -> fail "Activity object isn't JSON string or object"
|
|
|
|
mto2 <- o .:? "to"
|
|
|
|
to <- case mto <|> mto2 of
|
|
|
|
Nothing -> fail "to not provided"
|
2019-02-03 12:01:36 +01:00
|
|
|
Just t -> parseHttpsURI t
|
2019-01-21 16:54:57 +01:00
|
|
|
return $ Activity to o
|
|
|
|
|
|
|
|
instance ToJSON Activity where
|
|
|
|
toJSON = error "toJSON Activity"
|
|
|
|
toEncoding = toEncoding . activityJSON
|
|
|
|
|
|
|
|
typeActivityStreams2 :: ContentType
|
|
|
|
typeActivityStreams2 = "application/activity+json"
|
|
|
|
|
|
|
|
typeActivityStreams2LD :: ContentType
|
|
|
|
typeActivityStreams2LD =
|
|
|
|
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
|
|
|
|
|
|
|
|
provideAP :: (Monad m, ToJSON a) => a -> Writer (Endo [ProvidedRep m]) ()
|
|
|
|
provideAP v = do
|
|
|
|
let enc = toEncoding v
|
|
|
|
-- provideRepType typeActivityStreams2 $ return enc
|
|
|
|
provideRepType typeActivityStreams2LD $ return enc
|
|
|
|
|
|
|
|
data APGetError
|
|
|
|
= APGetErrorHTTP HttpException
|
|
|
|
| APGetErrorJSON JSONException
|
2019-01-21 23:24:09 +01:00
|
|
|
| APGetErrorContentType Text
|
2019-01-21 16:54:57 +01:00
|
|
|
deriving Show
|
|
|
|
|
|
|
|
instance Exception APGetError
|
|
|
|
|
|
|
|
-- | Perform an HTTP GET request to fetch an ActivityPub object.
|
|
|
|
--
|
|
|
|
-- * Verify the URI scheme is _https:_ and authority part is present
|
|
|
|
-- * Set _Accept_ request header
|
|
|
|
-- * Perform the GET request
|
|
|
|
-- * Verify the _Content-Type_ response header
|
|
|
|
-- * Parse the JSON response body
|
|
|
|
httpGetAP
|
|
|
|
:: (MonadIO m, FromJSON a)
|
|
|
|
=> Manager
|
|
|
|
-> URI
|
|
|
|
-> m (Either APGetError (Response a))
|
|
|
|
httpGetAP manager uri =
|
|
|
|
if uriScheme uri /= "https:"
|
|
|
|
then return $ Left $ APGetErrorHTTP $ InvalidUrlException (show uri) "Scheme isn't https"
|
|
|
|
else liftIO $ mkResult <$> try (httpAPEither manager =<< requestFromURI uri)
|
|
|
|
where
|
|
|
|
lookup' x = map snd . filter ((== x) . fst)
|
|
|
|
mkResult (Left e) = Left $ APGetErrorHTTP e
|
|
|
|
mkResult (Right r) =
|
|
|
|
case lookup' hContentType $ responseHeaders r of
|
|
|
|
[] -> Left $ APGetErrorContentType "No Content-Type"
|
|
|
|
[b] -> if b == typeActivityStreams2LD || b == typeActivityStreams2
|
|
|
|
then case responseBody r of
|
|
|
|
Left e -> Left $ APGetErrorJSON e
|
|
|
|
Right v -> Right $ v <$ r
|
2019-01-21 23:24:09 +01:00
|
|
|
else Left $ APGetErrorContentType $ "Non-AP Content-Type: " <> decodeUtf8 b
|
2019-01-21 16:54:57 +01:00
|
|
|
_ -> Left $ APGetErrorContentType "Multiple Content-Type"
|
|
|
|
|
|
|
|
-- Set method to POST, Set Content-Type, make HTTP signature, set response to throw on non-2xx
|
|
|
|
-- status
|
|
|
|
|
|
|
|
-- | Perform an HTTP POST request to submit an ActivityPub object.
|
|
|
|
--
|
|
|
|
-- * Verify the URI scheme is _https:_ and authority part is present
|
|
|
|
-- * Set _Content-Type_ request header
|
|
|
|
-- * Compute HTTP signature and add _Signature_ request header
|
|
|
|
-- * Perform the POST request
|
|
|
|
-- * Verify the response status is 2xx
|
|
|
|
httpPostAP
|
|
|
|
:: (MonadIO m, ToJSON a)
|
|
|
|
=> Manager
|
|
|
|
-> URI
|
|
|
|
-> NonEmpty HeaderName
|
|
|
|
-> (ByteString -> (KeyId, Signature))
|
|
|
|
-> a
|
|
|
|
-> m (Either HttpException (Response ()))
|
|
|
|
httpPostAP manager uri headers sign value =
|
|
|
|
if uriScheme uri /= "https:"
|
|
|
|
then return $ Left $ InvalidUrlException (show uri) "Scheme isn't https"
|
|
|
|
else liftIO $ try $ do
|
|
|
|
req <- requestFromURI uri
|
|
|
|
let req' =
|
|
|
|
setRequestCheckStatus $
|
|
|
|
consHeader hContentType typeActivityStreams2LD $
|
|
|
|
req { method = "POST"
|
|
|
|
, requestBody = RequestBodyLBS $ encode value
|
|
|
|
}
|
|
|
|
sign' b =
|
|
|
|
let (k, s) = sign b
|
|
|
|
in (Nothing, k, s)
|
|
|
|
req'' <- signRequest headers sign' Nothing req'
|
2019-02-03 11:59:35 +01:00
|
|
|
httpNoBody req'' manager
|
2019-01-21 16:54:57 +01:00
|
|
|
where
|
|
|
|
consHeader n b r = r { requestHeaders = (n, b) : requestHeaders r }
|
2019-02-04 00:39:56 +01:00
|
|
|
|
|
|
|
fetchKey
|
|
|
|
:: MonadIO m
|
|
|
|
=> Manager
|
|
|
|
-> Bool
|
|
|
|
-> URI
|
|
|
|
-> m (Either String (E.PublicKey, URI))
|
|
|
|
fetchKey manager sigAlgo u = runExceptT $ do
|
|
|
|
let fetch :: (MonadIO m, FromJSON a) => URI -> ExceptT String m a
|
|
|
|
fetch u = ExceptT $ bimap displayException responseBody <$> httpGetAP manager u
|
|
|
|
obj <- fetch u
|
|
|
|
(actor, pkey) <-
|
|
|
|
case obj of
|
|
|
|
Left' pkey ->
|
|
|
|
if uriAuthority (publicKeyOwner pkey) == uriAuthority u
|
|
|
|
then do
|
|
|
|
actor <- fetch $ publicKeyOwner pkey
|
|
|
|
return (actor, pkey)
|
|
|
|
else throwE "Actor and key on different domains, we reject"
|
|
|
|
Right' actor ->
|
|
|
|
if actorId actor == u { uriFragment = "" }
|
|
|
|
then return (actor, actorPublicKey actor)
|
|
|
|
else throwE "Actor ID doesn't match the keyid URI we fetched"
|
|
|
|
ExceptT . pure $ do
|
|
|
|
if publicKeyShared pkey
|
|
|
|
then Left "Actor's publicKey is shared, we're rejecting it!"
|
|
|
|
else Right ()
|
|
|
|
if publicKeyId pkey == u
|
|
|
|
then Right ()
|
|
|
|
else Left "Actor's publicKey's ID doesn't match the keyid URI"
|
|
|
|
if publicKeyOwner pkey == actorId actor
|
|
|
|
then Right ()
|
|
|
|
else Left "Actor's publicKey's owner doesn't match the actor's ID"
|
|
|
|
case publicKeyAlgo pkey of
|
|
|
|
Nothing ->
|
|
|
|
Left $
|
|
|
|
if sigAlgo
|
|
|
|
then "Algo mismatch, Ed25519 in Sig but none in actor"
|
|
|
|
else "Algo not given in Sig nor actor"
|
|
|
|
Just algo ->
|
|
|
|
case algo of
|
|
|
|
AlgorithmEd25519 -> Right ()
|
|
|
|
AlgorithmOther _ ->
|
|
|
|
Left $
|
|
|
|
if sigAlgo
|
|
|
|
then "Algo mismatch, Ed25519 in Sig but unsupported algo in actor"
|
|
|
|
else "No algo in Sig, unsupported algo in actor"
|
|
|
|
case E.publicKey $ pemContent $ publicKeyPem pkey of
|
|
|
|
CryptoPassed k -> Right (k, actorId actor)
|
|
|
|
CryptoFailed e -> Left "Parsing Ed25519 public key failed"
|