Publish 2 rotating instance-scope keys instead of the one-implicitly-shared-key

Before, there was a single key used as a personal key for all actors. Now,
things work like this:

- There are 2 keys, each time one is rotated, this way the old key remains
  valid and we can freely rotate without a risk of race conditions on other
  servers and end up with our posts being rejected
- The keys are explicitly instance-scope keys, all actors refer to them
- We add the ActivityPub-Actor header to all activity POSTs we send, to declare
  for which specific actor our signature applies. Activities and otherwise
  different payloads may have varying ways to specify attribution; using this
  header will be a standard uniform way to specify the actor, regardless of
  payload format. Of course, servers should make sure the actual activity is
  attributed to the same actor we specified in the header. (This is important
  with instance-scope keys; for personal keys it's not critical)
This commit is contained in:
fr33domlover 2019-02-07 10:34:33 +00:00
parent 8166d5b5eb
commit e325175a9c
8 changed files with 78 additions and 38 deletions

View file

@ -80,13 +80,6 @@ GroupMember
UniqueGroupMember person group UniqueGroupMember person group
RepoCollab
repo RepoId
person PersonId
role ProjectRoleId Maybe
UniqueRepoCollab repo person
ProjectRole ProjectRole
ident RlIdent ident RlIdent
sharer SharerId sharer SharerId
@ -106,6 +99,13 @@ ProjectAccess
UniqueProjectAccess role op UniqueProjectAccess role op
RepoCollab
repo RepoId
person PersonId
role ProjectRoleId Maybe
UniqueRepoCollab repo person
ProjectCollab ProjectCollab
project ProjectId project ProjectId
person PersonId person PersonId

View file

@ -26,6 +26,8 @@
/inbox InboxR GET POST /inbox InboxR GET POST
/outbox OutboxR GET POST /outbox OutboxR GET POST
/akey1 ActorKey1R GET
/akey2 ActorKey2R GET
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
-- Current user -- Current user

View file

@ -27,7 +27,7 @@ where
import Prelude import Prelude
import Control.Concurrent (threadDelay) import Control.Concurrent (threadDelay)
import Control.Concurrent.STM (TVar, writeTVar) import Control.Concurrent.STM (TVar, modifyTVar')
import Control.Monad (forever) import Control.Monad (forever)
import Control.Monad.STM (atomically) import Control.Monad.STM (atomically)
import Crypto.Error (throwCryptoErrorIO) import Crypto.Error (throwCryptoErrorIO)
@ -140,17 +140,23 @@ generateActorKey = mk <$> generateSecretKey
-- renderPEM :: PublicKey -> ByteString -- renderPEM :: PublicKey -> ByteString
-- renderPEM = pemWriteBS . PEM "PUBLIC KEY" [] . convert -- renderPEM = pemWriteBS . PEM "PUBLIC KEY" [] . convert
-- | A loop that runs forever and periodically generates a new actor key, -- | A loop that runs forever and periodically generates new actor keys,
-- storing it in a 'TVar'. -- storing them in a 'TVar'. It manages a pait of keys, and each time it toggles
actorKeyRotator :: TimeInterval -> TVar ActorKey -> IO () -- which key gets rotated.
actorKeyRotator interval key = actorKeyRotator :: TimeInterval -> TVar (ActorKey, ActorKey, Bool) -> IO ()
actorKeyRotator interval keys =
let micros = microseconds interval let micros = microseconds interval
in if 0 < micros && micros <= toInteger (maxBound :: Int) in if 0 < micros && micros <= toInteger (maxBound :: Int)
then then
let micros' = fromInteger micros let micros' = fromInteger micros
in forever $ do in forever $ do
threadDelay micros' threadDelay micros'
generateActorKey >>= atomically . writeTVar key fresh <- generateActorKey
atomically $
modifyTVar' keys $ \ (k1, k2, new1) ->
if new1
then (k1 , fresh, False)
else (fresh, k2 , True)
else else
error $ error $
"actorKeyRotator: interval out of range: " ++ show micros "actorKeyRotator: interval out of range: " ++ show micros

View file

@ -111,7 +111,9 @@ makeFoundation appSettings = do
then lin2 then lin2
else loadFont "data/LinLibertineCut.svg" else loadFont "data/LinLibertineCut.svg"
appActorKey <- newTVarIO =<< generateActorKey appActorKeys <-
newTVarIO =<<
(,,) <$> generateActorKey <*> generateActorKey <*> pure True
appCapSignKey <- loadActorKey $ appCapabilitySigningKeyFile appSettings appCapSignKey <- loadActorKey $ appCapabilitySigningKeyFile appSettings
@ -204,7 +206,7 @@ develMain = develMainHelper getApplicationDev
actorKeyPeriodicRotator :: App -> IO () actorKeyPeriodicRotator :: App -> IO ()
actorKeyPeriodicRotator app = actorKeyPeriodicRotator app =
actorKeyRotator (appActorKeyRotation $ appSettings app) (appActorKey app) actorKeyRotator (appActorKeyRotation $ appSettings app) (appActorKeys app)
sshServer :: App -> IO () sshServer :: App -> IO ()
sshServer foundation = sshServer foundation =

View file

@ -83,7 +83,7 @@ data App = App
, appLogger :: Logger , appLogger :: Logger
, appMailQueue :: Maybe (Chan (MailRecipe App)) , appMailQueue :: Maybe (Chan (MailRecipe App))
, appSvgFont :: PreparedFont Double , appSvgFont :: PreparedFont Double
, appActorKey :: TVar ActorKey , appActorKeys :: TVar (ActorKey, ActorKey, Bool)
, appCapSignKey :: ActorKey , appCapSignKey :: ActorKey
, appActivities :: TVar (Vector (UTCTime, Either String (ByteString, BL.ByteString))) , appActivities :: TVar (Vector (UTCTime, Either String (ByteString, BL.ByteString)))

View file

@ -18,6 +18,8 @@ module Vervis.Handler.Inbox
, postInboxR , postInboxR
, getOutboxR , getOutboxR
, postOutboxR , postOutboxR
, getActorKey1R
, getActorKey2R
) )
where where
@ -36,7 +38,7 @@ import Data.Aeson.Encode.Pretty.ToEncoding
import Data.Bifunctor (first, second) import Data.Bifunctor (first, second)
import Data.HashMap.Strict (HashMap) import Data.HashMap.Strict (HashMap)
import Data.List.NonEmpty (NonEmpty (..)) import Data.List.NonEmpty (NonEmpty (..))
import Data.PEM (pemContent) import Data.PEM (PEM (..))
import Data.Text (Text) import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8) import Data.Text.Encoding (encodeUtf8)
import Data.Text.Lazy.Encoding (decodeUtf8) import Data.Text.Lazy.Encoding (decodeUtf8)
@ -52,6 +54,7 @@ import Text.Blaze.Html (Html)
import UnliftIO.Exception (try) import UnliftIO.Exception (try)
import Yesod.Auth (requireAuth) import Yesod.Auth (requireAuth)
import Yesod.Core (ContentType, defaultLayout, whamlet, toHtml) import Yesod.Core (ContentType, defaultLayout, whamlet, toHtml)
import Yesod.Core.Content (TypedContent)
import Yesod.Core.Json (requireJsonBody) import Yesod.Core.Json (requireJsonBody)
import Yesod.Core.Handler import Yesod.Core.Handler
import Yesod.Form.Fields (Textarea (..), textareaField) import Yesod.Form.Fields (Textarea (..), textareaField)
@ -72,9 +75,11 @@ import Yesod.HttpSignature (verifyRequestSignature)
import qualified Network.HTTP.Signature as S (Algorithm (..)) import qualified Network.HTTP.Signature as S (Algorithm (..))
import Data.Aeson.Local (parseHttpsURI')
import Web.ActivityPub import Web.ActivityPub
import Vervis.ActorKey (actorKeySign) import Vervis.ActorKey
import Vervis.Foundation import Vervis.Foundation
import Vervis.Model import Vervis.Model
import Vervis.Settings (AppSettings (appHttpSigTimeLimit)) import Vervis.Settings (AppSettings (appHttpSigTimeLimit))
@ -264,7 +269,8 @@ postOutboxR = do
let actorID = renderUrl $ PersonR shr let actorID = renderUrl $ PersonR shr
actID = actorID <> "/fake/1" actID = actorID <> "/fake/1"
objID = actorID <> "/fake/2" objID = actorID <> "/fake/2"
keyID = actorID <> "#key" keyID1 = renderUrl ActorKey1R
keyID2 = renderUrl ActorKey2R
updateObj (Object obj) = Object $ M.insert "attributedTo" (String actorID) $ M.insert "id" (String objID) obj updateObj (Object obj) = Object $ M.insert "attributedTo" (String actorID) $ M.insert "id" (String objID) obj
updateObj v = v updateObj v = v
updateAct = M.adjust updateObj "object" . M.insert "actor" (String actorID) . M.insert "id" (String actID) updateAct = M.adjust updateObj "object" . M.insert "actor" (String actorID) . M.insert "id" (String actID)
@ -279,10 +285,40 @@ postOutboxR = do
if actorId actor /= to if actorId actor /= to
then setMessage "Fetched actor JSON but its id doesn't match the URL we fetched" then setMessage "Fetched actor JSON but its id doesn't match the URL we fetched"
else do else do
akey <- liftIO . readTVarIO =<< getsYesod appActorKey (akey1, akey2, new1) <- liftIO . readTVarIO =<< getsYesod appActorKeys
let sign b = (KeyId $ encodeUtf8 keyID, actorKeySign akey b) let (keyID, akey) =
eres' <- httpPostAP manager (actorInbox actor) (hRequestTarget :| [hHost, hDate]) sign (updateAct act) if new1
then (keyID1, akey1)
else (keyID2, akey2)
sign b = (KeyId $ encodeUtf8 keyID, actorKeySign akey b)
eres' <- httpPostAP manager (actorInbox actor) (hRequestTarget :| [hHost, hDate, hActivityPubActor]) sign actorID (updateAct act)
case eres of case eres of
Left e -> setMessage $ toHtml $ "Failed to POST to recipient's inbox: " <> T.pack (displayException e) Left e -> setMessage $ toHtml $ "Failed to POST to recipient's inbox: " <> T.pack (displayException e)
Right _ -> setMessage "Activity posted! You can go to the target server's /inbox to see the result." Right _ -> setMessage "Activity posted! You can go to the target server's /inbox to see the result."
defaultLayout $ activityWidget widget enctype defaultLayout $ activityWidget widget enctype
getActorKey :: ((ActorKey, ActorKey, Bool) -> ActorKey) -> Route App -> Handler TypedContent
getActorKey choose route = do
actorKey <-
liftIO . fmap (actorKeyPublicBin . choose) . readTVarIO =<<
getsYesod appActorKeys
renderUrl <- getUrlRender
let route2uri r =
case parseHttpsURI' $ renderUrl r of
Left e -> error e
Right u -> u
selectRep $
provideAP PublicKey
{ publicKeyId = route2uri route
, publicKeyExpires = Nothing
, publicKeyOwner = route2uri HomeR
, publicKeyPem = PEM "PUBLIC KEY" [] actorKey
, publicKeyAlgo = Just AlgorithmEd25519
, publicKeyShared = True
}
getActorKey1R :: Handler TypedContent
getActorKey1R = getActorKey (\ (k1, _, _) -> k1) ActorKey1R
getActorKey2R :: Handler TypedContent
getActorKey2R = getActorKey (\ (k1, _, _) -> k1) ActorKey2R

View file

@ -27,7 +27,6 @@ where
import Vervis.Import hiding ((==.)) import Vervis.Import hiding ((==.))
--import Prelude --import Prelude
import Data.PEM (PEM (..))
import Database.Esqueleto hiding (isNothing, count) import Database.Esqueleto hiding (isNothing, count)
import Network.URI (uriFragment, parseAbsoluteURI) import Network.URI (uriFragment, parseAbsoluteURI)
import Vervis.Form.Person import Vervis.Form.Person
@ -142,8 +141,6 @@ getPersonR shr = do
Nothing -> error "getRenderUrl produced invalid URI!!!" Nothing -> error "getRenderUrl produced invalid URI!!!"
Just u -> u Just u -> u
me = route2uri $ PersonR shr me = route2uri $ PersonR shr
actorKey <-
liftIO . fmap actorKeyPublicBin . readTVarIO =<< getsYesod appActorKey
selectRep $ do selectRep $ do
provideRep $ do provideRep $ do
secure <- getSecure secure <- getSecure
@ -154,15 +151,8 @@ getPersonR shr = do
, actorUsername = shr2text shr , actorUsername = shr2text shr
, actorInbox = route2uri InboxR , actorInbox = route2uri InboxR
, actorPublicKeys = PublicKeySet , actorPublicKeys = PublicKeySet
{ publicKey1 = Right PublicKey { publicKey1 = Left $ route2uri ActorKey1R
{ publicKeyId = me { uriFragment = "#key" } , publicKey2 = Just $ Left $ route2uri ActorKey2R
, publicKeyExpires = Nothing
, publicKeyOwner = me
, publicKeyPem = PEM "PUBLIC KEY" [] actorKey
, publicKeyAlgo = Just AlgorithmEd25519
, publicKeyShared = False
}
, publicKey2 = Nothing
} }
} }

View file

@ -32,6 +32,7 @@ module Web.ActivityPub
, Activity (..) , Activity (..)
-- * Utilities -- * Utilities
, hActivityPubActor
, provideAP , provideAP
, APGetError (..) , APGetError (..)
, httpGetAP , httpGetAP
@ -282,6 +283,9 @@ typeActivityStreams2LD :: ContentType
typeActivityStreams2LD = typeActivityStreams2LD =
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
hActivityPubActor :: HeaderName
hActivityPubActor = "ActivityPub-Actor"
provideAP :: (Monad m, ToJSON a) => a -> Writer (Endo [ProvidedRep m]) () provideAP :: (Monad m, ToJSON a) => a -> Writer (Endo [ProvidedRep m]) ()
provideAP v = do provideAP v = do
let enc = toEncoding v let enc = toEncoding v
@ -325,13 +329,11 @@ httpGetAP manager uri =
else Left $ APGetErrorContentType $ "Non-AP Content-Type: " <> decodeUtf8 b else Left $ APGetErrorContentType $ "Non-AP Content-Type: " <> decodeUtf8 b
_ -> Left $ APGetErrorContentType "Multiple Content-Type" _ -> 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. -- | Perform an HTTP POST request to submit an ActivityPub object.
-- --
-- * Verify the URI scheme is _https:_ and authority part is present -- * Verify the URI scheme is _https:_ and authority part is present
-- * Set _Content-Type_ request header -- * Set _Content-Type_ request header
-- * Set _ActivityPub-Actor_ request header
-- * Compute HTTP signature and add _Signature_ request header -- * Compute HTTP signature and add _Signature_ request header
-- * Perform the POST request -- * Perform the POST request
-- * Verify the response status is 2xx -- * Verify the response status is 2xx
@ -341,9 +343,10 @@ httpPostAP
-> URI -> URI
-> NonEmpty HeaderName -> NonEmpty HeaderName
-> (ByteString -> (KeyId, Signature)) -> (ByteString -> (KeyId, Signature))
-> Text
-> a -> a
-> m (Either HttpException (Response ())) -> m (Either HttpException (Response ()))
httpPostAP manager uri headers sign value = httpPostAP manager uri headers sign uActor value =
if uriScheme uri /= "https:" if uriScheme uri /= "https:"
then return $ Left $ InvalidUrlException (show uri) "Scheme isn't https" then return $ Left $ InvalidUrlException (show uri) "Scheme isn't https"
else liftIO $ try $ do else liftIO $ try $ do
@ -351,6 +354,7 @@ httpPostAP manager uri headers sign value =
let req' = let req' =
setRequestCheckStatus $ setRequestCheckStatus $
consHeader hContentType typeActivityStreams2LD $ consHeader hContentType typeActivityStreams2LD $
consHeader hActivityPubActor (encodeUtf8 uActor) $
req { method = "POST" req { method = "POST"
, requestBody = RequestBodyLBS $ encode value , requestBody = RequestBodyLBS $ encode value
} }