HTTP Signatures and inbox forwarding
I don’t know how I missed this issue for so long. I never even saw anyone mention it. I’d like to address the problem of verifying HTTP signatures on inbox-forwarded activities.
Imagine you’re alice@A
and you receive an activity whose as:actor
is bob@B
. There are 2 ways you can receive this activity:
- Bob addresses it to you, or to a collection owned by server B in which you’re listed, and server B sends you the activity
- Bob addresses it to a collection owned by server C, and server C then does inbox forwarding, delivering to you because you’re listed in that collection
When you get the activity in case (1), directly from B, everything works great: You verify the HTTP sig, which includes the Host and (request-target) headers, letting you know that server B is addressing this request specifically to you, and wants you to receive it. It’s not a hijacked request intended for someone else. You can tell because (request-target) contains the local path, and together with Host they make the whole request URL.
In case (2), though, there’s a problem: If you get the activity from server C, how can you determine that server B intended you to have it?
- If the HTTP sig is the one made by server B, verifying it will fail because the Host and (request-target) headers won’t match
- If the HTTP sig is a fresh one made by server C, verifying it will succeed, except the key’s actor and the activity’s actor don’t match, and live on different hosts. How do you determine whether server B wanted server C to forward this activity, or did server C just spam you with it?
- If you GET the activity from server B and use that instead of what you got from server C, you can be sure of the integrity of the activity, but it costs you a sad additional annoying HTTP request for the whole activity, and you still can’t tell whether it was intended for you
Ideas
- Don’t sign the Host and (request-target) headers
- Don’t care if they key actor and activity actor don’t match
- If key actor
c@C
and activity actorbob@B
are on different hosts, check if the activity audience contains collections owned by serverC
, possibly checking whether wealice@A
are aware of being members of those collections - When server
B
sends to serverC
, it adds a special signed header specifying it grants serverC
permission to forward; when serverC
forwards, it writes the Host, (request-target) and Signature headers to differently named headers, and makes its own HTTP signature; when we receive the signature, we notice the mismatch and check that special header, verifying both signatures
The ActivityPub spec doesn’t say exactly how to do inbox forwarding, it just says “target and deliver”. However, sending the original activity as-is creates a problem: There’s no proof of ownership of the activity, because the server sending it and the server hosting it would be different. One simple solution is to send an Announce activity linking to the original, and somehow use the addressing to signal that it’s being inbox-forwarded. The recipient server then downloads the original object from its owning server.
Other than the annoyance of downloading the activity, I see one possible issue with this approach: You lose the knowledge that the activity is specifically meant to be send to you. I’m not entirely sure though, whether this information is important. I want to try an example and check.
You’re alice@A
and you receive an Announce activity from bob@B
. The Announce’s object is an activity hosted by server C. What you don’t know, is that Bob never intended you to receive this activity. Server C just decided to spread it around to people totally unrelated to it. And now it’s annoyingly stealing your attention. However, you aren’t even aware that you weren’t supposed to receive it: You just see it in your timeline just like many other activities.
The method with the combined signatures would prevent that. When I publish a ticket comment on a remote ticket, I deliver to direct recipient users and to the target project, but when I deliver to the project I add a special signed header, saying that I allow the project actor to forward the activity. Even if it sends an Announce, it can put my little special header as a field in the Announce activity. That way, only the server owning the project can do inbox forwarding, with the project actor specifically as the announcer.
Is it possible to bypass this little protection? If yes, providing this protection doesn’t actually have any benefit. Let’s see.
Suppose I’m server C, I have a copy of the activity that bob@B
authored. If I’m not hosting the project, the protection would prevent me from doing inbox forwarding, but what if I just did a regular announce? I could spread a million regular Announces of the ticket comment. Using whichever local actors I wish. And if I’m the server hosting the project, I’m free to do fake inbox forwarding.
Assume server A does accept and happily handle regular Announce activities. I guess under this condition, the protection makes no difference. But what if server A doesn’t deal with regular Announce activities? It wants to receive only inbox forwarding. Without the protection, anyone can send Alice an inbox-forwarding Announce of Bob’s post. With protection, only server C, owning the collections, can do that, being given explicit permission by Bob. Am I right? But even then, if someone wants to spam my inbox with stuff, can’t they just send out many useless Create Note?
Decision
After a long discussion on IRC, here’s a plan.
When doing AP inbox forwarding, Vervis will:
- Send the activity as-is, not wrapped in an Announce
- Make a fresh HTTP signature
When receiving an activity in an inbox, Vervis will:
- Check if the activity’s actor and key’s actor are on the same host
- If they are, require them to be identical just like now
- If they aren’t, this means we possibly got an activity by inbox forwarding. For now, to verify its authenticity, we’ll be fetching it from the origin server. However, perhaps to reduce the spam potential, we could decide to accept and process such activities only if they have recipients on the server that forwarded the activity to us, or even better, have a special field that says which servers are allowed to forward it. Also, don’t bother to process forwarded activities for actors that never need inbox forwarding. For example, right now Create Note ticket comment is forwarded to ticket team and ticket participants, who are always Person, never Project or anything else. Projects don’t follow tickets of other projects right now (although they could), so they can ignore any forwarded activity they receive.
- Possibly in the future there can be a signature scheme that would preserve the original signature on the activity JSON, allowing us to avoid fetching the activity. This would simply be a signature with the HTTP sig key, except signing the request body (or a SHA-256 of it), not headers.
How would the signature work?
With every POST request, we also send an HTTP signature. However, that signature is meant just for that request, and it signs HTTP headers. It’s not obvious how forward the request with that signature still attached and possible to verify. So, when we intend to allow forwarding, we’ll attach a 2nd signature, as follows. It’s still possible to ignore that signature and GET the activity instead, but using the signature, when available, allows to avoid that GET step.
When a user publishes a ticket comment on a remote ticket, we deliver it to the project under which that ticket lives, and to any direct recipients listed in the activity. And when we deliver to the project, we want it to do the inbox forwarding, because it basically conceptually owns the 2 collections we’re addressing. We want to give it a signature it can forward along with the actual activity.
When we send the activity to the project’s inbox, we include an HTTP header ActivityPub-Signature. That header’s value is the base64 encoding of a cryptographic signature made on a value formed by concatenating 5 things:
- The string “SHA-256” (without the quotes)
- ASCII equal character ‘=’
- base64 encoding of the SHA-256 of the request body.
- ASCII period character ‘.’
- ID URI of the actor to whose inbox we’re sending the activity
The cryptographic signature is made with the same key as the HTTP Signature.
When the target server receives the request and does inbox forwarding, it sends the activity with a new HTTP signature of its own, and it preserves the special signature from the original request by:
- Putting the keyId of that request in the ActivityPub-Forwarded-KeyId header
- Putting the signature from the ActivityPub-Signature header in the ActivityPub-Forwarded-Signature header
Note that due to inbox forwarding it’s possible that a server receives an activity it owns! We need to allow that in the YesodHttpSig instance, and to support that in handleSharerInbox by inserting an InboxItemLocal
rather than InboxItemRemote
. We should probably verify our own signature there to be sure the forwarding is okay, and then I suppose find the LocalMessageId
in our database.
Q: What happens if, by the time the activity is forwarded, the key used for the original signature is unavailable or expires?
A: GET the activity ID URI to have a proof of authenticity. Another option would be to allow different keys, and let the forwarded signature’s key stay for a long time, weeks and months, but let’s not go there yet.
Q: If we receive an activity in which activity actor and key actor are different, but on the same server, should we check for forwarding headers and process it?
A: If a user makes a comment on a ticket on the same server, then the C2S outbox handler make sure to deliver the activity to the ticket followers. This currently happens by delivering the activity from the user as the key’s actor, not the project, and I guess it can work like this in general, so, there’s no need to handle the case of different same-host actors. No harm in it, but currently it’s never being generated, so if we see it, it may indicate a bug in the code
Q: What if instead of a custom input string, we use a HTTP signatures, except with a custom header name? Could that equally fill the requirements?
A: Let’s see. The idea is as follows: When an activity is sent to someone for forwarding, their actor URI is placed in an ActivityPub-Forwarder
header, and in addition to the regular HTTP sig in Signature
, another signature is sent in the ForwardedSignature
header. That signature signs at least the Digest
and the ActivityPub-Forwarder
, and probably nothing more. The receiving server does inbox forwarding by sending the activity with a new fresh HTTP sig of its own in Signature
, but it sends some headers intact: Digest
, ActivityPub-Forwarder
, and ForwardedSignature
. It’s possible that a different header name is used here and not ForwardedSignature
, so that forwarder and forwarding-target see differently named headers, but the question is, do we benefit from them being different? Let’s try to check.
When I receive an activity and the activity’s actor is identical to the sender’s actor, maybe somehow it’s possible that someone is inbox-forwarding their own activity? But even if that’s the case, makes no difference to me. I accept the activity. If ActivityPub-Forwarder
header is present, and set to my actor ID, then I can do inbox forwarding.
If the activity actor and sender actor are on different hosts, I reject the activity unless it’s forwarded. How do I know? I check the HTTP sig in ForwardedSignature
! If it exists and it signs at least Digest
and ActivityPub-Forwarder
, and ActivityPub-Forwarder
is identical to the sender actor (which, as I said, isn’t the activity actor), then I take it for valid forwarding and accept the activity.
Sounds good so far! But is there any scenario in which I might get confused between receiving a forwarded activity and needing to forward an activity? In such cases what if I determine the wrong author, or I forward something I shouldn’t, or I don’t forward when I should?
So, suppose I receive an activity where sender host and activity host are the same. What if it’s some weird case where the activity is forwarded? In the case I’m meant to do forwarding, the ActivityPub-Forwarder
header should be me. In the case the activity is forwarded, that header should specify the actor from whom I received the activity, the sender actor. So, here’s the rule. If I receive an activity from me to myself, I ignore it, I think the AP spec mentions that, either way I guess I’d want to ignore. But otherwise, if sender isn’t me: If ActivityPub-Forwarder
is me, I should do forwarding. If it’s not me, someone did forwarding, so it should be the actor from whom I received. I can just ignore the ForwardedSignature
in that case, but it would be nice to log a warning for debugging:
- First of all the sender’s host mustn’t be the local one
- And we’re handling the case that sender and activity hosts match
- If actors match and
ActivityPub-Forwarder
is me, that’s okay, I’m supposed to do forwarding - If actors match and
Activity-Forwarder
is the sender, are they forwarding their own activity to me? That’s weird, log a warning, it may be a bug - If actors match and
Activity-Forwarder
is some other actor, neither me nor the sender, it means the sender is forwarding its own activity without having its own permission for that (which is super weird, do accept the activity but log a warning), or the sender wants someone to do forwarding, but not us (log a warning) - If actors don’t match, it means one actor is forwarding for another actor on the same server, that isn’t supposed to happen, log a warning because it may be a bug
Now, the other case is that the hosts are different. So I require to see ActivityPub-Forwarder
and ForwardedSignature
headers. The usual possibility is that I got a forwarded activity: If the ActivityPub-Forwarder
is the sender actor, then I got valid forwarding and I accept. What if I’m being asked to forward though? Let’s say alice@A is sending me an activity by bob@B, and I’m chris@C. If ActivityPub-Forwarder
is the sender actor-
Hmmm this is feeling so complicated. It basically sounds okay, let’s go forward with this and change header names later if needed.
NOTE: When I send an activity for forwarding, I can sign ForwardedSignature
with any of my keys, not necessarily the same one as Signature
. When I receive an activity for forwarding, I can do forwarding without checking or verifying the ForwardedSignature
at all, although I could. When I receive a forwarded activity, say, from alice@A who is the ActivityPub-Forwarder
and ActivityPub-Actor
, but activity author is bob@B, I need to make sure that the ForwardedSignature
is made with a key owned by bob@B. Otherwise, how do I know Bob authorized the forwarding? It could be anyone else authorizing it, right? Bob has to list the key, and the key has to list Bob as owner, or be an instance-scope key for server B.
DECISION: At least for now, for safety and for forward compatibility with forward chains (you receive a forwarded message and forward it further), let’s use separate names:
- When you send an activity and ask to forward it, you put the signature in the
ActivityPub-Signature
header - When you forward an activity, you place that signature in the
ActivityPub-Forwarded-Signature
header
Status: Closed on 2019-05-08 by ~fr33domlover