From b0f807e2fe66b5ef6b473c35549e17a612891d7c Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Sat, 3 Aug 2024 00:09:36 +0200 Subject: [PATCH] refactor: :memo: Rewrite Signatures, specify "Working Draft 4" label --- app/signatures/page.mdx | 54 +++++++++++++-------------------------- components/Header.tsx | 10 +++----- components/Metadata.tsx | 13 +++++++--- components/Navigation.tsx | 3 +-- 4 files changed, 32 insertions(+), 48 deletions(-) diff --git a/app/signatures/page.mdx b/app/signatures/page.mdx index d3cef01..6684906 100644 --- a/app/signatures/page.mdx +++ b/app/signatures/page.mdx @@ -17,16 +17,13 @@ Lysand 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: -- **`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. 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. +- **`X-Signature`**: The signature itself, encoded in base64. +- **`X-Signed-By`**: URI of the user who signed the request. +- **`X-Nonce`**: A random string generated by the client. This is used to prevent replay attacks. 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`. +- **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`. If a signature fails, is missing or is invalid, the server **MUST** return a `401 Unauthorized` HTTP status code. @@ -34,31 +31,23 @@ If a signature fails, is missing or is invalid, the server **MUST** return a `40 Create a string containing the following (including newlines): ``` -(request-target): $0 $1 -host: $2 -date: $3 -digest: SHA-256=$4 +$0 $1 $2 $3 ``` - - The last line of the string MUST be terminated with a newline character (`\n`). - - 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 hostname of the server (e.g. `example.com`, not `https://example.com` or `example.com/`). -- `$3` is the date and time of the request, formatted as an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) string. -- `$4` is the SHA-256 hash of the request body, encoded in Base64. +- `$2` is the nonce, a random string generated by the client. +- `$3` is the SHA-256 hash of the request body, encoded in base64. -Sign this string using the user's private key. The resulting signature should be encoded in Base64. +Sign this string using the user's private key. The resulting signature should be encoded in base64. ### Verifying the Signature To verify a signature, the server must: - Recreate the string as described above. -- Extract the signature provided in the `Signature` header (`$signature` in the above section). -- Decode the signature from Base64. +- Extract the signature provided in the `X-Signature` header. +- Decode the signature from base64. - Perform a signature verification using the user's public key. ### Example @@ -94,17 +83,14 @@ const privateKey = await crypto.subtle.importKey( ["sign"], ); -const date = new Date().toISOString(); +const nonce = crypto.getRandomValues(new Uint8Array(32)) const digest = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(content) ); const stringToSign = - `(request-target): post /notes\n` + - `host: alice.com\n` + - `date: ${date}\n` + - `digest: SHA-256=${Buffer.from(digest).toString("base64")}\n`; + `post /notes ${Buffer.from(nonce).toString("hex")} ${Buffer.from(digest).toString("base64")}`; const signature = await crypto.subtle.sign( "Ed25519", @@ -120,8 +106,9 @@ To send the request, Bob would use the following code: ```typescript 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("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("Content-Type", "application/json"); const response = await fetch("https://alice.com/notes", { @@ -136,10 +123,8 @@ 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("Signature"); -const date = new Date(request.headers.get("Date")); - -const [keyId, algorithm, headers, signature] = signature.split(",").map((part) => part.split("=")[1].replace(/"/g, "")); +const signature = request.headers.get("X-Signature"); +const nonce = request.headers.get("X-Nonce"); const digest = await crypto.subtle.digest( "SHA-256", @@ -147,10 +132,7 @@ const digest = await crypto.subtle.digest( ); const stringToVerify = - `(request-target): ${method} ${path}\n` + - `host: alice.com\n` + - `date: ${date.toISOString()}\n` + - `digest: SHA-256=${Buffer.from(digest).toString("base64")}\n`; + `${method} ${path} ${nonce} ${Buffer.from(digest).toString("base64")}`; const isVerified = await crypto.subtle.verify( "Ed25519", diff --git a/components/Header.tsx b/components/Header.tsx index e1eccd4..90b334a 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -51,7 +51,7 @@ export const Header = forwardRef, { className?: string }>( ref={ref} className={clsx( className, - "fixed inset-x-0 top-0 z-50 flex h-14 items-center justify-between gap-12 px-4 transition sm:px-6 lg:left-72 lg:z-30 lg:px-8 xl:left-80", + "fixed inset-x-0 top-0 z-50 flex h-14 items-center justify-between gap-2 px-4 transition sm:px-6 lg:left-72 lg:z-30 lg:px-8 xl:left-80", !isInsideMobileNavigation && "backdrop-blur-sm lg:left-72 xl:left-80 dark:backdrop-blur", isInsideMobileNavigation @@ -86,10 +86,6 @@ export const Header = forwardRef, { className?: string }>( >
    API - - Documentation - - Support
@@ -97,8 +93,8 @@ export const Header = forwardRef, { className?: string }>(
-
- +
+
diff --git a/components/Metadata.tsx b/components/Metadata.tsx index 3162673..ebd242b 100644 --- a/components/Metadata.tsx +++ b/components/Metadata.tsx @@ -2,11 +2,18 @@ import { Icon } from "@iconify-icon/react/dist/iconify.mjs"; import { motion } from "framer-motion"; -import { type ReactNode, useState } from "react"; +import { type HTMLAttributes, type ReactNode, useState } from "react"; -export function Badge({ children }: { children: ReactNode }) { +export function Badge({ + children, + className, + ...props +}: HTMLAttributes) { return ( - + {children} ); diff --git a/components/Navigation.tsx b/components/Navigation.tsx index 31271ff..b3a44ce 100644 --- a/components/Navigation.tsx +++ b/components/Navigation.tsx @@ -282,7 +282,6 @@ export function Navigation(props: ComponentPropsWithoutRef<"nav">) {