diff --git a/ghost/ghost/src/core/activitypub/http-signature.service.test.ts b/ghost/ghost/src/core/activitypub/http-signature.service.test.ts new file mode 100644 index 0000000000..c327196623 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/http-signature.service.test.ts @@ -0,0 +1,53 @@ +import assert from 'assert'; +import {HTTPSignature} from './http-signature.service'; + +describe('HTTPSignature', function () { + describe('#validate', function () { + it('returns true when the signature is valid', async function () { + const requestMethod = 'POST'; + const requestUrl = '/activitypub/inbox/deadbeefdeadbeefdeadbeef'; + const requestHeaders = new Headers({ + host: 'a424-171-97-56-187.ngrok-free.app', + 'user-agent': 'http.rb/5.2.0 (Mastodon/4.3.0-nightly.2024-04-30; +https://mastodon.social/)', + 'content-length': '286', + 'accept-encoding': 'gzip', + 'content-type': 'application/activity+json', + date: 'Thu, 02 May 2024 09:51:57 GMT', + digest: 'SHA-256=tbr1NMXoLisaWc4LplxkUO19vrpGSjslPpHN5qGMEaU=', + signature: 'keyId="https://mastodon.social/users/testingshtuff#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="rbkHYjeJ6WpO5Pa6Ui3Z/9GzOeB4c/3IMKlXH+ZrBwtAy7DGannGzHXBe+sYWlLOS9U18IQvOcHvsnWkKMs6f63Fbk9kIylxoSOwZqlkWekI5/dfAhEnlz6azW0X3psiW6I/nAqTdAmWYTqszfQVRD19TwgsQXNsPVD/lEfbsopANCGALePY7mPhmf/ukGluy7Ck4sskwDn6eCqoSHSXi7Mav6ZEp5OABX9C626CyvRG5U/IWE2AVjc8hwGghp7NUgxSLiMKk/Tt3xKFd39dDMDJwj8NinCZQTBmvcZurdzChH2ShDsETxZDvPTFrj30jeH2g29kxZhq5rqHP7a6Gw=="', + 'x-forwarded-for': '49.13.137.65', + 'x-forwarded-host': 'a424-171-97-56-187.ngrok-free.app', + 'x-forwarded-proto': 'https' + }); + const requestBody = Buffer.from('eyJAY29udGV4dCI6Imh0dHBzOi8vd3d3LnczLm9yZy9ucy9hY3Rpdml0eXN0cmVhbXMiLCJpZCI6Imh0dHBzOi8vbWFzdG9kb24uc29jaWFsLzgzMWNlOWMyLWNkYWYtNGJhMC05NmUyLWE3MzY5NDk3MmI5OSIsInR5cGUiOiJGb2xsb3ciLCJhY3RvciI6Imh0dHBzOi8vbWFzdG9kb24uc29jaWFsL3VzZXJzL3Rlc3RpbmdzaHR1ZmYiLCJvYmplY3QiOiJodHRwczovL2E0MjQtMTcxLTk3LTU2LTE4Ny5uZ3Jvay1mcmVlLmFwcC9hY3Rpdml0eXB1Yi9hY3Rvci9kZWFkYmVlZmRlYWRiZWVmZGVhZGJlZWYifQ==', 'base64'); + + const actual = await HTTPSignature.validate(requestMethod, requestUrl, requestHeaders, requestBody); + const expected = true; + + assert.equal(actual, expected, 'The signature should have been validated'); + }); + it('also returns true when the signature is valid', async function () { + const requestMethod = 'POST'; + const requestUrl = '/activitypub/inbox/deadbeefdeadbeefdeadbeef'; + const requestHeaders = new Headers({ + host: 'a424-171-97-56-187.ngrok-free.app', + 'user-agent': 'http.rb/5.2.0 (Mastodon/4.3.0-nightly.2024-04-30; +https://mastodon.social/)', + 'content-length': '438', + 'accept-encoding': 'gzip', + 'content-type': 'application/activity+json', + date: 'Thu, 02 May 2024 09:51:30 GMT', + digest: 'SHA-256=Bru67GlP+0N3ySTtv/D8/QfhCaBc2P9vC1AjCxl5gmA=', + signature: 'keyId="https://mastodon.social/users/testingshtuff#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="qx5uo2gRN447a1B+yzjFyc5zy/lYCZqC8tJnIe2Tn6Q+vvVLRZL5hUoZQhFzwlxMPpcpibz2EoFdGlNBf/OFuNBoKa+dsjRA9JyCyc0fd/W2adoA+cp/y1smgSpLFjZUrIViG/SfnVBa3JTw+YeeqX4yY27WYiDMw1hSiQYGWbb64kwayChP6povH5MyoqkjyS1QZWYxOmbn27hlcGuqHgqhEEQhDeqwVEOPzq+JrkuosfIxCPTw/oLX0SWITGUwIffXFquOIV8oB1pWkqfbIXjstrMfFq5n48Ee/5vadsj3rR/dDFLMbUUAwO7uKTsvfurcWmzM4fJKoLyAOxzAgQ=="', + 'x-forwarded-for': '78.47.65.118', + 'x-forwarded-host': 'a424-171-97-56-187.ngrok-free.app', + 'x-forwarded-proto': 'https' + }); + const requestBody = Buffer.from('eyJAY29udGV4dCI6Imh0dHBzOi8vd3d3LnczLm9yZy9ucy9hY3Rpdml0eXN0cmVhbXMiLCJpZCI6Imh0dHBzOi8vbWFzdG9kb24uc29jaWFsL3VzZXJzL3Rlc3RpbmdzaHR1ZmYjZm9sbG93cy80MjQ3NDc2Ny91bmRvIiwidHlwZSI6IlVuZG8iLCJhY3RvciI6Imh0dHBzOi8vbWFzdG9kb24uc29jaWFsL3VzZXJzL3Rlc3RpbmdzaHR1ZmYiLCJvYmplY3QiOnsiaWQiOiJodHRwczovL21hc3RvZG9uLnNvY2lhbC8yNmY5M2Q2Yy03NmU3LTRiNzAtOWE4Yy03MzMzMTBhMjU4MjQiLCJ0eXBlIjoiRm9sbG93IiwiYWN0b3IiOiJodHRwczovL21hc3RvZG9uLnNvY2lhbC91c2Vycy90ZXN0aW5nc2h0dWZmIiwib2JqZWN0IjoiaHR0cHM6Ly9hNDI0LTE3MS05Ny01Ni0xODcubmdyb2stZnJlZS5hcHAvYWN0aXZpdHlwdWIvYWN0b3IvZGVhZGJlZWZkZWFkYmVlZmRlYWRiZWVmIn19', 'base64'); + + const actual = await HTTPSignature.validate(requestMethod, requestUrl, requestHeaders, requestBody); + const expected = true; + + assert.equal(actual, expected, 'The signature should have been validated'); + }); + }); +}); diff --git a/ghost/ghost/src/core/activitypub/http-signature.service.ts b/ghost/ghost/src/core/activitypub/http-signature.service.ts new file mode 100644 index 0000000000..ba9824dc83 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/http-signature.service.ts @@ -0,0 +1,193 @@ +import crypto from 'node:crypto'; + +type Signature = { + signature: Buffer + headers: string[] + keyId: URL + algorithm: string +}; + +export class HTTPSignature { + private static generateSignatureString( + signature: Signature, + headers: Headers, + requestMethod: string, + requestUrl: string + ): string { + const data = signature.headers + .map((header) => { + return `${header}: ${this.getHeader( + header, + headers, + requestMethod, + requestUrl + )}`; + }) + .join('\n'); + + return data; + } + + private static parseSignatureHeader(signature: string): Signature { + const signatureData: Record = signature + .split(',') + .reduce((data, str) => { + try { + const [key, value] = str.split('='); + return { + // values are wrapped in quotes like key="the value" + [key]: value.replace(/"/g, ''), + ...data + }; + } catch (err) { + return data; + } + }, {}); + + if ( + !signatureData.signature || + !signatureData.headers || + !signatureData.keyId || + !signatureData.algorithm + ) { + throw new Error('Could not parse signature'); + } + + return { + keyId: new URL(signatureData.keyId), + headers: signatureData.headers.split(/\s/), + signature: Buffer.from(signatureData.signature, 'base64url'), + algorithm: signatureData.algorithm + }; + } + + private static getHeader( + header: string, + headers: Headers, + requestMethod: string, + requestUrl: string + ) { + if (header === '(request-target)') { + return `${requestMethod.toLowerCase()} ${requestUrl}`; + } + if (!headers.has(header)) { + throw new Error(`Missing Header ${header}`); + } + return headers.get(header); + } + + protected static async getPublicKey(keyId: URL): Promise { + try { + const keyRes = await fetch(keyId, { + headers: { + Accept: 'application/ld+json' + } + }); + + // This whole thing is wrapped in try/catch so we can just cast as we want and not worry about errors + const json = (await keyRes.json()) as { + publicKey: { publicKeyPem: string }; + }; + + const key = crypto.createPublicKey(json.publicKey.publicKeyPem); + return key; + } catch (err) { + throw new Error(`Could not find public key ${keyId.href}: ${err}`); + } + } + + private static validateDigest( + signatureData: Signature, + requestBody: Buffer, + requestHeaders: Headers + ) { + const digest = crypto + .createHash(signatureData.algorithm) + .update(requestBody) + .digest('base64'); + + const remoteDigest = requestHeaders.get('digest')?.split('SHA-256=')[1]; + + return digest === remoteDigest; + } + + static async validate( + requestMethod: string, + requestUrl: string, + requestHeaders: Headers, + requestBody: Buffer = Buffer.alloc(0, 0) + ) { + const signatureHeader = requestHeaders.get('signature'); + if (typeof signatureHeader !== 'string') { + throw new Error('Invalid Signature header'); + } + const signatureData = this.parseSignatureHeader(signatureHeader); + + if (requestMethod.toLowerCase() === 'post') { + const digestIsValid = this.validateDigest( + signatureData, + requestBody, + requestHeaders + ); + if (!digestIsValid) { + return false; + } + } + + const publicKey = await this.getPublicKey(signatureData.keyId); + const signatureString = this.generateSignatureString( + signatureData, + requestHeaders, + requestMethod, + requestUrl + ); + + const verified = crypto + .createVerify(signatureData.algorithm) + .update(signatureString) + .verify(publicKey, signatureData.signature); + + return verified; + } + + static async sign( + request: Request, + keyId: URL, + privateKey: crypto.KeyObject + ): Promise { + let headers; + if (request.method.toLowerCase() === 'post') { + headers = ['(request-target)', 'host', 'date', 'digest']; + } else { + headers = ['(request-target)', 'host', 'date']; + } + const signatureData: Signature = { + signature: Buffer.alloc(0, 0), + headers, + keyId, + algorithm: 'rsa-sha256' + }; + const url = new URL(request.url); + const signatureString = this.generateSignatureString( + signatureData, + request.headers, + request.method, + url.pathname + ); + const signature = crypto + .createSign(signatureData.algorithm) + .update(signatureString) + .sign(privateKey) + .toString('base64'); + + const newHeaders = new Headers(request.headers); + newHeaders.set( + 'Signature', + `keyId="${keyId}",headers="${headers.join(' ')}",signature="${signature}",algorithm="${signatureData.algorithm}"` + ); + + return new Request(request, { + headers: newHeaders + }); + } +}