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",