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:
Fabien O'Carroll 2024-05-06 13:52:54 +07:00 committed by Fabien 'egg' O'Carroll
parent f34c33f330
commit deb6e05889
2 changed files with 246 additions and 0 deletions

View File

@ -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');
});
});
});

View 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
});
}
}