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: Domain of the instance authoring the request.Versia-Signed-At: The current Unix timestamp, in seconds (integer), when the request was signed. Timezone must be UTC, like all Unix timestamps.
Signatures must be put on:
- All
POSTrequests. - All
GETrequests. - All responses to
GETrequests (for example, when fetching a user's profile).
If a signature fails, is missing or is invalid, the instance must return a 401 Unauthorized HTTP status code. If the signature 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:
$0is the HTTP method (e.g.GET,POST) in lowercase. If signing a response, use the method of the original request.$1is the request pathname, URL-encoded.$2is the Unix timestamp when the request was signed, in UTC seconds (integer).$3is the SHA-256 hash of the request body, encoded in base64. (if it's aGETrequest, this should be the hash of an empty string)
Sign this string using the instance's private key with the Ed25519 algorithm. The resulting bytes must be encoded in base64.
Example:
post /notes 1729243417 n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=
Verifying the Signature
To verify a signature, the verifying instance must:
- Recreate the string as described above.
- 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.
- Perform a signature verification using the sender instance'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 /.versia/v0.6/inbox, with the following body:
{
"content": "Hello, world!"
}
Bob can be found at https://bob.com/.versia/v0.6/entities/User/bf44e6ad-7c0a-4560-9938-cf3fd4066511. His instance's 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 /.versia/v0.6/inbox ${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", "bob.com");
headers.set("Versia-Signed-At", timestamp);
headers.set("Versia-Signature", base64Signature);
headers.set("Content-Type", "application/vnd.versia+json; charset=utf-8");
const response = await fetch("https://alice.com/.versia/v0.6/inbox", {
method: "POST",
headers,
body: content,
});
On Alice's side, she would verify the signature using Bob's instance's public key. Here, we assume that Alice has Bob's instance's public key stored in a variable called publicKey (during real federation, this would be fetched from the instance's metadata endpoint).
const method = request.method.toLowerCase();
const path = new URL(request.url).pathname;
const signature = request.headers.get("Versia-Signature");
const timestamp = Number(request.headers.get("Versia-Signed-At")) * 1000; // Convert to milliseconds
// 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 merely the key's raw bytes encoded as base64. You must export the key in SPKI format, then encode it as base64.
This is also not 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");