I made a mistake in verifying HTTP Message Signatures
It's never great to find out you're wrong, but that's how learning and personal growth happens.
HTTP Message Signatures are hard1. There are lots of complex parts and getting any aspect wrong means certain death2.
In a previous post, I wrote A simple(ish) guide to verifying HTTP Message Signatures in PHP. It turns out that it was too simple. And far too trusting.
An HTTP Message Signature is a header which is separate to the message it signs. You might receive a JSON message like this:
JSON{
"actor": "https://example.com/user/Alice",
"message": "We strike at dawn!"
}
How do you know that really came from Alice? You look at the header of the message. It will be something like:
Signature:
keyId="https://example.org/user/Alice#main-key",
algorithm="rsa-sha256",
headers="(request-target) host date digest",
signature="/AJ4Dv/wSL3XE1dLjFHCYVc7AF4f3+Q10G/r8+6cPsooiUh2K3YX3z++Nclo4qKHYr61yu+T4OMqUry1T6ZHmZqmNkg1RpVg=="
We want to check that Alice signed this message with her private key. So we grab her public key given by the keyId
. From there, we do some fancy maths using RSA-SHA256 and conclude that, when you put together the (request-target) host date digest content-type
and compare them to the public key, they can only have be signed by the private key. Hurrah!
Did you spot the mistake I made? It wasn't in the maths, or the complex ordering of the data, or the algorithm choice, or some weird Unicode problem.
I made an error in trust.
Take a look at the Signature again.
The keyId
is from example.org. But the actor is from example.com.
This message is signed correctly. It is cryptographically valid. But it wasn't signed by the actor in the message!
In this case, the fix is simple. Get the public key from keyId
. Then independently get the named actor's public key. If they match, all is well. If not, skulduggery is afoot.
I'm almost tempted to say that you should ignore the provided keyId
entirely; the source of truth is the actor's key - and the best way to get that is directly from the actor's profile.
Please explain why I'm wrong in the comments.
marius says:
@blog you have two problems:
1/ Verify that the HTTP Signature is valid, and as Evan told you some time ago: you should always use the key in the header signature for that.
2/ You need to verify that the Activity actor is the same as the actor that owns that key.
In my opinion trying to simplify this is incorrect and you should always treat them as separate steps happening at different stages in the validation process.
marius says:
@blog on why they should be different steps: it could be that you want certain actors to be able to effect activities on behalf of other actors.
Think something like, posting on behalf of a Group actor.
Or anonymizing a Flag activity. You put a generic Actor in the Activity and you sign it as the instance Service actor on behalf of a regular user.
It's up to every server/instance if they want to support these things, but they seem pretty useful to me.
Julian Lam says:
@mariusor @blog I really feel like these should all be in a curated collection of security concerns for ActivityPub.
The spec glosses over security, but rightfully so, as it's not a flexible enough medium.
Justin Thomas says:
It also looks like you included "content-type" in your verification step when it wasn't included in the signature.
But I did totally miss the org/com switch. I'll have to check my implementation, but I'm pretty sure I don't use the keyId location at all. I start from the Actor and go to webfinger.
PointlessOne :loading: said on status.pointless.one:
@Edent It’s never great to be wrong but it’s always great to find out when you are.
More comments on Mastodon.