From b86933e77d2d859e146877b79c3488e143269373 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 14 May 2024 12:44:34 -1000 Subject: [PATCH] feat(federation): :sparkles: Add cryptography --- federation/README.md | 8 +- federation/cryptography/index.test.ts | 184 +++++++++++++++ federation/cryptography/index.ts | 318 ++++++++++++++++++++++++++ federation/index.ts | 8 +- federation/tests/index.test.ts | 4 - 5 files changed, 516 insertions(+), 6 deletions(-) create mode 100644 federation/cryptography/index.test.ts create mode 100644 federation/cryptography/index.ts diff --git a/federation/README.md b/federation/README.md index cc7fbe2..89816f5 100644 --- a/federation/README.md +++ b/federation/README.md @@ -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). diff --git a/federation/cryptography/index.test.ts b/federation/cryptography/index.test.ts new file mode 100644 index 0000000..8ca7a70 --- /dev/null +++ b/federation/cryptography/index.test.ts @@ -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(); + }); + }); +}); diff --git a/federation/cryptography/index.ts b/federation/cryptography/index.ts new file mode 100644 index 0000000..276d35d --- /dev/null +++ b/federation/cryptography/index.ts @@ -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 { + 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; + + /** + * 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; + + async validate( + requestOrSignature: Request | string, + date?: Date, + method?: HttpVerb, + url?: URL, + body?: string, + ): Promise { + 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 { + 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; + + /** + * 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; + + async sign( + requestOrMethod: Request | HttpVerb, + url?: URL, + body?: string, + headers: Headers = new Headers(), + ): Promise { + 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; + } +} diff --git a/federation/index.ts b/federation/index.ts index 6a83439..15709d6 100644 --- a/federation/index.ts +++ b/federation/index.ts @@ -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, +}; diff --git a/federation/tests/index.test.ts b/federation/tests/index.test.ts index b76d3c3..b802cb5 100644 --- a/federation/tests/index.test.ts +++ b/federation/tests/index.test.ts @@ -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(), - ); }); });