docs/app/signatures/page.mdx

183 lines
6.7 KiB
Plaintext

export const metadata = {
title: 'Signatures',
description:
'Learn how signatures work, and how to implement them in your Versia instance.',
}
# 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. {{ className: 'lead' }}
<Note>
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**.
</Note>
## 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](/api/basics#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 `POST` requests**.
- **All `GET` requests**.
- **All responses to `GET` requests** (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:
- `$0` is the HTTP method (e.g. `GET`, `POST`) in lowercase. If signing a *response*, use the method of the original request.
- `$1` is the request pathname, URL-encoded.
- `$2` is the Unix timestamp when the request was signed, in UTC seconds (integer).
- `$3` is the SHA-256 hash of the request body, encoded in base64. (if it's a `GET` request, this should be the hash of an empty string)
Sign this string using the instance's private key with the [Ed25519](https://en.wikipedia.org/wiki/EdDSA#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-Signature` header.
- Check that the `Versia-Signed-At` timestamp 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:
```json
{
"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](https://en.wikipedia.org/wiki/PKCS_8), is `MC4CAQAwBQYDK2VwBCIEILrNXhbWxC/MhKQDsJOAAF1FH/R+Am5G/eZKnqNum5ro`.
Here's how Bob would sign the request:
```typescript
/**
* 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:
```typescript
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](/api/endpoints#instance-metadata)).
```typescript
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](https://en.wikipedia.org/wiki/Simple_public-key_infrastructure) format. You will need to look up the appropriate method for your cryptographic library to convert the key to this format.
<Note>
This is **not** merely the key's raw bytes encoded as base64. You must export the key in [SPKI](https://en.wikipedia.org/wiki/Simple_public-key_infrastructure) format, *then* encode it as base64.
This is also not the commonly used "PEM" format.
</Note>
```typescript {{ title: "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");
```