diff --git a/app/extensions/websockets/page.mdx b/app/extensions/websockets/page.mdx
index aed1969..b2f21a5 100644
--- a/app/extensions/websockets/page.mdx
+++ b/app/extensions/websockets/page.mdx
@@ -24,13 +24,13 @@ Messages sent over the WebSocket connection are JSON objects.
- Same as the `X-Signature` header in HTTP requests.
+ Same as the `Versia-Signature` header in HTTP requests.
-
- Same as the `X-Nonce` header in HTTP requests.
+
+ Same as the `Versia-Signed-At` header in HTTP requests.
- Same as the `X-Signed-By` header in HTTP requests.
+ Same as the `Versia-Signed-By` header in HTTP requests.
Same as the request body in HTTP requests. Must be a string (stringified JSON), not JSON.
@@ -42,8 +42,8 @@ Messages sent over the WebSocket connection are JSON objects.
```jsonc {{ 'title': 'Example Message' }}
{
- "signature": "post /users/1/inbox a2ebc29eb6762a9164fbcffc9271e8a53562a5e725e7187ea7d88d03cbe59341 n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=",
- "nonce": "a2ebc29eb6762a9164fbcffc9271e8a53562a5e725e7187ea7d88d03cbe59341",
+ "signature": "/CjB2L9bcvRg+uP19B4/rqy7Ji9/cqMFPlL3GVCIndnQjYyOpBzJEAl9weDnXm7Jrqa3y6sBC+EYWKThO2r9Bw==",
+ "signed_at": "1729241807",
"signed_by": "https://bongo.social/users/63a00ab3-39b1-49eb-b88e-ed65d2361f3e",
"entity": "{\"id\":\"9a8928b6-2526-4979-aab1-ef2f88cd5700\",\"type\":\"Delete\",\"created_at\":\"2022-01-01T12:00:00Z\",\"author\":\"https://bongo.social/users/63a00ab3-39b1-49eb-b88e-ed65d2361f3e\",\"deleted\":\"https://bongo.social/notes/54059ce2-9332-46fa-bf6a-598b5493b81b\"}"
}
diff --git a/app/federation/http/page.mdx b/app/federation/http/page.mdx
index 19b5f3b..5169ea0 100644
--- a/app/federation/http/page.mdx
+++ b/app/federation/http/page.mdx
@@ -21,13 +21,13 @@ ALL kinds of HTTP requests/responses between instances **MUST** include a [Signa
Must include `application/json; charset=utf-8`, if the request has a body.
-
+
See [Signatures](/signatures) for more information.
-
+
See [Signatures](/signatures).
-
+
See [Signatures](/signatures).
@@ -40,9 +40,9 @@ ALL kinds of HTTP requests/responses between instances **MUST** include a [Signa
POST https://bob.com/users/1/inbox HTTP/1.1
Accept: application/json
User-Agent: CoolServer/1.0 (https://coolserver.com)
- X-Signature: post /users/1/inbox a2ebc29eb6762a9164fbcffc9271e8a53562a5e725e7187ea7d88d03cbe59341 n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=
- X-Signed-By: https://example.com/users/1
- X-Nonce: a2ebc29eb6762a9164fbcffc9271e8a53562a5e725e7187ea7d88d03cbe59341
+ Versia-Signature: /CjB2L9bcvRg+uP19B4/rqy7Ji9/cqMFPlL3GVCIndnQjYyOpBzJEAl9weDnXm7Jrqa3y6sBC+EYWKThO2r9Bw==
+ Versia-Signed-By: https://example.com/users/1
+ Versia-Signed-At: 1729241687
```
@@ -55,13 +55,13 @@ ALL kinds of HTTP requests/responses between instances **MUST** include a [Signa
Must include `application/json; charset=utf-8`.
-
+
See [Signatures](/signatures) for more information.
-
+
See [Signatures](/signatures).
-
+
See [Signatures](/signatures).
@@ -70,9 +70,9 @@ ALL kinds of HTTP requests/responses between instances **MUST** include a [Signa
```http {{ 'title': 'Example Response' }}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
- X-Signature: get /users/1/followers 8f872d4609d26819d03a7d60ce3db68f5b0dd5a80d5930260294f237e670ab76 YDA64iuZiGG847KPM+7BvnWKITyGyTwHbb6fVYwRx1I
- X-Signed-By: https://example.com/users/1
- X-Nonce: 8f872d4609d26819d03a7d60ce3db68f5b0dd5a80d5930260294f237e670ab76
+ Versia-Signature: /CjB2L9bcvRg+uP19B4/rqy7Ji9/cqMFPlL3GVCIndnQjYyOpBzJEAl9weDnXm7Jrqa3y6sBC+EYWKThO2r9Bw==+7BvnWKITyGyTwHbb6fVYwRx1I
+ Versia-Signed-By: https://example.com/users/1
+ Versia-Signed-At: 1729241717
```
\ No newline at end of file
diff --git a/app/signatures/page.mdx b/app/signatures/page.mdx
index 0efbbdb..afa0e2d 100644
--- a/app/signatures/page.mdx
+++ b/app/signatures/page.mdx
@@ -17,21 +17,15 @@ Versia uses cryptographic signatures to ensure the integrity and authenticity of
## Signature Definition
A signature consists of a series of headers in an HTTP request. The following headers are used:
-- **`X-Signature`**: The signature itself, encoded in base64.
-- **`X-Signed-By`**: URI of the user who signed the request, [or the string `instance $1`, to represent the instance, where `$1` is the instance's host](/entities/instance-metadata#the-null-author).
-- **`X-Nonce`**: A random string generated by the client. This is used to prevent replay attacks.
+- **`Versia-Signature`**: The signature itself, encoded in base64.
+- **`Versia-Signed-By`**: URI of the user who signed the request, [or the string `instance $1`, to represent the instance, where `$1` is the instance's host](/entities/instance-metadata#the-null-author).
+- **`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`.
-
- Versia's security model makes replay attacks useless, so they are not a concern.
-
- For more information, please read [the security model documentation](/security).
-
-
-If a signature fails, is missing or is invalid, the instance **MUST** return a `401 Unauthorized` HTTP status code.
+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
@@ -43,7 +37,7 @@ $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 nonce, a random string generated by the client.
+- `$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 a `GET` 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.
@@ -57,7 +51,8 @@ post /notes a2ebc29eb6762a9164fbcffc9271e8a53562a5e725e7187ea7d88d03cbe59341 n4b
To verify a signature, the instance must:
- Recreate the string as described above.
-- Extract the signature provided in the `X-Signature` header.
+- 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.
@@ -94,14 +89,14 @@ const privateKey = await crypto.subtle.importKey(
["sign"],
);
-const nonce = crypto.getRandomValues(new Uint8Array(32))
+const timestamp = Date.now();
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(content)
);
const stringToSign =
- `post /notes ${Buffer.from(nonce).toString("hex")} ${Buffer.from(digest).toString("base64")}`;
+ `post /notes ${timestamp} ${Buffer.from(digest).toString("base64")}`;
const signature = await crypto.subtle.sign(
"Ed25519",
@@ -117,9 +112,9 @@ To send the request, Bob would use the following code:
```typescript
const headers = new Headers();
-headers.set("X-Signed-By", "https://bob.com/users/bf44e6ad-7c0a-4560-9938-cf3fd4066511");
-headers.set("X-Nonce", Buffer.from(nonce).toString("hex"));
-headers.set("X-Signature", base64Signature);
+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", {
@@ -134,8 +129,13 @@ On Alice's side, she would verify the signature using Bob's public key. Here, we
```typescript
const method = request.method.toLowerCase();
const path = new URL(request.url).pathname;
-const signature = request.headers.get("X-Signature");
-const nonce = request.headers.get("X-Nonce");
+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",
@@ -143,7 +143,7 @@ const digest = await crypto.subtle.digest(
);
const stringToVerify =
- `${method} ${path} ${nonce} ${Buffer.from(digest).toString("base64")}`;
+ `${method} ${path} ${timestamp} ${Buffer.from(digest).toString("base64")}`;
const isVerified = await crypto.subtle.verify(
"Ed25519",