mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-28 21:33:24 +03:00
Added HTTPSignature service
ref https://linear.app/tryghost/issue/MOM-72 This module handles signing and validating HTTP signatures, which is a core part of interfacing with ActivityPub enabled servers.
This commit is contained in:
parent
f34c33f330
commit
deb6e05889
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
193
ghost/ghost/src/core/activitypub/http-signature.service.ts
Normal file
193
ghost/ghost/src/core/activitypub/http-signature.service.ts
Normal file
@ -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<string, string> = 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<crypto.KeyObject> {
|
||||
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<Request> {
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user