feat(federation): 👽 Update cryptography code to Versia 0.5

This commit is contained in:
Jesse Wierzbinski 2025-02-13 17:39:16 +01:00
parent bac34b3f39
commit afec384a51
No known key found for this signature in database
2 changed files with 56 additions and 51 deletions

View file

@ -7,7 +7,7 @@ describe("SignatureValidator", () => {
let publicKey: CryptoKey; let publicKey: CryptoKey;
let body: string; let body: string;
let signature: string; let signature: string;
let nonce: string; let timestamp: Date;
beforeAll(async () => { beforeAll(async () => {
const keys = await crypto.subtle.generateKey("Ed25519", true, [ const keys = await crypto.subtle.generateKey("Ed25519", true, [
@ -25,8 +25,8 @@ describe("SignatureValidator", () => {
"https://bob.org/users/6a18f2c3-120e-4949-bda4-2aa4c8264d51", "https://bob.org/users/6a18f2c3-120e-4949-bda4-2aa4c8264d51",
).sign("GET", new URL("https://example.com"), body); ).sign("GET", new URL("https://example.com"), body);
signature = headers.get("X-Signature") ?? ""; signature = headers.get("Versia-Signature") ?? "";
nonce = headers.get("X-Nonce") ?? ""; timestamp = new Date(Number(headers.get("Versia-Signed-At")) * 1000);
}); });
test("fromStringKey", async () => { test("fromStringKey", async () => {
@ -46,8 +46,8 @@ describe("SignatureValidator", () => {
const request = new Request("https://example.com", { const request = new Request("https://example.com", {
method: "GET", method: "GET",
headers: { headers: {
"X-Signature": signature, "Versia-Signature": signature,
"X-Nonce": nonce, "Versia-Signed-At": String(timestamp.getTime() / 1000),
}, },
body: body, body: body,
}); });
@ -59,8 +59,8 @@ describe("SignatureValidator", () => {
const request = new Request("https://example.com", { const request = new Request("https://example.com", {
method: "GET", method: "GET",
headers: { headers: {
"X-Signature": "invalid", "Versia-Signature": "invalid",
"X-Nonce": nonce, "Versia-Signed-At": String(timestamp.getTime() / 1000),
}, },
body: body, body: body,
}); });
@ -70,16 +70,16 @@ describe("SignatureValidator", () => {
expect(isValid).toBe(false); expect(isValid).toBe(false);
}); });
test("should throw with missing nonce", async () => { test("should throw with missing timestamp", async () => {
const request = new Request("https://example.com", { const request = new Request("https://example.com", {
method: "GET", method: "GET",
headers: { headers: {
"X-Signature": signature, "Versia-Signature": signature,
}, },
body: body, body: body,
}); });
expect(() => validator.validate(request)).toThrow( expect(() => validator.validate(request)).toThrow(
"Headers are missing in request: X-Nonce", "Headers are missing in request: Versia-Signed-At",
); );
}); });
@ -87,8 +87,8 @@ describe("SignatureValidator", () => {
const request = new Request("https://example.com", { const request = new Request("https://example.com", {
method: "GET", method: "GET",
headers: { headers: {
"X-Signature": signature, "Versia-Signature": signature,
"X-Nonce": nonce, "Versia-Signed-At": String(timestamp.getTime() / 1000),
}, },
body: "different", body: "different",
}); });
@ -101,8 +101,8 @@ describe("SignatureValidator", () => {
const request = new Request("https://example.com", { const request = new Request("https://example.com", {
method: "GET", method: "GET",
headers: { headers: {
"X-Signature": "thisIsNotbase64OhNo$^ù", "Versia-Signature": "thisIsNotbase64OhNo$^ù",
"X-Nonce": nonce, "Versia-Signed-At": String(timestamp.getTime() / 1000),
}, },
body: body, body: body,
}); });
@ -151,11 +151,11 @@ describe("SignatureConstructor", () => {
test("should correctly sign ", async () => { test("should correctly sign ", async () => {
const url = new URL("https://example.com"); const url = new URL("https://example.com");
headers = (await ctor.sign("GET", url, body)).headers; headers = (await ctor.sign("GET", url, body)).headers;
expect(headers.get("X-Signature")).toBeDefined(); expect(headers.get("Versia-Signature")).toBeDefined();
expect(headers.get("X-Nonce")).toBeDefined(); expect(headers.get("Versia-Signed-At")).toBeDefined();
expect(headers.get("X-Nonce")?.length).toBeGreaterThan(10); expect(headers.get("Versia-Signed-At")?.length).toBeGreaterThan(10);
expect(headers.get("X-Signature")?.length).toBeGreaterThan(10); expect(headers.get("Versia-Signature")?.length).toBeGreaterThan(10);
}); });
test("should correctly sign a Request", async () => { test("should correctly sign a Request", async () => {
@ -167,8 +167,8 @@ describe("SignatureConstructor", () => {
const { request: newRequest } = await ctor.sign(request); const { request: newRequest } = await ctor.sign(request);
headers = newRequest.headers; headers = newRequest.headers;
expect(headers.get("X-Signature")).toBeDefined(); expect(headers.get("Versia-Signature")).toBeDefined();
expect(headers.get("X-Nonce")).toBeDefined(); expect(headers.get("Versia-Signed-At")).toBeDefined();
expect(await newRequest.text()).toBe(body); expect(await newRequest.text()).toBe(body);
}); });

View file

@ -16,9 +16,6 @@ const base64ToArrayBuffer = (base64: string) =>
const arrayBufferToBase64 = (arrayBuffer: ArrayBuffer) => const arrayBufferToBase64 = (arrayBuffer: ArrayBuffer) =>
btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
const uint8ArrayToBase64 = (uint8Array: Uint8Array) =>
btoa(String.fromCharCode(...uint8Array));
const checkEvironmentSupport = () => { const checkEvironmentSupport = () => {
// Check if WebCrypto is supported // Check if WebCrypto is supported
if (!globalThis.crypto?.subtle) { if (!globalThis.crypto?.subtle) {
@ -78,7 +75,7 @@ export class SignatureValidator {
/** /**
* Validates the signature of a request. * Validates the signature of a request.
* @param signature The signature string. * @param signature The signature string.
* @param nonce Signature nonce. * @param timestamp Signature timestamp.
* @param method The HTTP verb. * @param method The HTTP verb.
* @param url The URL object. * @param url The URL object.
* @param body The request body. * @param body The request body.
@ -86,15 +83,15 @@ export class SignatureValidator {
* @throws TypeError if any required parameters are missing or empty. * @throws TypeError if any required parameters are missing or empty.
* @example * @example
* const signature = "k4QNt5Grl40KK8orIdiaq118Z+P5pa6vIeArq55wsvfL7wNy4cE3f2fhsGcpZql+PStm+x2ZjZIhudrAC/32Cg=="; * const signature = "k4QNt5Grl40KK8orIdiaq118Z+P5pa6vIeArq55wsvfL7wNy4cE3f2fhsGcpZql+PStm+x2ZjZIhudrAC/32Cg==";
* const nonce = "bJzyhTNK2RXUCetKIpm0Fw=="; * const date = new Date(1549312452000)
* const method = "GET"; * const method = "GET";
* const url = new URL("https://example.com/users/ff54ee40-2ce9-4d2e-86ac-3cd06a1e1480"); * const url = new URL("https://example.com/users/ff54ee40-2ce9-4d2e-86ac-3cd06a1e1480");
* const body = "{ ... }"; * const body = "{ ... }";
* const isValid = await validator.validate(signature, nonce, method, url, body); * const isValid = await validator.validate(signature, date, method, url, body);
*/ */
async validate( async validate(
signature: string, signature: string,
nonce: string, timestamp: Date,
method: HttpVerb, method: HttpVerb,
url: URL, url: URL,
body: string, body: string,
@ -102,25 +99,28 @@ export class SignatureValidator {
async validate( async validate(
requestOrSignature: Request | string, requestOrSignature: Request | string,
nonce?: string, timestamp?: Date,
method?: HttpVerb, method?: HttpVerb,
url?: URL, url?: URL,
body?: string, body?: string,
): Promise<boolean> { ): Promise<boolean> {
if (requestOrSignature instanceof Request) { if (requestOrSignature instanceof Request) {
const signature = requestOrSignature.headers.get("X-Signature"); const signature =
const nonce = requestOrSignature.headers.get("X-Nonce"); requestOrSignature.headers.get("Versia-Signature");
const timestampHeader =
requestOrSignature.headers.get("Versia-Signed-At");
const timestamp = new Date(Number(timestampHeader) * 1000);
const url = new URL(requestOrSignature.url); const url = new URL(requestOrSignature.url);
const body = await requestOrSignature.text(); const body = await requestOrSignature.text();
const method = requestOrSignature.method as HttpVerb; const method = requestOrSignature.method as HttpVerb;
const missingHeaders = [ const missingHeaders = [
!signature && "X-Signature", !signature && "Versia-Signature",
!nonce && "X-Nonce", !timestampHeader && "Versia-Signed-At",
].filter(Boolean); ].filter(Boolean);
// Check if all headers are present // Check if all headers are present
if (!(signature && nonce && method && url && body)) { if (!(signature && timestampHeader && method && url && body)) {
// Say which headers are missing // Say which headers are missing
throw new TypeError( throw new TypeError(
`Headers are missing in request: ${missingHeaders.join( `Headers are missing in request: ${missingHeaders.join(
@ -129,12 +129,12 @@ export class SignatureValidator {
); );
} }
return this.validate(signature, nonce, method, url, body); return this.validate(signature, timestamp, method, url, body);
} }
if (!(nonce && method && url && body)) { if (!(timestamp && method && url && body)) {
throw new TypeError( throw new TypeError(
"Missing or empty required parameters: nonce, method, url or body", "Missing or empty required parameters: timestamp, method, url or body",
); );
} }
@ -152,7 +152,7 @@ export class SignatureValidator {
new TextEncoder().encode(body), new TextEncoder().encode(body),
); );
const expectedSignedString = `${method.toLowerCase()} ${encodeURIComponent(url.pathname)} ${nonce} ${arrayBufferToBase64(digest)}`; const expectedSignedString = `${method.toLowerCase()} ${encodeURIComponent(url.pathname)} ${timestamp.getTime() / 1000} ${arrayBufferToBase64(digest)}`;
// Check if signed string is valid // Check if signed string is valid
const isValid = await crypto.subtle.verify( const isValid = await crypto.subtle.verify(
@ -232,7 +232,7 @@ export class SignatureConstructor {
* @param url The URL object. * @param url The URL object.
* @param body The request body. * @param body The request body.
* @param headers The request headers. * @param headers The request headers.
* @param nonce The signature nonce (optional). * @param timestamp The signature timestamp (optional).
* @returns A Promise that resolves to the signed headers, and the signed string. * @returns A Promise that resolves to the signed headers, and the signed string.
* @throws TypeError if any required parameters are missing or empty. * @throws TypeError if any required parameters are missing or empty.
* @example * @example
@ -246,7 +246,7 @@ export class SignatureConstructor {
url: URL, url: URL,
body?: string, body?: string,
headers?: Headers, headers?: Headers,
nonce?: string, timestamp?: Date,
): Promise<{ ): Promise<{
headers: Headers; headers: Headers;
signedString: string; signedString: string;
@ -257,7 +257,7 @@ export class SignatureConstructor {
url?: URL, url?: URL,
body?: string, body?: string,
headers: Headers = new Headers(), headers: Headers = new Headers(),
nonce?: string, timestamp?: Date,
): Promise< ): Promise<
| { | {
headers: Headers; headers: Headers;
@ -270,19 +270,23 @@ export class SignatureConstructor {
> { > {
if (requestOrMethod instanceof Request) { if (requestOrMethod instanceof Request) {
const request = requestOrMethod.clone(); const request = requestOrMethod.clone();
const signedAt = requestOrMethod.headers.get("Versia-Signed-At");
const { headers, signedString } = await this.sign( const { headers, signedString } = await this.sign(
requestOrMethod.method as HttpVerb, requestOrMethod.method as HttpVerb,
new URL(requestOrMethod.url), new URL(requestOrMethod.url),
await requestOrMethod.text(), await requestOrMethod.text(),
requestOrMethod.headers, requestOrMethod.headers,
requestOrMethod.headers.get("X-Nonce") ?? undefined, signedAt ? new Date(Number(signedAt) * 1000) : undefined,
); );
request.headers.set("X-Nonce", headers.get("X-Nonce") ?? "");
request.headers.set( request.headers.set(
"X-Signature", "Versia-Signed-At",
headers.get("X-Signature") ?? "", headers.get("Versia-Signed-At") ?? "",
);
request.headers.set(
"Versia-Signature",
headers.get("Versia-Signature") ?? "",
); );
return { request, signedString }; return { request, signedString };
@ -294,9 +298,7 @@ export class SignatureConstructor {
); );
} }
const finalNonce = const finalTimestamp = timestamp || new Date();
nonce ||
uint8ArrayToBase64(crypto.getRandomValues(new Uint8Array(16)));
const digest = await crypto.subtle.digest( const digest = await crypto.subtle.digest(
"SHA-256", "SHA-256",
@ -305,7 +307,7 @@ export class SignatureConstructor {
const signedString = `${requestOrMethod.toLowerCase()} ${encodeURIComponent( const signedString = `${requestOrMethod.toLowerCase()} ${encodeURIComponent(
url.pathname, url.pathname,
)} ${finalNonce} ${arrayBufferToBase64(digest)}`; )} ${finalTimestamp.getTime() / 1000} ${arrayBufferToBase64(digest)}`;
const signature = await crypto.subtle.sign( const signature = await crypto.subtle.sign(
"Ed25519", "Ed25519",
@ -315,9 +317,12 @@ export class SignatureConstructor {
const signatureBase64 = arrayBufferToBase64(signature); const signatureBase64 = arrayBufferToBase64(signature);
headers.set("X-Nonce", finalNonce); headers.set(
headers.set("X-Signature", signatureBase64); "Versia-Signed-At",
headers.set("X-Signed-By", this.authorUri.toString()); String(finalTimestamp.getTime() / 1000),
);
headers.set("Versia-Signature", signatureBase64);
headers.set("Versia-Signed-By", this.authorUri.toString());
return { return {
headers, headers,