refactor: 📝 Rewrite Signatures, specify "Working Draft 4" label

This commit is contained in:
Jesse Wierzbinski 2024-08-03 00:09:36 +02:00
parent 84d5d04696
commit b0f807e2fe
No known key found for this signature in database
4 changed files with 32 additions and 48 deletions

View file

@ -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
```
<Note>
The last line of the string MUST be terminated with a newline character (`\n`).
</Note>
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",

View file

@ -51,7 +51,7 @@ export const Header = forwardRef<ElementRef<"div">, { 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<ElementRef<"div">, { className?: string }>(
>
<ul className="flex items-center gap-8">
<TopLevelNavItem href="/">API</TopLevelNavItem>
<TopLevelNavItem href="#">
Documentation
</TopLevelNavItem>
<TopLevelNavItem href="#">Support</TopLevelNavItem>
</ul>
</nav>
<div className="hidden md:block md:h-5 md:w-px md:bg-zinc-900/10 md:dark:bg-white/15" />
@ -97,8 +93,8 @@ export const Header = forwardRef<ElementRef<"div">, { className?: string }>(
<MobileSearch />
<ThemeToggle />
</div>
<div className="hidden min-[416px]:contents">
<Button href="#">Sign in</Button>
<div className="hidden min-[500px]:contents">
<Button href="#">Working Draft 4</Button>
</div>
</div>
</motion.div>

View file

@ -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<HTMLSpanElement>) {
return (
<span className="inline-flex items-center rounded-md bg-brand-50 px-2 py-0 text-xs font-medium text-brand-700 ring-1 ring-inset ring-brand-500/10 dark:bg-brand-500/10 dark:text-brand-100 dark:ring-brand-200/20 h-8">
<span
className={`inline-flex items-center justify-center rounded-md bg-brand-50 px-2 py-0 text-xs font-medium text-brand-700 ring-1 ring-inset ring-brand-500/10 dark:bg-brand-500/10 dark:text-brand-100 dark:ring-brand-200/20 h-8${className ? ` ${className}` : ""}`}
{...props}
>
{children}
</span>
);

View file

@ -282,7 +282,6 @@ export function Navigation(props: ComponentPropsWithoutRef<"nav">) {
<nav {...props} aria-label="Side navigation">
<ul>
<TopLevelNavItem href="/">API</TopLevelNavItem>
<TopLevelNavItem href="#">Support</TopLevelNavItem>
{navigation.map((group, groupIndex) => (
<NavigationGroup
key={group.title}
@ -292,7 +291,7 @@ export function Navigation(props: ComponentPropsWithoutRef<"nav">) {
))}
<li className="sticky bottom-0 z-10 mt-6 min-[416px]:hidden">
<Button href="#" variant="filled" className="w-full">
Sign in
Working Draft 4
</Button>
</li>
</ul>