Signatures
Versia uses cryptographic signatures to ensure the integrity and authenticity of data. Signatures are used to verify that the data has not been tampered with and that it was created by the expected user.
This part is very important! If signatures are implemented incorrectly in your instance, you will not be able to federate.
Mistakes made in this section can lead to security vulnerabilities and impersonation attacks.
Signature Definition
A signature consists of a series of headers in an HTTP request. The following headers are used:
Versia-Signature
: The signature itself, encoded in base64.Versia-Signed-By
: URI of the user who signed the request, or the stringinstance $1
, to represent the instance, where$1
is the instance's host.Versia-Signed-At
: The current Unix timestamp, in seconds (no milliseconds), when the request was signed. Timezone must be UTC, like all Unix timestamps.
Signatures are required on ALL federation traffic. If a request does not have a signature, it MUST be rejected. Specifically, signatures must be put on:
- All POST requests.
- All responses to GET requests (for example, when fetching a user's profile). In this case, the HTTP method used in the signature string must be
GET
.
If a signature fails, is missing or is invalid, the instance MUST return a 401 Unauthorized
HTTP status code. If the signature timestamp is too old or too new (more than 5 minutes from the current time), the instance MUST return a 422 Unprocessable Entity
status code.
Calculating the Signature
Create a string containing the following (including newlines):
$0 $1 $2 $3
Where:
$0
is the HTTP method (e.g.GET
,POST
) in lowercase.$1
is the path of the request, in standard URI format (don't forget to URL-encode it).$2
is the Unix timestamp when the request was signed, in UTC seconds.$3
is the SHA-256 hash of the request body, encoded in base64. (if it's aGET
request, this should be the hash of an empty string)
Sign this string using the user's private key. The resulting signature should be encoded in base64.
Example:
post /notes 1729243417 n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=
Verifying the Signature
To verify a signature, the instance must:
- Recreate the string as described above.
- Extract the signature provided in the
Versia-Signature
header. - Check that the
Versia-Signed-At
timestamp is within 5 minutes of the current time. - Decode the signature from base64.
- Perform a signature verification using the user's public key.
Example
The following example is written in TypeScript using the WebCrypto API.
@bob
, from bob.com
, wants to sign a request to alice.com
. The request is a POST
to /notes
, with the following body:
{
"content": "Hello, world!"
}
Bob can be found at https://bob.com/users/bf44e6ad-7c0a-4560-9938-cf3fd4066511
. His ed25519 private key, encoded in Base64 PKCS8, is MC4CAQAwBQYDK2VwBCIEILrNXhbWxC/MhKQDsJOAAF1FH/R+Am5G/eZKnqNum5ro
.
Here's how Bob would sign the request:
/**
* Using Node.js's Buffer API for brevity
* If using another runtime, you may need to use a different method to convert to/from Base64
*/
const content = JSON.stringify({
content: "Hello, world!",
});
const base64PrivateKey = "MC4CAQAwBQYDK2VwBCIEILrNXhbWxC/MhKQDsJOAAF1FH/R+Am5G/eZKnqNum5ro";
const privateKey = await crypto.subtle.importKey(
"pkcs8",
Buffer.from(base64PrivateKey, "base64"),
"Ed25519",
false,
["sign"],
);
const timestamp = Date.now();
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(content)
);
const stringToSign =
`post /notes ${timestamp} ${Buffer.from(digest).toString("base64")}`;
const signature = await crypto.subtle.sign(
"Ed25519",
privateKey,
new TextEncoder().encode(stringToSign)
);
const base64Signature = Buffer.from(signature).toString("base64");
To send the request, Bob would use the following code:
const headers = new Headers();
headers.set("Versia-Signed-By", "https://bob.com/users/bf44e6ad-7c0a-4560-9938-cf3fd4066511");
headers.set("Versia-Signed-At", timestamp);
headers.set("Versia-Signature", base64Signature);
headers.set("Content-Type", "application/json");
const response = await fetch("https://alice.com/notes", {
method: "POST",
headers,
body: content,
});
On Alice's side, she would verify the signature using Bob's public key. Here, we assume that Alice has Bob's public key stored in a variable called publicKey
(during real federation, this would be fetched from Bob's profile).
const method = request.method.toLowerCase();
const path = new URL(request.url).pathname;
const signature = request.headers.get("Versia-Signature");
const timestamp = request.headers.get("Versia-Signed-At");
// Check if timestamp is within 5 minutes of the current time
if (Math.abs(Date.now() - timestamp) > 300_000) {
return new Response("Timestamp is too old or too new", { status: 422 });
}
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(await request.text())
);
const stringToVerify =
`${method} ${path} ${timestamp} ${Buffer.from(digest).toString("base64")}`;
const isVerified = await crypto.subtle.verify(
"Ed25519",
publicKey,
Buffer.from(signature, "base64"),
new TextEncoder().encode(stringToVerify)
);
if (!isVerified) {
return new Response("Signature verification failed", { status: 401 });
}
Exporting the Public Key
Public keys are always encoded using base64
and must be in SPKI format. You will need to look up the appropriate method for your cryptographic library to convert the key to this format.
This is not the same as the key's raw bytes.
This is also not related to the commonly used "PEM" format.
Example using TypeScript and the WebCrypto API
/**
* Using Node.js's Buffer API for brevity
* If using another runtime, you may need to use a different method to convert to/from Base64
*/
const spkiEncodedPublicKey = await crypto.subtle.exportKey(
"spki",
/* Your public key */
publicKey,
);
const base64PublicKey = Buffer.from(publicKey).toString("base64");