Signatures
ActivityPub servers usually sign with a key for each user. Versia, like Matrix, signs with one key for the whole instance. See Other Protocols.
Versia uses cryptographic signatures to ensure the integrity and authenticity of data. A signature proves that a message was sent by the instance named in Versia-Signed-By, and that it has not been tampered with.
For working code, see Signing Requests.
Signature Definition
A signature consists of three headers on an HTTP request or response:
Versia-Signature: The signature itself, encoded in base64.Versia-Signed-By: Domain of the instance signing the message.Versia-Signed-At: The time when the message was signed, as a Unix timestamp in whole seconds.
All base64 on this page is the standard alphabet with padding, as defined in RFC 4648, section 4.
Where Signatures Are Required
Requests and responses between instances MUST be signed with the instance’s private key, except on the routes marked as not required below.
| Route | Requests and responses |
|---|---|
| Inbox | MUST be signed |
| Entities | MUST be signed |
| Entity collections and instance collections | MUST be signed |
| Well-known | Not required |
| Instance metadata | Not required |
| WebFinger | Not required |
| Instance messaging | Not required |
Instance metadata needs no signature because it holds the public key used to verify signatures.
An instance receiving a request on a route that requires a signature MUST return a 401 Unauthorized HTTP status code if the signature is missing or invalid, or if its timestamp is too old or too new.
An instance receiving a 2xx response on these routes MUST discard it if its signature is missing, invalid, too old or too new, and treat the attempt as failed.
A response with a 4xx or 5xx status code may come from a proxy that cannot sign it, so its signature is not required to act on it. The instance acts on the status code, and ignores the body unless the signature verifies. Instances SHOULD still sign the errors they generate themselves.
Calculating the Signature
Create a string containing the following:
$0 $1 $2 $3 $4
Where:
$0is the HTTP method of the request in lowercase (e.g.get,post). If signing a response, use the method of the request it answers.$1is the Domain of the instance the request is sent to, in lowercase. If signing a response, this is the responding instance’s own domain.$2is the path and query string of the request, exactly as they appear in the HTTP request line, with no re-encoding. If there is no query string, this is the path alone, with no?. If signing a response, use the path and query string of the request it answers.$3is the value ofVersia-Signed-At.$4is the SHA-256 hash of the raw bytes of this message’s body, exactly as sent, encoded in base64. If signing a response, hash the response’s body, not the request’s. If the message has no body, this is the hash of zero bytes:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=.
Sign this string using the instance’s private key with the Ed25519 algorithm. The resulting bytes MUST be encoded in base64.
Example:
$0methodpost$1hostalice.example$2path and query/.versia/v0.7/inbox$3timestamp1729243417$4body hash4+e2vswDyKEalby/akgnvZl4yJTXIbN1u42bC6inlOo=
Full stringpost alice.example /.versia/v0.7/inbox 1729243417 4+e2vswDyKEalby/akgnvZl4yJTXIbN1u42bC6inlOo=
A signed response is not tied to one particular request. Its freshness relies on TLS and the timestamp check.
Verifying the Signature
To verify a signature, the verifying instance MUST:
- Recreate the string as described above. For
$1, use its own domain when verifying a request, and the domain it sent the request to when verifying a response. It MUST NOT read$1from theHostheader. - Extract the signature provided in the
Versia-Signatureheader. - Check that the
Versia-Signed-Attimestamp is within 5 minutes of the current time. - Decode the signature from base64 to the raw bytes.
- Verify the signature using the public key of the instance named in
Versia-Signed-By. - When verifying a response, check that
Versia-Signed-Bynames the instance the request was sent to.
Try It
This tool needs Ed25519 in the browser: Chrome 137, Firefox 129, Safari 17 or later.
Everything here runs in your browser, and nothing is sent anywhere. It starts with the message from the home page.
Repeated Signatures
A signed request stays valid for 5 minutes either side of its timestamp, so a captured request could be sent again.
An instance MUST NOT process an inbox request whose Versia-Signature it has already accepted from the same instance in the last 10 minutes, and MUST answer it with a 2xx status code so that the sender does not retry. Processing in the inbox MUST also be idempotent: receiving the same entity twice MUST have the same effect as receiving it once.
Key Rotation
An instance has one current key, published in its metadata. Rotating the key replaces it at once, and anything signed with the old key stops verifying.
Instance metadata is not signed, so TLS is what makes a published key trustworthy.
A verifying instance:
- MUST fetch the signer’s metadata again, once, before rejecting a signature that fails to verify. It SHOULD limit how often it does this for each instance, so that bad signatures cannot make it send requests. Once every 5 minutes is a reasonable limit.
- SHOULD fetch the metadata of the instances it stores keys for at least every 24 hours, so that a replaced key stops being accepted even if nothing fails to verify.
Exporting the Public Key
Public keys are always encoded using base64 and MUST be in SubjectPublicKeyInfo (SPKI) format: export the key in SPKI format, then encode it as base64. This is neither the key’s raw bytes encoded as base64, nor the commonly used “PEM” format.
const spkiEncodedPublicKey = await crypto.subtle.exportKey(
"spki",
/* Your public key */
publicKey,
);
const base64PublicKey = new Uint8Array(spkiEncodedPublicKey).toBase64();