mirror of
https://github.com/versia-pub/docs.git
synced 2025-12-06 14:28:20 +01:00
refactor: 📝 Rewrite Signatures, specify "Working Draft 4" label
This commit is contained in:
parent
84d5d04696
commit
b0f807e2fe
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue