2023-07-27 05:31:52 +00:00
|
|
|
/*
|
2024-02-13 15:59:27 +00:00
|
|
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
2023-07-27 05:31:52 +00:00
|
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
*/
|
|
|
|
|
2022-09-17 18:27:08 +00:00
|
|
|
import * as crypto from 'node:crypto';
|
|
|
|
import { URL } from 'node:url';
|
|
|
|
import { Inject, Injectable } from '@nestjs/common';
|
|
|
|
import { DI } from '@/di-symbols.js';
|
2022-09-20 20:33:11 +00:00
|
|
|
import type { Config } from '@/config.js';
|
2023-09-20 02:33:36 +00:00
|
|
|
import type { MiUser } from '@/models/User.js';
|
2023-04-05 03:10:40 +00:00
|
|
|
import { UserKeypairService } from '@/core/UserKeypairService.js';
|
2023-01-25 03:00:04 +00:00
|
|
|
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
2023-01-12 12:03:02 +00:00
|
|
|
import { LoggerService } from '@/core/LoggerService.js';
|
2022-12-04 06:03:09 +00:00
|
|
|
import { bindThis } from '@/decorators.js';
|
2023-01-12 12:03:02 +00:00
|
|
|
import type Logger from '@/logger.js';
|
2024-03-30 11:05:58 +00:00
|
|
|
import type { IObject } from './type.js';
|
2024-02-17 03:41:19 +00:00
|
|
|
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
2024-03-30 11:05:58 +00:00
|
|
|
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
|
2022-09-17 18:27:08 +00:00
|
|
|
|
|
|
|
type Request = {
|
|
|
|
url: string;
|
2023-01-25 03:00:04 +00:00
|
|
|
method: string;
|
2022-09-17 18:27:08 +00:00
|
|
|
headers: Record<string, string>;
|
|
|
|
};
|
|
|
|
|
|
|
|
type Signed = {
|
|
|
|
request: Request;
|
|
|
|
signingString: string;
|
|
|
|
signature: string;
|
|
|
|
signatureHeader: string;
|
|
|
|
};
|
|
|
|
|
|
|
|
type PrivateKey = {
|
|
|
|
privateKeyPem: string;
|
|
|
|
keyId: string;
|
|
|
|
};
|
|
|
|
|
2023-02-24 07:10:48 +00:00
|
|
|
export class ApRequestCreator {
|
2024-01-06 00:07:48 +00:00
|
|
|
static createSignedPost(args: { key: PrivateKey, url: string, body: string, digest?: string, additionalHeaders: Record<string, string> }): Signed {
|
2022-09-17 18:27:08 +00:00
|
|
|
const u = new URL(args.url);
|
2024-01-06 00:07:48 +00:00
|
|
|
const digestHeader = args.digest ?? this.createDigest(args.body);
|
2022-09-17 18:27:08 +00:00
|
|
|
|
|
|
|
const request: Request = {
|
|
|
|
url: u.href,
|
|
|
|
method: 'POST',
|
2023-02-24 07:10:48 +00:00
|
|
|
headers: this.#objectAssignWithLcKey({
|
2022-09-17 18:27:08 +00:00
|
|
|
'Date': new Date().toUTCString(),
|
2023-01-16 17:21:15 +00:00
|
|
|
'Host': u.host,
|
2022-09-17 18:27:08 +00:00
|
|
|
'Content-Type': 'application/activity+json',
|
|
|
|
'Digest': digestHeader,
|
|
|
|
}, args.additionalHeaders),
|
|
|
|
};
|
|
|
|
|
2023-02-24 07:10:48 +00:00
|
|
|
const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']);
|
2022-09-17 18:27:08 +00:00
|
|
|
|
|
|
|
return {
|
|
|
|
request,
|
|
|
|
signingString: result.signingString,
|
|
|
|
signature: result.signature,
|
|
|
|
signatureHeader: result.signatureHeader,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2024-01-06 00:07:48 +00:00
|
|
|
static createDigest(body: string) {
|
|
|
|
return `SHA-256=${crypto.createHash('sha256').update(body).digest('base64')}`;
|
|
|
|
}
|
|
|
|
|
2023-02-24 07:10:48 +00:00
|
|
|
static createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record<string, string> }): Signed {
|
2022-09-17 18:27:08 +00:00
|
|
|
const u = new URL(args.url);
|
|
|
|
|
|
|
|
const request: Request = {
|
|
|
|
url: u.href,
|
|
|
|
method: 'GET',
|
2023-02-24 07:10:48 +00:00
|
|
|
headers: this.#objectAssignWithLcKey({
|
2024-02-17 03:41:19 +00:00
|
|
|
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
2022-09-17 18:27:08 +00:00
|
|
|
'Date': new Date().toUTCString(),
|
2023-01-16 17:21:15 +00:00
|
|
|
'Host': new URL(args.url).host,
|
2022-09-17 18:27:08 +00:00
|
|
|
}, args.additionalHeaders),
|
|
|
|
};
|
|
|
|
|
2023-02-24 07:10:48 +00:00
|
|
|
const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']);
|
2022-09-17 18:27:08 +00:00
|
|
|
|
|
|
|
return {
|
|
|
|
request,
|
|
|
|
signingString: result.signingString,
|
|
|
|
signature: result.signature,
|
|
|
|
signatureHeader: result.signatureHeader,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2023-02-24 07:10:48 +00:00
|
|
|
static #signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed {
|
|
|
|
const signingString = this.#genSigningString(request, includeHeaders);
|
2022-09-17 18:27:08 +00:00
|
|
|
const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64');
|
|
|
|
const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`;
|
|
|
|
|
2023-02-24 07:10:48 +00:00
|
|
|
request.headers = this.#objectAssignWithLcKey(request.headers, {
|
2022-09-17 18:27:08 +00:00
|
|
|
Signature: signatureHeader,
|
|
|
|
});
|
2023-01-16 17:21:15 +00:00
|
|
|
// node-fetch will generate this for us. if we keep 'Host', it won't change with redirects!
|
|
|
|
delete request.headers['host'];
|
2022-09-17 18:27:08 +00:00
|
|
|
|
|
|
|
return {
|
|
|
|
request,
|
|
|
|
signingString,
|
|
|
|
signature,
|
|
|
|
signatureHeader,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2023-02-24 07:10:48 +00:00
|
|
|
static #genSigningString(request: Request, includeHeaders: string[]): string {
|
|
|
|
request.headers = this.#lcObjectKey(request.headers);
|
2022-09-17 18:27:08 +00:00
|
|
|
|
|
|
|
const results: string[] = [];
|
|
|
|
|
|
|
|
for (const key of includeHeaders.map(x => x.toLowerCase())) {
|
|
|
|
if (key === '(request-target)') {
|
|
|
|
results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`);
|
|
|
|
} else {
|
|
|
|
results.push(`${key}: ${request.headers[key]}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return results.join('\n');
|
|
|
|
}
|
|
|
|
|
2023-02-24 07:10:48 +00:00
|
|
|
static #lcObjectKey(src: Record<string, string>): Record<string, string> {
|
2022-09-17 18:27:08 +00:00
|
|
|
const dst: Record<string, string> = {};
|
|
|
|
for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key];
|
|
|
|
return dst;
|
|
|
|
}
|
|
|
|
|
2023-02-24 07:10:48 +00:00
|
|
|
static #objectAssignWithLcKey(a: Record<string, string>, b: Record<string, string>): Record<string, string> {
|
|
|
|
return Object.assign(this.#lcObjectKey(a), this.#lcObjectKey(b));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
export class ApRequestService {
|
|
|
|
private logger: Logger;
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
@Inject(DI.config)
|
|
|
|
private config: Config,
|
|
|
|
|
2023-04-05 03:10:40 +00:00
|
|
|
private userKeypairService: UserKeypairService,
|
2023-02-24 07:10:48 +00:00
|
|
|
private httpRequestService: HttpRequestService,
|
|
|
|
private loggerService: LoggerService,
|
|
|
|
) {
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
|
|
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
|
2022-09-17 18:27:08 +00:00
|
|
|
}
|
|
|
|
|
2022-12-04 06:03:09 +00:00
|
|
|
@bindThis
|
2024-01-06 00:07:48 +00:00
|
|
|
public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, digest?: string): Promise<void> {
|
|
|
|
const body = typeof object === 'string' ? object : JSON.stringify(object);
|
2022-09-17 18:27:08 +00:00
|
|
|
|
2023-04-05 03:10:40 +00:00
|
|
|
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
2022-09-17 18:27:08 +00:00
|
|
|
|
2023-02-24 07:10:48 +00:00
|
|
|
const req = ApRequestCreator.createSignedPost({
|
2022-09-17 18:27:08 +00:00
|
|
|
key: {
|
|
|
|
privateKeyPem: keypair.privateKey,
|
|
|
|
keyId: `${this.config.url}/users/${user.id}#main-key`,
|
|
|
|
},
|
|
|
|
url,
|
|
|
|
body,
|
2024-01-06 00:07:48 +00:00
|
|
|
digest,
|
2022-09-17 18:27:08 +00:00
|
|
|
additionalHeaders: {
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
2023-01-25 03:00:04 +00:00
|
|
|
await this.httpRequestService.send(url, {
|
|
|
|
method: req.request.method,
|
|
|
|
headers: req.request.headers,
|
|
|
|
body,
|
|
|
|
});
|
2022-09-17 18:27:08 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get AP object with http-signature
|
|
|
|
* @param user http-signature user
|
|
|
|
* @param url URL to fetch
|
|
|
|
*/
|
2022-12-04 06:03:09 +00:00
|
|
|
@bindThis
|
2023-08-16 08:51:28 +00:00
|
|
|
public async signedGet(url: string, user: { id: MiUser['id'] }): Promise<unknown> {
|
2023-04-05 03:10:40 +00:00
|
|
|
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
2022-09-17 18:27:08 +00:00
|
|
|
|
2023-02-24 07:10:48 +00:00
|
|
|
const req = ApRequestCreator.createSignedGet({
|
2022-09-17 18:27:08 +00:00
|
|
|
key: {
|
|
|
|
privateKeyPem: keypair.privateKey,
|
|
|
|
keyId: `${this.config.url}/users/${user.id}#main-key`,
|
|
|
|
},
|
|
|
|
url,
|
|
|
|
additionalHeaders: {
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
2023-01-25 03:00:04 +00:00
|
|
|
const res = await this.httpRequestService.send(url, {
|
|
|
|
method: req.request.method,
|
|
|
|
headers: req.request.headers,
|
2024-02-17 03:41:19 +00:00
|
|
|
}, {
|
|
|
|
throwErrorWhenResponseNotOk: true,
|
|
|
|
validators: [validateContentTypeSetAsActivityPub],
|
2023-01-25 03:00:04 +00:00
|
|
|
});
|
2022-09-17 18:27:08 +00:00
|
|
|
|
2024-03-30 11:05:58 +00:00
|
|
|
const finalUrl = res.url; // redirects may have been involved
|
|
|
|
const activity = await res.json() as IObject;
|
|
|
|
|
|
|
|
assertActivityMatchesUrls(activity, [url, finalUrl]);
|
|
|
|
|
|
|
|
return activity;
|
2022-09-17 18:27:08 +00:00
|
|
|
}
|
|
|
|
}
|