feat(federation): Update signature code to Working Draft 4 of Versia

This commit is contained in:
Jesse Wierzbinski 2024-08-24 19:48:37 +02:00
parent 0ca7bb3bda
commit a7e4092a93
No known key found for this signature in database
4 changed files with 84 additions and 128 deletions

BIN
bun.lockb

Binary file not shown.

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 date: string; let nonce: string;
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("Signature") ?? ""; signature = headers.get("X-Signature") ?? "";
date = headers.get("Date") ?? ""; nonce = headers.get("X-Nonce") ?? "";
}); });
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: {
Signature: signature, "X-Signature": signature,
Date: date, "X-Nonce": nonce,
}, },
body: body, body: body,
}); });
@ -55,47 +55,40 @@ describe("SignatureValidator", () => {
expect(isValid).toBe(true); expect(isValid).toBe(true);
}); });
test("should throw with an invalid signature", async () => { test("should return false with an invalid signature", async () => {
const request = new Request("https://example.com", { const request = new Request("https://example.com", {
method: "GET", method: "GET",
headers: { headers: {
Signature: "invalid", "X-Signature": "invalid",
Date: date, "X-Nonce": nonce,
}, },
body: body, body: body,
}); });
expect(() => validator.validate(request)).toThrow(TypeError); const isValid = await validator.validate(request);
expect(isValid).toBe(false);
}); });
test("should throw with missing headers", async () => { test("should throw with missing nonce", async () => {
const request = new Request("https://example.com", { const request = new Request("https://example.com", {
method: "GET", method: "GET",
headers: { headers: {
Signature: signature, "X-Signature": signature,
}, },
body: body, body: body,
}); });
expect(() => validator.validate(request)).toThrow(TypeError); expect(() => validator.validate(request)).toThrow(
}); "Headers are missing in request: X-Nonce",
);
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 () => { test("should not verify a valid signature with a different body", async () => {
const request = new Request("https://example.com", { const request = new Request("https://example.com", {
method: "GET", method: "GET",
headers: { headers: {
Signature: signature, "X-Signature": signature,
Date: date, "X-Nonce": nonce,
}, },
body: "different", body: "different",
}); });
@ -104,19 +97,19 @@ describe("SignatureValidator", () => {
expect(isValid).toBe(false); expect(isValid).toBe(false);
}); });
test("should not verify a signature with a wrong key", async () => { test("should throw if signature is not base64", async () => {
const request = new Request("https://example.com", { const request = new Request("https://example.com", {
method: "GET", method: "GET",
headers: { headers: {
Signature: "X-Signature": "thisIsNotbase64OhNo$^ù",
'keyId="badbbadwrong",algorithm="ed25519",headers="(request-target) host date digest",signature="ohno"', "X-Nonce": nonce,
Date: date,
}, },
body: body, body: body,
}); });
const isValid = await validator.validate(request); expect(() => validator.validate(request)).toThrow(
expect(isValid).toBe(false); "Signature is not valid base64",
);
}); });
}); });
}); });
@ -158,27 +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("Signature")).toBeDefined(); expect(headers.get("X-Signature")).toBeDefined();
expect(headers.get("Date")).toBeDefined(); expect(headers.get("X-Nonce")).toBeDefined();
// Check structure of Signature expect(headers.get("X-Nonce")?.length).toBeGreaterThan(10);
const signature = headers.get("Signature") ?? ""; expect(headers.get("X-Signature")?.length).toBeGreaterThan(10);
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();
}); });
test("should correctly sign a Request", async () => { test("should correctly sign a Request", async () => {
@ -190,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("Signature")).toBeDefined(); expect(headers.get("X-Signature")).toBeDefined();
expect(headers.get("Date")).toBeDefined(); expect(headers.get("X-Nonce")).toBeDefined();
expect(await newRequest.text()).toBe(body); expect(await newRequest.text()).toBe(body);
}); });

View file

@ -16,6 +16,9 @@ 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) {
@ -75,23 +78,23 @@ 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 date The date that the request was signed. * @param nonce Signature nonce.
* @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.
* @returns A Promise that resolves to a boolean indicating whether the signature is valid. * @returns A Promise that resolves to a boolean indicating whether the signature is valid.
* @throws TypeError if any required parameters are missing or empty. * @throws TypeError if any required parameters are missing or empty.
* @example * @example
* const signature = "keyId=\"https://example.com\",algorithm=\"ed25519\",headers=\"(request-target) host date digest\",signature=\"base64Signature\""; * const signature = "k4QNt5Grl40KK8orIdiaq118Z+P5pa6vIeArq55wsvfL7wNy4cE3f2fhsGcpZql+PStm+x2ZjZIhudrAC/32Cg==";
* const date = new Date("2021-01-01T00:00:00.000Z"); * const nonce = "bJzyhTNK2RXUCetKIpm0Fw==";
* 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, date, method, url, body); * const isValid = await validator.validate(signature, nonce, method, url, body);
*/ */
async validate( async validate(
signature: string, signature: string,
date: Date, nonce: string,
method: HttpVerb, method: HttpVerb,
url: URL, url: URL,
body: string, body: string,
@ -99,28 +102,25 @@ export class SignatureValidator {
async validate( async validate(
requestOrSignature: Request | string, requestOrSignature: Request | string,
date?: Date, nonce?: string,
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("Signature"); const signature = requestOrSignature.headers.get("X-Signature");
const date = requestOrSignature.headers.get("Date"); const nonce = requestOrSignature.headers.get("X-Nonce");
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 && "Signature", !signature && "X-Signature",
!date && "Date", !nonce && "X-Nonce",
!method && "Method",
!url && "URL",
!body && "Body",
].filter(Boolean); ].filter(Boolean);
// Check if all headers are present // Check if all headers are present
if (!(signature && date && method && url && body)) { if (!(signature && nonce && 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,49 +129,30 @@ export class SignatureValidator {
); );
} }
if (signature.split("signature=").length < 2) { return this.validate(signature, nonce, method, url, body);
throw new TypeError(
"Invalid Signature header (wrong format or missing signature)",
);
}
const extractedSignature = signature
.split("signature=")[1]
.replace(/"/g, "");
if (!extractedSignature) {
throw new 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)) { if (!(nonce && method && url && body)) {
throw new TypeError( throw new TypeError(
"Missing or empty required parameters: date, method, url or body", "Missing or empty required parameters: nonce, method, url or body",
); );
} }
const signature = requestOrSignature; const signature = requestOrSignature;
// Check if signature is base64
try {
atob(signature);
} catch {
throw new TypeError("Signature is not valid base64");
}
const digest = await crypto.subtle.digest( const digest = await crypto.subtle.digest(
"SHA-256", "SHA-256",
new TextEncoder().encode(body), new TextEncoder().encode(body),
); );
const expectedSignedString = const expectedSignedString = `${method.toLowerCase()} ${encodeURIComponent(url.pathname)} ${nonce} ${arrayBufferToBase64(digest)}`;
`(request-target): ${method.toLowerCase()} ${url.pathname}\n` +
`host: ${url.host}\n` +
`date: ${date.toISOString()}\n` +
`digest: SHA-256=${arrayBufferToBase64(digest)}\n`;
// Check if signed string is valid // Check if signed string is valid
const isValid = await crypto.subtle.verify( const isValid = await crypto.subtle.verify(
@ -193,15 +174,15 @@ export class SignatureConstructor {
/** /**
* Creates a new instance of SignatureConstructor. * Creates a new instance of SignatureConstructor.
* @param privateKey The private key used for signature generation. * @param privateKey The private key used for signature generation.
* @param keyId The key ID used for the Signature header. * @param authorUri URI of the User who is signing the request.
* @example * @example
* const privateKey = // CryptoKey * const privateKey = // CryptoKey
* const keyId = "https://example.com/users/6a18f2c3-120e-4949-bda4-2aa4c8264d51"; * const authorUri = "https://example.com/users/6a18f2c3-120e-4949-bda4-2aa4c8264d51";
* const constructor = new SignatureConstructor(privateKey, keyId); * const constructor = new SignatureConstructor(privateKey, authorUri);
*/ */
constructor( constructor(
private privateKey: CryptoKey, private privateKey: CryptoKey,
private keyId: string, private authorUri: URL | string,
) { ) {
checkEvironmentSupport(); checkEvironmentSupport();
} }
@ -209,16 +190,16 @@ export class SignatureConstructor {
/** /**
* Creates a SignatureConstructor instance from a base64-encoded private key. * Creates a SignatureConstructor instance from a base64-encoded private key.
* @param base64PrivateKey The base64-encoded private key. * @param base64PrivateKey The base64-encoded private key.
* @param keyId The key ID used for the Signature header. * @param authorUri URI of the User who is signing the request.
* @returns A Promise that resolves to a SignatureConstructor instance. * @returns A Promise that resolves to a SignatureConstructor instance.
* @example * @example
* const privateKey = "base64PrivateKey"; * const privateKey = "base64PrivateKey";
* const keyId = "https://example.com/users/6a18f2c3-120e-4949-bda4-2aa4c8264d51"; * const authorUri = "https://example.com/users/6a18f2c3-120e-4949-bda4-2aa4c8264d51";
* const constructor = await SignatureConstructor.fromStringKey(privateKey, keyId); * const constructor = await SignatureConstructor.fromStringKey(privateKey, authorUri);
*/ */
static async fromStringKey( static async fromStringKey(
base64PrivateKey: string, base64PrivateKey: string,
keyId: string, authorUri: URL | string,
): Promise<SignatureConstructor> { ): Promise<SignatureConstructor> {
return new SignatureConstructor( return new SignatureConstructor(
await crypto.subtle.importKey( await crypto.subtle.importKey(
@ -228,7 +209,7 @@ export class SignatureConstructor {
false, false,
["sign"], ["sign"],
), ),
keyId, authorUri,
); );
} }
@ -251,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 date The date that the request was signed (optional) * @param nonce The signature nonce (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
@ -265,7 +246,7 @@ export class SignatureConstructor {
url: URL, url: URL,
body?: string, body?: string,
headers?: Headers, headers?: Headers,
date?: Date, nonce?: string,
): Promise<{ ): Promise<{
headers: Headers; headers: Headers;
signedString: string; signedString: string;
@ -276,7 +257,7 @@ export class SignatureConstructor {
url?: URL, url?: URL,
body?: string, body?: string,
headers: Headers = new Headers(), headers: Headers = new Headers(),
date?: Date, nonce?: string,
): Promise< ): Promise<
| { | {
headers: Headers; headers: Headers;
@ -295,13 +276,14 @@ export class SignatureConstructor {
new URL(requestOrMethod.url), new URL(requestOrMethod.url),
await requestOrMethod.text(), await requestOrMethod.text(),
requestOrMethod.headers, requestOrMethod.headers,
requestOrMethod.headers.get("Date") requestOrMethod.headers.get("X-Nonce") ?? undefined,
? new Date(requestOrMethod.headers.get("Date") ?? "")
: undefined,
); );
request.headers.set("Date", headers.get("Date") ?? ""); request.headers.set("X-Nonce", headers.get("X-Nonce") ?? "");
request.headers.set("Signature", headers.get("Signature") ?? ""); request.headers.set(
"X-Signature",
headers.get("X-Signature") ?? "",
);
return { request, signedString }; return { request, signedString };
} }
@ -312,20 +294,18 @@ export class SignatureConstructor {
); );
} }
const finalDate = date?.toISOString() ?? new Date().toISOString(); const finalNonce =
nonce ||
uint8ArrayToBase64(crypto.getRandomValues(new Uint8Array(16)));
const digest = await crypto.subtle.digest( const digest = await crypto.subtle.digest(
"SHA-256", "SHA-256",
new TextEncoder().encode(body ?? ""), new TextEncoder().encode(body ?? ""),
); );
const signedString = const signedString = `${requestOrMethod.toLowerCase()} ${encodeURIComponent(
`(request-target): ${requestOrMethod.toLowerCase()} ${ url.pathname,
url.pathname )} ${finalNonce} ${arrayBufferToBase64(digest)}`;
}\n` +
`host: ${url.host}\n` +
`date: ${finalDate}\n` +
`digest: SHA-256=${arrayBufferToBase64(digest)}\n`;
const signature = await crypto.subtle.sign( const signature = await crypto.subtle.sign(
"Ed25519", "Ed25519",
@ -335,11 +315,9 @@ export class SignatureConstructor {
const signatureBase64 = arrayBufferToBase64(signature); const signatureBase64 = arrayBufferToBase64(signature);
headers.set("Date", finalDate); headers.set("X-Nonce", finalNonce);
headers.set( headers.set("X-Signature", signatureBase64);
"Signature", headers.set("X-Signed-By", this.authorUri.toString());
`keyId="${this.keyId}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
);
return { return {
headers, headers,

View file

@ -9,7 +9,8 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.8.3", "@biomejs/biome": "^1.8.3",
"@types/bun": "^1.1.6", "@types/bun": "^1.1.7",
"@types/node": "^22.5.0",
"bun-plugin-dts": "^0.2.3" "bun-plugin-dts": "^0.2.3"
}, },
"trustedDependencies": ["@biomejs/biome"], "trustedDependencies": ["@biomejs/biome"],