mirror of
https://github.com/versia-pub/api.git
synced 2025-12-06 08:28:19 +01:00
feat(federation): ✨ Add cryptography
This commit is contained in:
parent
967ceb8cde
commit
b86933e77d
|
|
@ -77,10 +77,11 @@ This library is built for JavaScript runtimes with the support for:
|
|||
|
||||
- [**ES Modules**](https://nodejs.org/api/esm.html)
|
||||
- [**ECMAScript 2020**](https://www.ecma-international.org/ecma-262/11.0/index.html)
|
||||
- (only required for cryptography) [**Ed25519**](https://en.wikipedia.org/wiki/EdDSA) cryptography in the [**WebCrypto API**](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API)
|
||||
|
||||
#### Runtimes
|
||||
|
||||
- **Node.js**: 14.0+ is the minimum, but only Node.js 20.0+ (LTS) is officially supported.
|
||||
- **Node.js**: 14.0+ is the minimum (18.0+ for cryptography), but only Node.js 20.0+ (LTS) is officially supported.
|
||||
- **Deno**: Support is unknown. 1.0+ is expected to work.
|
||||
- **Bun**: Bun 1.1.8 is the minimum-supported version. As Bun is rapidly evolving, this may change. Previous versions may also work.
|
||||
|
||||
|
|
@ -95,6 +96,11 @@ Consequently, this library is compatible without any bundling in the following b
|
|||
- **Opera**: 67+
|
||||
- **Internet Explorer**: None
|
||||
|
||||
Cryptography functions are supported in the following browsers:
|
||||
|
||||
- **Safari**: 17.0+
|
||||
- **Chrome**: 113.0+ with `#enable-experimental-web-platform-features` enabled
|
||||
|
||||
If you are targeting older browsers, please don't, you are doing yourself a disservice.
|
||||
|
||||
Transpilation to non-ES Module environments is not officially supported, but should be simple with the use of a bundler like [**Parcel**](https://parceljs.org) or [**Rollup**](https://rollupjs.org).
|
||||
|
|
|
|||
184
federation/cryptography/index.test.ts
Normal file
184
federation/cryptography/index.test.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { describe, beforeAll, test, expect, beforeEach } from "bun:test";
|
||||
import { SignatureValidator, SignatureConstructor } from "./index";
|
||||
|
||||
describe("SignatureValidator", () => {
|
||||
let validator: SignatureValidator;
|
||||
let privateKey: CryptoKey;
|
||||
let publicKey: CryptoKey;
|
||||
let body: string;
|
||||
let signature: string;
|
||||
let date: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const keys = await crypto.subtle.generateKey("Ed25519", true, [
|
||||
"sign",
|
||||
"verify",
|
||||
]);
|
||||
|
||||
publicKey = keys.publicKey;
|
||||
privateKey = keys.privateKey;
|
||||
|
||||
body = JSON.stringify({ key: "value" });
|
||||
|
||||
const headers = await new SignatureConstructor(
|
||||
privateKey,
|
||||
"https://bob.org/users/6a18f2c3-120e-4949-bda4-2aa4c8264d51",
|
||||
).sign("GET", new URL("https://example.com"), body);
|
||||
|
||||
signature = headers.get("Signature") ?? "";
|
||||
date = headers.get("Date") ?? "";
|
||||
});
|
||||
|
||||
test("fromStringKey", async () => {
|
||||
const base64PublicKey = Buffer.from(
|
||||
await crypto.subtle.exportKey("spki", publicKey),
|
||||
).toString("base64");
|
||||
validator = await SignatureValidator.fromStringKey(base64PublicKey);
|
||||
expect(validator).toBeInstanceOf(SignatureValidator);
|
||||
});
|
||||
|
||||
describe("Validator", async () => {
|
||||
beforeEach(() => {
|
||||
validator = new SignatureValidator(publicKey);
|
||||
});
|
||||
|
||||
test("should verify a valid signature", async () => {
|
||||
const request = new Request("https://example.com", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Signature: signature,
|
||||
Date: date,
|
||||
},
|
||||
body: body,
|
||||
});
|
||||
const isValid = await validator.validate(request);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
test("should throw with an invalid signature", async () => {
|
||||
const request = new Request("https://example.com", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Signature: "invalid",
|
||||
Date: date,
|
||||
},
|
||||
body: body,
|
||||
});
|
||||
|
||||
expect(() => validator.validate(request)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test("should throw with missing headers", async () => {
|
||||
const request = new Request("https://example.com", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Signature: signature,
|
||||
},
|
||||
body: body,
|
||||
});
|
||||
expect(() => validator.validate(request)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test("should throw with missing date", async () => {
|
||||
const request = new Request("https://example.com", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Signature: signature,
|
||||
},
|
||||
body: body,
|
||||
});
|
||||
expect(() => validator.validate(request)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test("should not verify a valid signature with a different body", async () => {
|
||||
const request = new Request("https://example.com", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Signature: signature,
|
||||
Date: date,
|
||||
},
|
||||
body: "different",
|
||||
});
|
||||
|
||||
const isValid = await validator.validate(request);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
test("should not verify a signature with a wrong key", async () => {
|
||||
const request = new Request("https://example.com", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Signature:
|
||||
'keyId="badbbadwrong",algorithm="ed25519",headers="(request-target) host date digest",signature="ohno"',
|
||||
Date: date,
|
||||
},
|
||||
body: body,
|
||||
});
|
||||
|
||||
const isValid = await validator.validate(request);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SignatureConstructor", () => {
|
||||
let ctor: SignatureConstructor;
|
||||
let privateKey: CryptoKey;
|
||||
let body: string;
|
||||
let headers: Headers;
|
||||
|
||||
beforeAll(async () => {
|
||||
const keys = await crypto.subtle.generateKey("Ed25519", true, [
|
||||
"sign",
|
||||
"verify",
|
||||
]);
|
||||
privateKey = keys.privateKey;
|
||||
body = JSON.stringify({ key: "value" });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
ctor = new SignatureConstructor(
|
||||
privateKey,
|
||||
"https://bob.org/users/6a18f2c3-120e-4949-bda4-2aa4c8264d51",
|
||||
);
|
||||
});
|
||||
|
||||
test("fromStringKey", async () => {
|
||||
const base64PrivateKey = Buffer.from(
|
||||
await crypto.subtle.exportKey("pkcs8", privateKey),
|
||||
).toString("base64");
|
||||
const constructorFromString = await SignatureConstructor.fromStringKey(
|
||||
base64PrivateKey,
|
||||
"https://bob.org/users/6a18f2c3-120e-4949-bda4-2aa4c8264d51",
|
||||
);
|
||||
expect(constructorFromString).toBeInstanceOf(SignatureConstructor);
|
||||
});
|
||||
|
||||
describe("Signing", () => {
|
||||
test("should correctly sign ", async () => {
|
||||
const url = new URL("https://example.com");
|
||||
headers = await ctor.sign("GET", url, body);
|
||||
expect(headers.get("Signature")).toBeDefined();
|
||||
expect(headers.get("Date")).toBeDefined();
|
||||
|
||||
// Check structure of Signature
|
||||
const signature = headers.get("Signature") ?? "";
|
||||
const parts = signature.split(",");
|
||||
expect(parts).toHaveLength(4);
|
||||
|
||||
expect(parts[0].split("=")[0]).toBe("keyId");
|
||||
expect(parts[1].split("=")[0]).toBe("algorithm");
|
||||
expect(parts[2].split("=")[0]).toBe("headers");
|
||||
expect(parts[3].split("=")[0]).toBe("signature");
|
||||
|
||||
expect(parts[0].split("=")[1]).toBe(
|
||||
'"https://bob.org/users/6a18f2c3-120e-4949-bda4-2aa4c8264d51"',
|
||||
);
|
||||
expect(parts[1].split("=")[1]).toBe('"ed25519"');
|
||||
expect(parts[2].split("=")[1]).toBe(
|
||||
'"(request-target) host date digest"',
|
||||
);
|
||||
expect(parts[3].split("=")[1]).toBeString();
|
||||
});
|
||||
});
|
||||
});
|
||||
318
federation/cryptography/index.ts
Normal file
318
federation/cryptography/index.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
/**
|
||||
* Represents an HTTP verb.
|
||||
*/
|
||||
type HttpVerb =
|
||||
| "GET"
|
||||
| "POST"
|
||||
| "PUT"
|
||||
| "DELETE"
|
||||
| "PATCH"
|
||||
| "OPTIONS"
|
||||
| "HEAD";
|
||||
|
||||
const checkEvironmentSupport = async () => {
|
||||
// Check if WebCrypto is supported
|
||||
if (!globalThis.crypto || !globalThis.crypto.subtle) {
|
||||
throw new Error("WebCrypto is not supported in this environment");
|
||||
}
|
||||
|
||||
// No way to check if Ed25519 is supported, so just return true
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the signature of a request.
|
||||
* @see https://lysand.org/security/signing
|
||||
*/
|
||||
export class SignatureValidator {
|
||||
/**
|
||||
* Creates a new instance of SignatureValidator.
|
||||
* @param public_key The public key used for signature verification.
|
||||
*/
|
||||
constructor(private public_key: CryptoKey) {
|
||||
checkEvironmentSupport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SignatureValidator instance from a base64-encoded public key.
|
||||
* @param base64PublicKey The base64-encoded public key.
|
||||
* @returns A Promise that resolves to a SignatureValidator instance.
|
||||
* @example
|
||||
* const publicKey = "base64PublicKey";
|
||||
* const validator = await SignatureValidator.fromStringKey(publicKey);
|
||||
*/
|
||||
static async fromStringKey(
|
||||
base64PublicKey: string,
|
||||
): Promise<SignatureValidator> {
|
||||
return new SignatureValidator(
|
||||
await crypto.subtle.importKey(
|
||||
"spki",
|
||||
Buffer.from(base64PublicKey, "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["verify"],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the signature of a request.
|
||||
* @param request The request object to validate.
|
||||
* @returns A Promise that resolves to a boolean indicating whether the signature is valid.
|
||||
* @throws TypeError if any required headers are missing in the request.
|
||||
* @example
|
||||
* const request = new Request(); // Should be a Request from a Lysand federation request
|
||||
* const isValid = await validator.validate(request);
|
||||
*/
|
||||
async validate(request: Request): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Validates the signature of a request.
|
||||
* @param signature The signature string.
|
||||
* @param date The date that the request was signed.
|
||||
* @param method The HTTP verb.
|
||||
* @param url The URL object.
|
||||
* @param body The request body.
|
||||
* @returns A Promise that resolves to a boolean indicating whether the signature is valid.
|
||||
* @throws TypeError if any required parameters are missing or empty.
|
||||
* @example
|
||||
* const signature = "keyId=\"https://example.com\",algorithm=\"ed25519\",headers=\"(request-target) host date digest\",signature=\"base64Signature\"";
|
||||
* const date = new Date("2021-01-01T00:00:00.000Z");
|
||||
* const method = "GET";
|
||||
* const url = new URL("https://example.com/users/ff54ee40-2ce9-4d2e-86ac-3cd06a1e1480");
|
||||
* const body = "{ ... }";
|
||||
* const isValid = await validator.validate(signature, date, method, url, body);
|
||||
*/
|
||||
async validate(
|
||||
signature: string,
|
||||
date: Date,
|
||||
method: HttpVerb,
|
||||
url: URL,
|
||||
body: string,
|
||||
): Promise<boolean>;
|
||||
|
||||
async validate(
|
||||
requestOrSignature: Request | string,
|
||||
date?: Date,
|
||||
method?: HttpVerb,
|
||||
url?: URL,
|
||||
body?: string,
|
||||
): Promise<boolean> {
|
||||
if (requestOrSignature instanceof Request) {
|
||||
const signature = requestOrSignature.headers.get("Signature");
|
||||
const date = requestOrSignature.headers.get("Date");
|
||||
const url = new URL(requestOrSignature.url);
|
||||
const body = await requestOrSignature.text();
|
||||
const method = requestOrSignature.method as HttpVerb;
|
||||
|
||||
const missingHeaders = [
|
||||
!signature && "Signature",
|
||||
!date && "Date",
|
||||
!method && "Method",
|
||||
!url && "URL",
|
||||
!body && "Body",
|
||||
].filter(Boolean);
|
||||
|
||||
// Check if all headers are present
|
||||
if (!signature || !date || !method || !url || !body) {
|
||||
// Say which headers are missing
|
||||
throw TypeError(
|
||||
`Headers are missing in request: ${missingHeaders.join(
|
||||
", ",
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (signature.split("signature=").length < 2) {
|
||||
throw TypeError(
|
||||
"Invalid Signature header (wrong format or missing signature)",
|
||||
);
|
||||
}
|
||||
|
||||
const extractedSignature = signature
|
||||
.split("signature=")[1]
|
||||
.replace(/"/g, "");
|
||||
|
||||
if (!extractedSignature) {
|
||||
throw TypeError(
|
||||
"Invalid Signature header (wrong format or missing signature)",
|
||||
);
|
||||
}
|
||||
|
||||
return this.validate(
|
||||
extractedSignature,
|
||||
new Date(date),
|
||||
method as HttpVerb,
|
||||
url,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
if (!date || !method || !url || !body) {
|
||||
throw TypeError(
|
||||
"Missing or empty required parameters: date, method, url or body",
|
||||
);
|
||||
}
|
||||
|
||||
const signature = requestOrSignature;
|
||||
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(body),
|
||||
);
|
||||
|
||||
const expectedSignedString =
|
||||
`(request-target): ${method.toLowerCase()} ${url.pathname}\n` +
|
||||
`host: ${url.host}\n` +
|
||||
`date: ${date.toISOString()}\n` +
|
||||
`digest: SHA-256=${Buffer.from(new Uint8Array(digest)).toString(
|
||||
"base64",
|
||||
)}\n`;
|
||||
|
||||
// Check if signed string is valid
|
||||
const isValid = await crypto.subtle.verify(
|
||||
"Ed25519",
|
||||
this.public_key,
|
||||
Buffer.from(signature, "base64"),
|
||||
new TextEncoder().encode(expectedSignedString),
|
||||
);
|
||||
|
||||
return isValid;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a signature for a request.
|
||||
* @see https://lysand.org/security/signing
|
||||
*/
|
||||
export class SignatureConstructor {
|
||||
/**
|
||||
* Creates a new instance of SignatureConstructor.
|
||||
* @param privateKey The private key used for signature generation.
|
||||
*/
|
||||
constructor(
|
||||
private privateKey: CryptoKey,
|
||||
private keyId: string,
|
||||
) {
|
||||
checkEvironmentSupport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SignatureConstructor instance from a base64-encoded private key.
|
||||
* @param base64PrivateKey The base64-encoded private key.
|
||||
* @returns A Promise that resolves to a SignatureConstructor instance.
|
||||
* @example
|
||||
* const privateKey = "base64PrivateKey";
|
||||
* const constructor = await SignatureConstructor.fromStringKey(privateKey);
|
||||
*/
|
||||
static async fromStringKey(
|
||||
base64PrivateKey: string,
|
||||
keyId: string,
|
||||
): Promise<SignatureConstructor> {
|
||||
return new SignatureConstructor(
|
||||
await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(base64PrivateKey, "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["sign"],
|
||||
),
|
||||
keyId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a request.
|
||||
* @param request The request object to sign.
|
||||
* @returns A Promise that resolves to the signed request.
|
||||
* @example
|
||||
* const request = new Request();
|
||||
* const signedRequest = await constructor.sign(request);
|
||||
*/
|
||||
async sign(request: Request): Promise<Request>;
|
||||
|
||||
/**
|
||||
* Signs a request.
|
||||
* @param method The HTTP verb.
|
||||
* @param url The URL object.
|
||||
* @param body The request body.
|
||||
* @param headers The request headers.
|
||||
* @returns A Promise that resolves to the signed headers.
|
||||
* @throws TypeError if any required parameters are missing or empty.
|
||||
* @example
|
||||
* const method = "GET";
|
||||
* const url = new URL("https://example.com");
|
||||
* const body = "request body";
|
||||
* const headers = new Headers();
|
||||
* const signedHeaders = await constructor.sign(method, url, body, headers);
|
||||
*/
|
||||
async sign(
|
||||
method: HttpVerb,
|
||||
url: URL,
|
||||
body: string,
|
||||
headers?: Headers,
|
||||
): Promise<Headers>;
|
||||
|
||||
async sign(
|
||||
requestOrMethod: Request | HttpVerb,
|
||||
url?: URL,
|
||||
body?: string,
|
||||
headers: Headers = new Headers(),
|
||||
): Promise<Request | Headers> {
|
||||
if (requestOrMethod instanceof Request) {
|
||||
const headers = await this.sign(
|
||||
requestOrMethod.method as HttpVerb,
|
||||
new URL(requestOrMethod.url),
|
||||
await requestOrMethod.text(),
|
||||
requestOrMethod.headers,
|
||||
);
|
||||
|
||||
const request = requestOrMethod.clone();
|
||||
|
||||
request.headers.set("Date", headers.get("Date") ?? "");
|
||||
request.headers.set("Signature", headers.get("Signature") ?? "");
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
if (!url || !body || !headers) {
|
||||
throw TypeError(
|
||||
"Missing or empty required parameters: url, body or headers",
|
||||
);
|
||||
}
|
||||
|
||||
const date = new Date().toISOString();
|
||||
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(body),
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign(
|
||||
"Ed25519",
|
||||
this.privateKey,
|
||||
new TextEncoder().encode(
|
||||
`(request-target): ${requestOrMethod.toLowerCase()} ${
|
||||
url.pathname
|
||||
}\n` +
|
||||
`host: ${url.host}\n` +
|
||||
`date: ${date}\n` +
|
||||
`digest: SHA-256=${Buffer.from(
|
||||
new Uint8Array(digest),
|
||||
).toString("base64")}\n`,
|
||||
),
|
||||
);
|
||||
|
||||
const signatureBase64 = Buffer.from(new Uint8Array(signature)).toString(
|
||||
"base64",
|
||||
);
|
||||
|
||||
headers.set("Date", date);
|
||||
headers.set(
|
||||
"Signature",
|
||||
`keyId="${this.keyId}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
|
||||
);
|
||||
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import type { z } from "zod";
|
||||
import { type ValidationError, fromError } from "zod-validation-error";
|
||||
import { SignatureConstructor, SignatureValidator } from "./cryptography";
|
||||
import {
|
||||
ActionSchema,
|
||||
ActorPublicKeyDataSchema,
|
||||
|
|
@ -303,4 +304,9 @@ class EntityValidator {
|
|||
}
|
||||
}
|
||||
|
||||
export { EntityValidator, type ValidationError };
|
||||
export {
|
||||
EntityValidator,
|
||||
type ValidationError,
|
||||
SignatureConstructor,
|
||||
SignatureValidator,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,9 +11,5 @@ describe("Package testing", () => {
|
|||
const validator = new EntityValidator();
|
||||
|
||||
expect(validator.Note(badObject)).rejects.toThrow();
|
||||
|
||||
console.log(
|
||||
(await validator.Note(badObject).catch((e) => e)).toString(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue