From 31fb1b8920311f88aecd2f865899a9d850d5e2c4 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Thu, 25 Jul 2024 14:53:05 +0200 Subject: [PATCH] docs: :memo: Finish signing docs --- app/signatures/page.mdx | 50 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/app/signatures/page.mdx b/app/signatures/page.mdx index 95d1f55..13d0e96 100644 --- a/app/signatures/page.mdx +++ b/app/signatures/page.mdx @@ -16,12 +16,19 @@ Lysand uses cryptographic signatures to ensure the integrity and authenticity of ## Signature Definition -A signature is encoded the same way that Mastodon does it. It consists of a series of HTTP headers in a request. The following headers are used: +A signature consists of a series of headers in an HTTP request. The following headers are used: - **`Signature`**: The signature itself, encoded in the following format: `keyId="$keyId",algorithm="$algorithm",headers="$headers",signature="$signature"`. - - `keyId`: URI of the user that signed the request. + - `keyId`: URI of the user that signed the request. Must be the Server Actor's URI if this request is not originated by user action. - `algorithm`: Algorithm used to sign the request. Currently, only `ed25519` is supported. - `headers`: List of headers that were signed. Should always be `(request-target) host date digest`. - `signature`: The signature itself, encoded in Base64. +- **`Date`**: Date and time of the request, formatted as an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) string. + +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 must be `GET`. + +If a signature fails, is missing or is invalid, the server **MUST** return a `401 Unauthorized` HTTP status code. ### Calculating the Signature @@ -61,6 +68,11 @@ Bob can be found at `https://bob.com/users/bf44e6ad-7c0a-4560-9938-cf3fd4066511` 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!", }); @@ -102,10 +114,44 @@ const headers = new Headers(); headers.set("Date", date); headers.set("Signature", `keyId="https://bob.com/users/bf44e6ad-7c0a-4560-9938-cf3fd4066511",algorithm="ed25519",headers="(request-target) host date digest",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). + +```typescript +const method = request.method.toLowerCase(); +const path = new URL(request.url).pathname; +const signature = request.headers.get("Signature"); +const date = new Date(request.headers.get("Date")); + +const [keyId, algorithm, headers, signature] = signature.split(",").map((part) => part.split("=")[1].replace(/"/g, "")); + +const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(await request.text()) +); + +const stringToVerify = + `(request-target): ${method} ${path}\n` + + `host: alice.com\n` + + `date: ${date.toISOString()}\n` + + `digest: SHA-256=${Buffer.from(digest).toString("base64")}\n`; + +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 }); +} ``` \ No newline at end of file