summaryrefslogtreecommitdiff
path: root/packages/backend/src/core
diff options
context:
space:
mode:
authortamaina <tamaina@hotmail.co.jp>2024-07-18 01:28:17 +0900
committerGitHub <noreply@github.com>2024-07-18 01:28:17 +0900
commit5f88d56d9699863da58deb243db114da53f12f6b (patch)
tree1793bb8effcecafa12fdcc6d1b481ef546c54fbc /packages/backend/src/core
parentfix(frontend): 「アニメーション画像を再生しない」がオン... (diff)
downloadsharkey-5f88d56d9699863da58deb243db114da53f12f6b.tar.gz
sharkey-5f88d56d9699863da58deb243db114da53f12f6b.tar.bz2
sharkey-5f88d56d9699863da58deb243db114da53f12f6b.zip
perf(federation): Ed25519署名に対応する (#13464)
* 1. ed25519キーペアを発行・Personとして公開鍵を送受信 * validate additionalPublicKeys * getAuthUserFromApIdはmainを選ぶ * :v: * fix * signatureAlgorithm * set publicKeyCache lifetime * refresh * httpMessageSignatureAcceptable * ED25519_SIGNED_ALGORITHM * ED25519_PUBLIC_KEY_SIGNATURE_ALGORITHM * remove sign additionalPublicKeys signature requirements * httpMessageSignaturesSupported * httpMessageSignaturesImplementationLevel * httpMessageSignaturesImplementationLevel: '01' * perf(federation): Use hint for getAuthUserFromApId (#13470) * Hint for getAuthUserFromApId * とどのつまりこれでいいのか? * use @misskey-dev/node-http-message-signatures * fix * signedPost, signedGet * ap-request.tsを復活させる * remove digest prerender * fix test? * fix test * add httpMessageSignaturesImplementationLevel to FederationInstance * ManyToOne * fetchPersonWithRenewal * exactKey * :v: * use const * use gen-key-pair fn. from '@misskey-dev/node-http-message-signatures' * update node-http-message-signatures * fix * @misskey-dev/node-http-message-signatures@0.0.0-alpha.11 * getAuthUserFromApIdでupdatePersonの頻度を増やす * cacheRaw.date * use requiredInputs https://github.com/misskey-dev/misskey/pull/13464#discussion_r1509964359 * update @misskey-dev/node-http-message-signatures * clean up * err msg * fix(backend): fetchInstanceMetadataのLockが永遠に解除されない問題を修正 Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com> * fix httpMessageSignaturesImplementationLevel validation * fix test * fix * comment * comment * improve test * fix * use Promise.all in genRSAAndEd25519KeyPair * refreshAndprepareEd25519KeyPair * refreshAndfindKey * commetn * refactor public keys add * digestプリレンダを復活させる RFC実装時にどうするか考える * fix, async * fix * !== true * use save * Deliver update person when new key generated (not tested) https://github.com/misskey-dev/misskey/pull/13464#issuecomment-1977049061 * 循環参照で落ちるのを解消? * fix? * Revert "fix?" This reverts commit 0082f6f8e8c5d5febd14933ba9a1ac643f70ca92. * a * logger * log * change logger * 秘密鍵の変更は、フラグではなく鍵を引き回すようにする * addAllKnowingSharedInboxRecipe * nanka meccha kaeta * delivre * キャッシュ有効チェックはロック取得前に行う * @misskey-dev/node-http-message-signatures@0.0.3 * PrivateKeyPem * getLocalUserPrivateKey * fix test * if * fix ap-request * update node-http-message-signatures * fix type error * update package * fix type * update package * retry no key * @misskey-dev/node-http-message-signatures@0.0.8 * fix type error * log keyid * logger * db-resolver * JSON.stringify * HTTP Signatureがなかったり使えなかったりしそうな場合にLD Signatureを活用するように * inbox-delayed use actor if no signature * ユーザーとキーの同一性チェックはhostの一致にする * log signature parse err * save array * とりあえずtryで囲っておく * fetchPersonWithRenewalでエラーが起きたら古いデータを返す * use transactionalEntityManager * fix spdx * @misskey-dev/node-http-message-signatures@0.0.10 * add comment * fix * publicKeyに配列が入ってもいいようにする https://github.com/misskey-dev/misskey/pull/13950 * define additionalPublicKeys * fix * merge fix * refreshAndprepareEd25519KeyPair → refreshAndPrepareEd25519KeyPair * remove gen-key-pair.ts * defaultMaxListeners = 512 * Revert "defaultMaxListeners = 512" This reverts commit f2c412c18057a9300540794ccbe4dfbf6d259ed6. * genRSAAndEd25519KeyPairではキーを直列に生成する? * maxConcurrency: 8 * maxConcurrency: 16 * maxConcurrency: 8 * Revert "genRSAAndEd25519KeyPairではキーを直列に生成する?" This reverts commit d0aada55c1ed5aa98f18731ec82f3ac5eb5a6c16. * maxWorkers: '90%' * Revert "maxWorkers: '90%'" This reverts commit 9e0a93f110456320d6485a871f014f7cdab29b33. * e2e/timelines.tsで個々のテストに対するtimeoutを削除, maxConcurrency: 32 * better error handling of this.userPublickeysRepository.delete * better comment * set result to keypairEntityCache * deliverJobConcurrency: 16, deliverJobPerSec: 1024, inboxJobConcurrency: 4 * inboxJobPerSec: 64 * delete request.headers['host']; * fix * // node-fetch will generate this for us. if we keep 'Host', it won't change with redirects! * move delete host * modify comment * modify comment * fix correct → collect * refreshAndfindKey → refreshAndFindKey * modify comment * modify attachLdSignature * getApId, InboxProcessorService * TODO * [skip ci] add CHANGELOG --------- Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com> Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com>
Diffstat (limited to 'packages/backend/src/core')
-rw-r--r--packages/backend/src/core/AccountUpdateService.ts27
-rw-r--r--packages/backend/src/core/CreateSystemUserService.ts7
-rw-r--r--packages/backend/src/core/FetchInstanceMetadataService.ts61
-rw-r--r--packages/backend/src/core/GlobalEventService.ts1
-rw-r--r--packages/backend/src/core/HttpRequestService.ts2
-rw-r--r--packages/backend/src/core/QueueService.ts17
-rw-r--r--packages/backend/src/core/RelayService.ts13
-rw-r--r--packages/backend/src/core/SignupService.ts22
-rw-r--r--packages/backend/src/core/UserKeypairService.ts155
-rw-r--r--packages/backend/src/core/UserSuspendService.ts66
-rw-r--r--packages/backend/src/core/WebfingerService.ts2
-rw-r--r--packages/backend/src/core/activitypub/ApDbResolverService.ts172
-rw-r--r--packages/backend/src/core/activitypub/ApDeliverManagerService.ts95
-rw-r--r--packages/backend/src/core/activitypub/ApInboxService.ts11
-rw-r--r--packages/backend/src/core/activitypub/ApRendererService.ts21
-rw-r--r--packages/backend/src/core/activitypub/ApRequestService.ts210
-rw-r--r--packages/backend/src/core/activitypub/ApResolverService.ts8
-rw-r--r--packages/backend/src/core/activitypub/misc/contexts.ts1
-rw-r--r--packages/backend/src/core/activitypub/models/ApPersonService.ts114
-rw-r--r--packages/backend/src/core/activitypub/type.ts11
-rw-r--r--packages/backend/src/core/entities/InstanceEntityService.ts1
21 files changed, 667 insertions, 350 deletions
diff --git a/packages/backend/src/core/AccountUpdateService.ts b/packages/backend/src/core/AccountUpdateService.ts
index 69a57b4854..ca0864f679 100644
--- a/packages/backend/src/core/AccountUpdateService.ts
+++ b/packages/backend/src/core/AccountUpdateService.ts
@@ -3,7 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { Inject, Injectable } from '@nestjs/common';
+import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
+import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js';
import type { UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
@@ -12,30 +13,44 @@ import { RelayService } from '@/core/RelayService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
+import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures';
@Injectable()
-export class AccountUpdateService {
+export class AccountUpdateService implements OnModuleInit {
+ private apDeliverManagerService: ApDeliverManagerService;
constructor(
+ private moduleRef: ModuleRef,
+
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private userEntityService: UserEntityService,
private apRendererService: ApRendererService,
- private apDeliverManagerService: ApDeliverManagerService,
private relayService: RelayService,
) {
}
+ async onModuleInit() {
+ this.apDeliverManagerService = this.moduleRef.get(ApDeliverManagerService.name);
+ }
+
@bindThis
- public async publishToFollowers(userId: MiUser['id']) {
+ /**
+ * Deliver account update to followers
+ * @param userId user id
+ * @param deliverKey optional. Private key to sign the deliver.
+ */
+ public async publishToFollowers(userId: MiUser['id'], deliverKey?: PrivateKeyWithPem) {
const user = await this.usersRepository.findOneBy({ id: userId });
if (user == null) throw new Error('user not found');
// フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信
if (this.userEntityService.isLocalUser(user)) {
const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user));
- this.apDeliverManagerService.deliverToFollowers(user, content);
- this.relayService.deliverToRelays(user, content);
+ await Promise.allSettled([
+ this.apDeliverManagerService.deliverToFollowers(user, content, deliverKey),
+ this.relayService.deliverToRelays(user, content, deliverKey),
+ ]);
}
}
}
diff --git a/packages/backend/src/core/CreateSystemUserService.ts b/packages/backend/src/core/CreateSystemUserService.ts
index 6c5b0f6a36..60ddc9cde2 100644
--- a/packages/backend/src/core/CreateSystemUserService.ts
+++ b/packages/backend/src/core/CreateSystemUserService.ts
@@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import { IsNull, DataSource } from 'typeorm';
-import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
+import { genRSAAndEd25519KeyPair } from '@/misc/gen-key-pair.js';
import { MiUser } from '@/models/User.js';
import { MiUserProfile } from '@/models/UserProfile.js';
import { IdService } from '@/core/IdService.js';
@@ -38,7 +38,7 @@ export class CreateSystemUserService {
// Generate secret
const secret = generateNativeUserToken();
- const keyPair = await genRsaKeyPair();
+ const keyPair = await genRSAAndEd25519KeyPair();
let account!: MiUser;
@@ -64,9 +64,8 @@ export class CreateSystemUserService {
}).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0]));
await transactionalEntityManager.insert(MiUserKeypair, {
- publicKey: keyPair.publicKey,
- privateKey: keyPair.privateKey,
userId: account.id,
+ ...keyPair,
});
await transactionalEntityManager.insert(MiUserProfile, {
diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts
index aa16468ecb..dc53c8711d 100644
--- a/packages/backend/src/core/FetchInstanceMetadataService.ts
+++ b/packages/backend/src/core/FetchInstanceMetadataService.ts
@@ -15,6 +15,7 @@ import { LoggerService } from '@/core/LoggerService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
+import { REMOTE_SERVER_CACHE_TTL } from '@/const.js';
import type { DOMWindow } from 'jsdom';
type NodeInfo = {
@@ -24,6 +25,7 @@ type NodeInfo = {
version?: unknown;
};
metadata?: {
+ httpMessageSignaturesImplementationLevel?: unknown,
name?: unknown;
nodeName?: unknown;
nodeDescription?: unknown;
@@ -39,6 +41,7 @@ type NodeInfo = {
@Injectable()
export class FetchInstanceMetadataService {
private logger: Logger;
+ private httpColon = 'https://';
constructor(
private httpRequestService: HttpRequestService,
@@ -48,6 +51,7 @@ export class FetchInstanceMetadataService {
private redisClient: Redis.Redis,
) {
this.logger = this.loggerService.getLogger('metadata', 'cyan');
+ this.httpColon = process.env.MISSKEY_USE_HTTP?.toLowerCase() === 'true' ? 'http://' : 'https://';
}
@bindThis
@@ -59,7 +63,7 @@ export class FetchInstanceMetadataService {
return await this.redisClient.set(
`fetchInstanceMetadata:mutex:v2:${host}`, '1',
'EX', 30, // 30秒したら自動でロック解除 https://github.com/misskey-dev/misskey/issues/13506#issuecomment-1975375395
- 'GET' // 古い値を返す(なかったらnull)
+ 'GET', // 古い値を返す(なかったらnull)
);
}
@@ -73,23 +77,24 @@ export class FetchInstanceMetadataService {
public async fetchInstanceMetadata(instance: MiInstance, force = false): Promise<void> {
const host = instance.host;
- // finallyでunlockされてしまうのでtry内でロックチェックをしない
- // (returnであってもfinallyは実行される)
- if (!force && await this.tryLock(host) === '1') {
- // 1が返ってきていたらロックされているという意味なので、何もしない
- return;
- }
+ if (!force) {
+ // キャッシュ有効チェックはロック取得前に行う
+ const _instance = await this.federatedInstanceService.fetch(host);
+ const now = Date.now();
+ if (_instance && _instance.infoUpdatedAt != null && (now - _instance.infoUpdatedAt.getTime() < REMOTE_SERVER_CACHE_TTL)) {
+ this.logger.debug(`Skip because updated recently ${_instance.infoUpdatedAt.toJSON()}`);
+ return;
+ }
- try {
- if (!force) {
- const _instance = await this.federatedInstanceService.fetch(host);
- const now = Date.now();
- if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
- // unlock at the finally caluse
- return;
- }
+ // finallyでunlockされてしまうのでtry内でロックチェックをしない
+ // (returnであってもfinallyは実行される)
+ if (await this.tryLock(host) === '1') {
+ // 1が返ってきていたら他にロックされているという意味なので、何もしない
+ return;
}
+ }
+ try {
this.logger.info(`Fetching metadata of ${instance.host} ...`);
const [info, dom, manifest] = await Promise.all([
@@ -118,6 +123,14 @@ export class FetchInstanceMetadataService {
updates.openRegistrations = info.openRegistrations;
updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null;
updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null;
+ if (info.metadata && info.metadata.httpMessageSignaturesImplementationLevel && (
+ info.metadata.httpMessageSignaturesImplementationLevel === '01' ||
+ info.metadata.httpMessageSignaturesImplementationLevel === '11'
+ )) {
+ updates.httpMessageSignaturesImplementationLevel = info.metadata.httpMessageSignaturesImplementationLevel;
+ } else {
+ updates.httpMessageSignaturesImplementationLevel = '00';
+ }
}
if (name) updates.name = name;
@@ -129,6 +142,12 @@ export class FetchInstanceMetadataService {
await this.federatedInstanceService.update(instance.id, updates);
this.logger.succ(`Successfuly updated metadata of ${instance.host}`);
+ this.logger.debug('Updated metadata:', {
+ info: !!info,
+ dom: !!dom,
+ manifest: !!manifest,
+ updates,
+ });
} catch (e) {
this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`);
} finally {
@@ -141,7 +160,7 @@ export class FetchInstanceMetadataService {
this.logger.info(`Fetching nodeinfo of ${instance.host} ...`);
try {
- const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo')
+ const wellknown = await this.httpRequestService.getJson(this.httpColon + instance.host + '/.well-known/nodeinfo')
.catch(err => {
if (err.statusCode === 404) {
throw new Error('No nodeinfo provided');
@@ -184,7 +203,7 @@ export class FetchInstanceMetadataService {
private async fetchDom(instance: MiInstance): Promise<DOMWindow['document']> {
this.logger.info(`Fetching HTML of ${instance.host} ...`);
- const url = 'https://' + instance.host;
+ const url = this.httpColon + instance.host;
const html = await this.httpRequestService.getHtml(url);
@@ -196,7 +215,7 @@ export class FetchInstanceMetadataService {
@bindThis
private async fetchManifest(instance: MiInstance): Promise<Record<string, unknown> | null> {
- const url = 'https://' + instance.host;
+ const url = this.httpColon + instance.host;
const manifestUrl = url + '/manifest.json';
@@ -207,7 +226,7 @@ export class FetchInstanceMetadataService {
@bindThis
private async fetchFaviconUrl(instance: MiInstance, doc: DOMWindow['document'] | null): Promise<string | null> {
- const url = 'https://' + instance.host;
+ const url = this.httpColon + instance.host;
if (doc) {
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
@@ -234,12 +253,12 @@ export class FetchInstanceMetadataService {
@bindThis
private async fetchIconUrl(instance: MiInstance, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) {
- const url = 'https://' + instance.host;
+ const url = this.httpColon + instance.host;
return (new URL(manifest.icons[0].src, url)).href;
}
if (doc) {
- const url = 'https://' + instance.host;
+ const url = this.httpColon + instance.host;
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
const links = Array.from(doc.getElementsByTagName('link')).reverse();
diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index a70743bed2..2a7d8d4bbe 100644
--- a/packages/backend/src/core/GlobalEventService.ts
+++ b/packages/backend/src/core/GlobalEventService.ts
@@ -245,6 +245,7 @@ export interface InternalEventTypes {
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; };
userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; };
+ userKeypairUpdated: { userId: MiUser['id']; };
}
// name/messages(spec) pairs dictionary
diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts
index 7f3cac7c58..4249c158d7 100644
--- a/packages/backend/src/core/HttpRequestService.ts
+++ b/packages/backend/src/core/HttpRequestService.ts
@@ -70,7 +70,7 @@ export class HttpRequestService {
localAddress: config.outgoingAddress,
});
- const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
+ const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 16);
this.httpAgent = config.proxy
? new HttpProxyAgent({
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index 80827a500b..dd3f2182b4 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -13,7 +13,6 @@ import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
-import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
import type {
DbJobData,
DeliverJobData,
@@ -33,7 +32,7 @@ import type {
UserWebhookDeliverQueue,
SystemWebhookDeliverQueue,
} from './QueueModule.js';
-import type httpSignature from '@peertube/http-signature';
+import { genRFC3230DigestHeader, type PrivateKeyWithPem, type ParsedSignature } from '@misskey-dev/node-http-message-signatures';
import type * as Bull from 'bullmq';
@Injectable()
@@ -90,21 +89,21 @@ export class QueueService {
}
@bindThis
- public deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean) {
+ public async deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean, privateKey?: PrivateKeyWithPem) {
if (content == null) return null;
if (to == null) return null;
const contentBody = JSON.stringify(content);
- const digest = ApRequestCreator.createDigest(contentBody);
const data: DeliverJobData = {
user: {
id: user.id,
},
content: contentBody,
- digest,
+ digest: await genRFC3230DigestHeader(contentBody, 'SHA-256'),
to,
isSharedInbox,
+ privateKey: privateKey && { keyId: privateKey.keyId, privateKeyPem: privateKey.privateKeyPem },
};
return this.deliverQueue.add(to, data, {
@@ -122,13 +121,13 @@ export class QueueService {
* @param user `{ id: string; }` この関数ではThinUserに変換しないので前もって変換してください
* @param content IActivity | null
* @param inboxes `Map<string, boolean>` / key: to (inbox url), value: isSharedInbox (whether it is sharedInbox)
+ * @param forceMainKey boolean | undefined, force to use main (rsa) key
* @returns void
*/
@bindThis
- public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map<string, boolean>) {
+ public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map<string, boolean>, privateKey?: PrivateKeyWithPem) {
if (content == null) return null;
const contentBody = JSON.stringify(content);
- const digest = ApRequestCreator.createDigest(contentBody);
const opts = {
attempts: this.config.deliverJobMaxAttempts ?? 12,
@@ -144,9 +143,9 @@ export class QueueService {
data: {
user,
content: contentBody,
- digest,
to: d[0],
isSharedInbox: d[1],
+ privateKey: privateKey && { keyId: privateKey.keyId, privateKeyPem: privateKey.privateKeyPem },
} as DeliverJobData,
opts,
})));
@@ -155,7 +154,7 @@ export class QueueService {
}
@bindThis
- public inbox(activity: IActivity, signature: httpSignature.IParsedSignature) {
+ public inbox(activity: IActivity, signature: ParsedSignature | null) {
const data = {
activity: activity,
signature,
diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts
index 8dd3d64f5b..ad01f98902 100644
--- a/packages/backend/src/core/RelayService.ts
+++ b/packages/backend/src/core/RelayService.ts
@@ -16,6 +16,8 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { DI } from '@/di-symbols.js';
import { deepClone } from '@/misc/clone.js';
import { bindThis } from '@/decorators.js';
+import { UserKeypairService } from './UserKeypairService.js';
+import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures';
const ACTOR_USERNAME = 'relay.actor' as const;
@@ -34,6 +36,7 @@ export class RelayService {
private queueService: QueueService,
private createSystemUserService: CreateSystemUserService,
private apRendererService: ApRendererService,
+ private userKeypairService: UserKeypairService,
) {
this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10);
}
@@ -111,7 +114,7 @@ export class RelayService {
}
@bindThis
- public async deliverToRelays(user: { id: MiUser['id']; host: null; }, activity: any): Promise<void> {
+ public async deliverToRelays(user: { id: MiUser['id']; host: null; }, activity: any, privateKey?: PrivateKeyWithPem): Promise<void> {
if (activity == null) return;
const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({
@@ -121,11 +124,9 @@ export class RelayService {
const copy = deepClone(activity);
if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public'];
+ privateKey = privateKey ?? await this.userKeypairService.getLocalUserPrivateKeyPem(user.id);
+ const signed = await this.apRendererService.attachLdSignature(copy, privateKey);
- const signed = await this.apRendererService.attachLdSignature(copy, user);
-
- for (const relay of relays) {
- this.queueService.deliver(user, signed, relay.inbox, false);
- }
+ this.queueService.deliverMany(user, signed, new Map(relays.map(({ inbox }) => [inbox, false])), privateKey);
}
}
diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts
index 5522ecd6cc..54c6170062 100644
--- a/packages/backend/src/core/SignupService.ts
+++ b/packages/backend/src/core/SignupService.ts
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { generateKeyPair } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import { DataSource, IsNull } from 'typeorm';
@@ -21,6 +20,7 @@ import { bindThis } from '@/decorators.js';
import UsersChart from '@/core/chart/charts/users.js';
import { UtilityService } from '@/core/UtilityService.js';
import { MetaService } from '@/core/MetaService.js';
+import { genRSAAndEd25519KeyPair } from '@/misc/gen-key-pair.js';
@Injectable()
export class SignupService {
@@ -93,22 +93,7 @@ export class SignupService {
}
}
- const keyPair = await new Promise<string[]>((res, rej) =>
- generateKeyPair('rsa', {
- modulusLength: 2048,
- publicKeyEncoding: {
- type: 'spki',
- format: 'pem',
- },
- privateKeyEncoding: {
- type: 'pkcs8',
- format: 'pem',
- cipher: undefined,
- passphrase: undefined,
- },
- }, (err, publicKey, privateKey) =>
- err ? rej(err) : res([publicKey, privateKey]),
- ));
+ const keyPair = await genRSAAndEd25519KeyPair();
let account!: MiUser;
@@ -131,9 +116,8 @@ export class SignupService {
}));
await transactionalEntityManager.save(new MiUserKeypair({
- publicKey: keyPair[0],
- privateKey: keyPair[1],
userId: account.id,
+ ...keyPair,
}));
await transactionalEntityManager.save(new MiUserProfile({
diff --git a/packages/backend/src/core/UserKeypairService.ts b/packages/backend/src/core/UserKeypairService.ts
index 51ac99179a..aa90f1e209 100644
--- a/packages/backend/src/core/UserKeypairService.ts
+++ b/packages/backend/src/core/UserKeypairService.ts
@@ -5,41 +5,184 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Redis from 'ioredis';
+import { genEd25519KeyPair, importPrivateKey, PrivateKey, PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures';
import type { MiUser } from '@/models/User.js';
import type { UserKeypairsRepository } from '@/models/_.js';
-import { RedisKVCache } from '@/misc/cache.js';
+import { RedisKVCache, MemoryKVCache } from '@/misc/cache.js';
import type { MiUserKeypair } from '@/models/UserKeypair.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
+import { GlobalEventService, GlobalEvents } from '@/core/GlobalEventService.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import type { webcrypto } from 'node:crypto';
@Injectable()
export class UserKeypairService implements OnApplicationShutdown {
- private cache: RedisKVCache<MiUserKeypair>;
+ private keypairEntityCache: RedisKVCache<MiUserKeypair>;
+ private privateKeyObjectCache: MemoryKVCache<webcrypto.CryptoKey>;
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
-
+ @Inject(DI.redisForSub)
+ private redisForSub: Redis.Redis,
@Inject(DI.userKeypairsRepository)
private userKeypairsRepository: UserKeypairsRepository,
+
+ private globalEventService: GlobalEventService,
+ private userEntityService: UserEntityService,
) {
- this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', {
+ this.keypairEntityCache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', {
lifetime: 1000 * 60 * 60 * 24, // 24h
memoryCacheLifetime: Infinity,
fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }),
toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => JSON.parse(value),
});
+ this.privateKeyObjectCache = new MemoryKVCache<webcrypto.CryptoKey>(1000 * 60 * 60 * 1);
+
+ this.redisForSub.on('message', this.onMessage);
}
@bindThis
public async getUserKeypair(userId: MiUser['id']): Promise<MiUserKeypair> {
- return await this.cache.fetch(userId);
+ return await this.keypairEntityCache.fetch(userId);
+ }
+
+ /**
+ * Get private key [Only PrivateKeyWithPem for queue data etc.]
+ * @param userIdOrHint user id or MiUserKeypair
+ * @param preferType
+ * If ed25519-like(`ed25519`, `01`, `11`) is specified, ed25519 keypair will be returned if exists.
+ * Otherwise, main keypair will be returned.
+ * @returns
+ */
+ @bindThis
+ public async getLocalUserPrivateKeyPem(
+ userIdOrHint: MiUser['id'] | MiUserKeypair,
+ preferType?: string,
+ ): Promise<PrivateKeyWithPem> {
+ const keypair = typeof userIdOrHint === 'string' ? await this.getUserKeypair(userIdOrHint) : userIdOrHint;
+ if (
+ preferType && ['01', '11', 'ed25519'].includes(preferType.toLowerCase()) &&
+ keypair.ed25519PublicKey != null && keypair.ed25519PrivateKey != null
+ ) {
+ return {
+ keyId: `${this.userEntityService.genLocalUserUri(keypair.userId)}#ed25519-key`,
+ privateKeyPem: keypair.ed25519PrivateKey,
+ };
+ }
+ return {
+ keyId: `${this.userEntityService.genLocalUserUri(keypair.userId)}#main-key`,
+ privateKeyPem: keypair.privateKey,
+ };
+ }
+
+ /**
+ * Get private key [Only PrivateKey for ap request]
+ * Using cache due to performance reasons of `crypto.subtle.importKey`
+ * @param userIdOrHint user id, MiUserKeypair, or PrivateKeyWithPem
+ * @param preferType
+ * If ed25519-like(`ed25519`, `01`, `11`) is specified, ed25519 keypair will be returned if exists.
+ * Otherwise, main keypair will be returned. (ignored if userIdOrHint is PrivateKeyWithPem)
+ * @returns
+ */
+ @bindThis
+ public async getLocalUserPrivateKey(
+ userIdOrHint: MiUser['id'] | MiUserKeypair | PrivateKeyWithPem,
+ preferType?: string,
+ ): Promise<PrivateKey> {
+ if (typeof userIdOrHint === 'object' && 'privateKeyPem' in userIdOrHint) {
+ // userIdOrHint is PrivateKeyWithPem
+ return {
+ keyId: userIdOrHint.keyId,
+ privateKey: await this.privateKeyObjectCache.fetch(userIdOrHint.keyId, async () => {
+ return await importPrivateKey(userIdOrHint.privateKeyPem);
+ }),
+ };
+ }
+
+ const userId = typeof userIdOrHint === 'string' ? userIdOrHint : userIdOrHint.userId;
+ const getKeypair = () => typeof userIdOrHint === 'string' ? this.getUserKeypair(userId) : userIdOrHint;
+
+ if (preferType && ['01', '11', 'ed25519'].includes(preferType.toLowerCase())) {
+ const keyId = `${this.userEntityService.genLocalUserUri(userId)}#ed25519-key`;
+ const fetched = await this.privateKeyObjectCache.fetchMaybe(keyId, async () => {
+ const keypair = await getKeypair();
+ if (keypair.ed25519PublicKey != null && keypair.ed25519PrivateKey != null) {
+ return await importPrivateKey(keypair.ed25519PrivateKey);
+ }
+ return;
+ });
+ if (fetched) {
+ return {
+ keyId,
+ privateKey: fetched,
+ };
+ }
+ }
+
+ const keyId = `${this.userEntityService.genLocalUserUri(userId)}#main-key`;
+ return {
+ keyId,
+ privateKey: await this.privateKeyObjectCache.fetch(keyId, async () => {
+ const keypair = await getKeypair();
+ return await importPrivateKey(keypair.privateKey);
+ }),
+ };
}
@bindThis
+ public async refresh(userId: MiUser['id']): Promise<void> {
+ return await this.keypairEntityCache.refresh(userId);
+ }
+
+ /**
+ * If DB has ed25519 keypair, refresh cache and return it.
+ * If not, create, save and return ed25519 keypair.
+ * @param userId user id
+ * @returns MiUserKeypair if keypair is created, void if keypair is already exists
+ */
+ @bindThis
+ public async refreshAndPrepareEd25519KeyPair(userId: MiUser['id']): Promise<MiUserKeypair | void> {
+ await this.refresh(userId);
+ const keypair = await this.keypairEntityCache.fetch(userId);
+ if (keypair.ed25519PublicKey != null) {
+ return;
+ }
+
+ const ed25519 = await genEd25519KeyPair();
+ await this.userKeypairsRepository.update({ userId }, {
+ ed25519PublicKey: ed25519.publicKey,
+ ed25519PrivateKey: ed25519.privateKey,
+ });
+ this.globalEventService.publishInternalEvent('userKeypairUpdated', { userId });
+ const result = {
+ ...keypair,
+ ed25519PublicKey: ed25519.publicKey,
+ ed25519PrivateKey: ed25519.privateKey,
+ };
+ this.keypairEntityCache.set(userId, result);
+ return result;
+ }
+
+ @bindThis
+ private async onMessage(_: string, data: string): Promise<void> {
+ const obj = JSON.parse(data);
+
+ if (obj.channel === 'internal') {
+ const { type, body } = obj.message as GlobalEvents['internal']['payload'];
+ switch (type) {
+ case 'userKeypairUpdated': {
+ this.refresh(body.userId);
+ break;
+ }
+ }
+ }
+ }
+ @bindThis
public dispose(): void {
- this.cache.dispose();
+ this.keypairEntityCache.dispose();
}
@bindThis
diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts
index d594a223f4..fc5a68c72e 100644
--- a/packages/backend/src/core/UserSuspendService.ts
+++ b/packages/backend/src/core/UserSuspendService.ts
@@ -3,27 +3,23 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { Inject, Injectable } from '@nestjs/common';
-import { Not, IsNull } from 'typeorm';
-import type { FollowingsRepository } from '@/models/_.js';
+import { Injectable } from '@nestjs/common';
import type { MiUser } from '@/models/User.js';
-import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
-import { DI } from '@/di-symbols.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
+import { UserKeypairService } from './UserKeypairService.js';
+import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js';
@Injectable()
export class UserSuspendService {
constructor(
- @Inject(DI.followingsRepository)
- private followingsRepository: FollowingsRepository,
-
private userEntityService: UserEntityService,
- private queueService: QueueService,
private globalEventService: GlobalEventService,
private apRendererService: ApRendererService,
+ private userKeypairService: UserKeypairService,
+ private apDeliverManagerService: ApDeliverManagerService,
) {
}
@@ -32,28 +28,12 @@ export class UserSuspendService {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
if (this.userEntityService.isLocalUser(user)) {
- // 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
-
- const queue: string[] = [];
-
- const followings = await this.followingsRepository.find({
- where: [
- { followerSharedInbox: Not(IsNull()) },
- { followeeSharedInbox: Not(IsNull()) },
- ],
- select: ['followerSharedInbox', 'followeeSharedInbox'],
- });
-
- const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
-
- for (const inbox of inboxes) {
- if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
- }
-
- for (const inbox of queue) {
- this.queueService.deliver(user, content, inbox, true);
- }
+ const manager = this.apDeliverManagerService.createDeliverManager(user, content);
+ manager.addAllKnowingSharedInboxRecipe();
+ // process deliver時にはキーペアが消去されているはずなので、ここで挿入する
+ const privateKey = await this.userKeypairService.getLocalUserPrivateKeyPem(user.id, 'main');
+ manager.execute({ privateKey });
}
}
@@ -62,28 +42,12 @@ export class UserSuspendService {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
if (this.userEntityService.isLocalUser(user)) {
- // 知り得る全SharedInboxにUndo Delete配信
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
-
- const queue: string[] = [];
-
- const followings = await this.followingsRepository.find({
- where: [
- { followerSharedInbox: Not(IsNull()) },
- { followeeSharedInbox: Not(IsNull()) },
- ],
- select: ['followerSharedInbox', 'followeeSharedInbox'],
- });
-
- const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
-
- for (const inbox of inboxes) {
- if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
- }
-
- for (const inbox of queue) {
- this.queueService.deliver(user as any, content, inbox, true);
- }
+ const manager = this.apDeliverManagerService.createDeliverManager(user, content);
+ manager.addAllKnowingSharedInboxRecipe();
+ // process deliver時にはキーペアが消去されているはずなので、ここで挿入する
+ const privateKey = await this.userKeypairService.getLocalUserPrivateKeyPem(user.id, 'main');
+ manager.execute({ privateKey });
}
}
}
diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts
index 374536a741..aa1144778c 100644
--- a/packages/backend/src/core/WebfingerService.ts
+++ b/packages/backend/src/core/WebfingerService.ts
@@ -46,7 +46,7 @@ export class WebfingerService {
const m = query.match(mRegex);
if (m) {
const hostname = m[2];
- const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true';
+ const useHttp = process.env.MISSKEY_USE_HTTP && process.env.MISSKEY_USE_HTTP.toLowerCase() === 'true';
return `http${useHttp ? '' : 's'}://${hostname}/.well-known/webfinger?${urlQuery({ resource: `acct:${query}` })}`;
}
diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts
index f6b70ead44..973394683f 100644
--- a/packages/backend/src/core/activitypub/ApDbResolverService.ts
+++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts
@@ -5,7 +5,7 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
-import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js';
+import type { MiUser, NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js';
import type { Config } from '@/config.js';
import { MemoryKVCache } from '@/misc/cache.js';
import type { MiUserPublickey } from '@/models/UserPublickey.js';
@@ -13,9 +13,12 @@ import { CacheService } from '@/core/CacheService.js';
import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
import { MiLocalUser, MiRemoteUser } from '@/models/User.js';
+import Logger from '@/logger.js';
import { getApId } from './type.js';
import { ApPersonService } from './models/ApPersonService.js';
+import { ApLoggerService } from './ApLoggerService.js';
import type { IObject } from './type.js';
+import { UtilityService } from '../UtilityService.js';
export type UriParseResult = {
/** wether the URI was generated by us */
@@ -35,8 +38,8 @@ export type UriParseResult = {
@Injectable()
export class ApDbResolverService implements OnApplicationShutdown {
- private publicKeyCache: MemoryKVCache<MiUserPublickey | null>;
- private publicKeyByUserIdCache: MemoryKVCache<MiUserPublickey | null>;
+ private publicKeyByUserIdCache: MemoryKVCache<MiUserPublickey[] | null>;
+ private logger: Logger;
constructor(
@Inject(DI.config)
@@ -53,9 +56,17 @@ export class ApDbResolverService implements OnApplicationShutdown {
private cacheService: CacheService,
private apPersonService: ApPersonService,
+ private apLoggerService: ApLoggerService,
+ private utilityService: UtilityService,
) {
- this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(Infinity);
- this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(Infinity);
+ this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey[] | null>(Infinity);
+ this.logger = this.apLoggerService.logger.createSubLogger('db-resolver');
+ }
+
+ private punyHost(url: string): string {
+ const urlObj = new URL(url);
+ const host = `${this.utilityService.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
+ return host;
}
@bindThis
@@ -116,62 +127,141 @@ export class ApDbResolverService implements OnApplicationShutdown {
}
}
+ @bindThis
+ private async refreshAndFindKey(userId: MiUser['id'], keyId: string): Promise<MiUserPublickey | null> {
+ this.refreshCacheByUserId(userId);
+ const keys = await this.getPublicKeyByUserId(userId);
+ if (keys == null || !Array.isArray(keys) || keys.length === 0) {
+ this.logger.warn(`No key found (refreshAndFindKey) userId=${userId} keyId=${keyId} keys=${JSON.stringify(keys)}`);
+ return null;
+ }
+ const exactKey = keys.find(x => x.keyId === keyId);
+ if (exactKey) return exactKey;
+ this.logger.warn(`No exact key found (refreshAndFindKey) userId=${userId} keyId=${keyId} keys=${JSON.stringify(keys)}`);
+ return null;
+ }
+
/**
- * AP KeyId => Misskey User and Key
+ * AP Actor id => Misskey User and Key
+ * @param uri AP Actor id
+ * @param keyId Key id to find. If not specified, main key will be selected.
+ * @returns
+ * 1. `null` if the user and key host do not match
+ * 2. `{ user: null, key: null }` if the user is not found
+ * 3. `{ user: MiRemoteUser, key: null }` if key is not found
+ * 4. `{ user: MiRemoteUser, key: MiUserPublickey }` if both are found
*/
@bindThis
- public async getAuthUserFromKeyId(keyId: string): Promise<{
+ public async getAuthUserFromApId(uri: string, keyId?: string): Promise<{
user: MiRemoteUser;
- key: MiUserPublickey;
- } | null> {
- const key = await this.publicKeyCache.fetch(keyId, async () => {
- const key = await this.userPublickeysRepository.findOneBy({
- keyId,
+ key: MiUserPublickey | null;
+ } | {
+ user: null;
+ key: null;
+ } |
+ null> {
+ if (keyId) {
+ if (this.punyHost(uri) !== this.punyHost(keyId)) {
+ /**
+ * keyIdはURL形式かつkeyIdのホストはuriのホストと一致するはず
+ * (ApPersonService.validateActorに由来)
+ *
+ * ただ、Mastodonはリプライ関連で他人のトゥートをHTTP Signature署名して送ってくることがある
+ * そのような署名は有効性に疑問があるので無視することにする
+ * ここではuriとkeyIdのホストが一致しない場合は無視する
+ * ハッシュをなくしたkeyIdとuriの同一性を比べてみてもいいが、`uri#*-key`というkeyIdを設定するのが
+ * 決まりごとというわけでもないため幅を持たせることにする
+ *
+ *
+ * The keyId should be in URL format and its host should match the host of the uri
+ * (derived from ApPersonService.validateActor)
+ *
+ * However, Mastodon sometimes sends toots from other users with HTTP Signature signing for reply-related purposes
+ * Such signatures are of questionable validity, so we choose to ignore them
+ * Here, we ignore cases where the hosts of uri and keyId do not match
+ * We could also compare the equality of keyId without the hash and uri, but since setting a keyId like `uri#*-key`
+ * is not a strict rule, we decide to allow for some flexibility
+ */
+ this.logger.warn(`actor uri and keyId are not matched uri=${uri} keyId=${keyId}`);
+ return null;
+ }
+ }
+
+ const user = await this.apPersonService.resolvePerson(uri, undefined, true) as MiRemoteUser;
+ if (user.isDeleted) return { user: null, key: null };
+
+ const keys = await this.getPublicKeyByUserId(user.id);
+
+ if (keys == null || !Array.isArray(keys) || keys.length === 0) {
+ this.logger.warn(`No key found uri=${uri} userId=${user.id} keys=${JSON.stringify(keys)}`);
+ return { user, key: null };
+ }
+
+ if (!keyId) {
+ // Choose the main-like
+ const mainKey = keys.find(x => {
+ try {
+ const url = new URL(x.keyId);
+ const path = url.pathname.split('/').pop()?.toLowerCase();
+ if (url.hash) {
+ if (url.hash.toLowerCase().includes('main')) {
+ return true;
+ }
+ } else if (path?.includes('main') || path === 'publickey') {
+ return true;
+ }
+ } catch { /* noop */ }
+
+ return false;
});
+ return { user, key: mainKey ?? keys[0] };
+ }
- if (key == null) return null;
+ const exactKey = keys.find(x => x.keyId === keyId);
+ if (exactKey) return { user, key: exactKey };
- return key;
- }, key => key != null);
+ /**
+ * keyIdで見つからない場合、まずはキャッシュを更新して再取得
+ * If not found with keyId, update cache and reacquire
+ */
+ const cacheRaw = this.publicKeyByUserIdCache.cache.get(user.id);
+ if (cacheRaw && cacheRaw.date > Date.now() - 1000 * 60 * 12) {
+ const exactKey = await this.refreshAndFindKey(user.id, keyId);
+ if (exactKey) return { user, key: exactKey };
+ }
- if (key == null) return null;
+ /**
+ * lastFetchedAtでの更新制限を弱めて再取得
+ * Reacquisition with weakened update limit at lastFetchedAt
+ */
+ if (user.lastFetchedAt == null || user.lastFetchedAt < new Date(Date.now() - 1000 * 60 * 12)) {
+ this.logger.info(`Fetching user to find public key uri=${uri} userId=${user.id} keyId=${keyId}`);
+ const renewed = await this.apPersonService.fetchPersonWithRenewal(uri, 0);
+ if (renewed == null || renewed.isDeleted) return null;
- const user = await this.cacheService.findUserById(key.userId).catch(() => null) as MiRemoteUser | null;
- if (user == null) return null;
- if (user.isDeleted) return null;
+ return { user, key: await this.refreshAndFindKey(user.id, keyId) };
+ }
- return {
- user,
- key,
- };
+ this.logger.warn(`No key found uri=${uri} userId=${user.id} keyId=${keyId}`);
+ return { user, key: null };
}
- /**
- * AP Actor id => Misskey User and Key
- */
@bindThis
- public async getAuthUserFromApId(uri: string): Promise<{
- user: MiRemoteUser;
- key: MiUserPublickey | null;
- } | null> {
- const user = await this.apPersonService.resolvePerson(uri) as MiRemoteUser;
- if (user.isDeleted) return null;
-
- const key = await this.publicKeyByUserIdCache.fetch(
- user.id,
- () => this.userPublickeysRepository.findOneBy({ userId: user.id }),
+ public async getPublicKeyByUserId(userId: MiUser['id']): Promise<MiUserPublickey[] | null> {
+ return await this.publicKeyByUserIdCache.fetch(
+ userId,
+ () => this.userPublickeysRepository.find({ where: { userId } }),
v => v != null,
);
+ }
- return {
- user,
- key,
- };
+ @bindThis
+ public refreshCacheByUserId(userId: MiUser['id']): void {
+ this.publicKeyByUserIdCache.delete(userId);
}
@bindThis
public dispose(): void {
- this.publicKeyCache.dispose();
this.publicKeyByUserIdCache.dispose();
}
diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts
index 5d07cd8e8f..db3302e6ff 100644
--- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts
+++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts
@@ -9,10 +9,14 @@ import { DI } from '@/di-symbols.js';
import type { FollowingsRepository } from '@/models/_.js';
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
import { QueueService } from '@/core/QueueService.js';
-import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import type { IActivity } from '@/core/activitypub/type.js';
import { ThinUser } from '@/queue/types.js';
+import { AccountUpdateService } from '@/core/AccountUpdateService.js';
+import type Logger from '@/logger.js';
+import { UserKeypairService } from '../UserKeypairService.js';
+import { ApLoggerService } from './ApLoggerService.js';
+import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures';
interface IRecipe {
type: string;
@@ -27,12 +31,19 @@ interface IDirectRecipe extends IRecipe {
to: MiRemoteUser;
}
+interface IAllKnowingSharedInboxRecipe extends IRecipe {
+ type: 'AllKnowingSharedInbox';
+}
+
const isFollowers = (recipe: IRecipe): recipe is IFollowersRecipe =>
recipe.type === 'Followers';
const isDirect = (recipe: IRecipe): recipe is IDirectRecipe =>
recipe.type === 'Direct';
+const isAllKnowingSharedInbox = (recipe: IRecipe): recipe is IAllKnowingSharedInboxRecipe =>
+ recipe.type === 'AllKnowingSharedInbox';
+
class DeliverManager {
private actor: ThinUser;
private activity: IActivity | null;
@@ -40,16 +51,18 @@ class DeliverManager {
/**
* Constructor
- * @param userEntityService
+ * @param userKeypairService
* @param followingsRepository
* @param queueService
* @param actor Actor
* @param activity Activity to deliver
*/
constructor(
- private userEntityService: UserEntityService,
+ private userKeypairService: UserKeypairService,
private followingsRepository: FollowingsRepository,
private queueService: QueueService,
+ private accountUpdateService: AccountUpdateService,
+ private logger: Logger,
actor: { id: MiUser['id']; host: null; },
activity: IActivity | null,
@@ -92,6 +105,18 @@ class DeliverManager {
}
/**
+ * Add recipe for all-knowing shared inbox deliver
+ */
+ @bindThis
+ public addAllKnowingSharedInboxRecipe(): void {
+ const deliver: IAllKnowingSharedInboxRecipe = {
+ type: 'AllKnowingSharedInbox',
+ };
+
+ this.addRecipe(deliver);
+ }
+
+ /**
* Add recipe
* @param recipe Recipe
*/
@@ -104,11 +129,44 @@ class DeliverManager {
* Execute delivers
*/
@bindThis
- public async execute(): Promise<void> {
+ public async execute(opts?: { privateKey?: PrivateKeyWithPem }): Promise<void> {
+ //#region MIGRATION
+ if (!opts?.privateKey) {
+ /**
+ * ed25519の署名がなければ追加する
+ */
+ const created = await this.userKeypairService.refreshAndPrepareEd25519KeyPair(this.actor.id);
+ if (created) {
+ // createdが存在するということは新規作成されたということなので、フォロワーに配信する
+ this.logger.info(`ed25519 key pair created for user ${this.actor.id} and publishing to followers`);
+ // リモートに配信
+ const keyPair = await this.userKeypairService.getLocalUserPrivateKeyPem(created, 'main');
+ await this.accountUpdateService.publishToFollowers(this.actor.id, keyPair);
+ }
+ }
+ //#endregion
+
+ //#region collect inboxes by recipes
// The value flags whether it is shared or not.
// key: inbox URL, value: whether it is sharedInbox
const inboxes = new Map<string, boolean>();
+ if (this.recipes.some(r => isAllKnowingSharedInbox(r))) {
+ // all-knowing shared inbox
+ const followings = await this.followingsRepository.find({
+ where: [
+ { followerSharedInbox: Not(IsNull()) },
+ { followeeSharedInbox: Not(IsNull()) },
+ ],
+ select: ['followerSharedInbox', 'followeeSharedInbox'],
+ });
+
+ for (const following of followings) {
+ if (following.followeeSharedInbox) inboxes.set(following.followeeSharedInbox, true);
+ if (following.followerSharedInbox) inboxes.set(following.followerSharedInbox, true);
+ }
+ }
+
// build inbox list
// Process follower recipes first to avoid duplication when processing direct recipes later.
if (this.recipes.some(r => isFollowers(r))) {
@@ -142,39 +200,49 @@ class DeliverManager {
inboxes.set(recipe.to.inbox, false);
}
+ //#endregion
// deliver
- await this.queueService.deliverMany(this.actor, this.activity, inboxes);
+ await this.queueService.deliverMany(this.actor, this.activity, inboxes, opts?.privateKey);
+ this.logger.info(`Deliver queues dispatched: inboxes=${inboxes.size} actorId=${this.actor.id} activityId=${this.activity?.id}`);
}
}
@Injectable()
export class ApDeliverManagerService {
+ private logger: Logger;
+
constructor(
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
- private userEntityService: UserEntityService,
+ private userKeypairService: UserKeypairService,
private queueService: QueueService,
+ private accountUpdateService: AccountUpdateService,
+ private apLoggerService: ApLoggerService,
) {
+ this.logger = this.apLoggerService.logger.createSubLogger('deliver-manager');
}
/**
* Deliver activity to followers
* @param actor
* @param activity Activity
+ * @param forceMainKey Force to use main (rsa) key
*/
@bindThis
- public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity): Promise<void> {
+ public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, privateKey?: PrivateKeyWithPem): Promise<void> {
const manager = new DeliverManager(
- this.userEntityService,
+ this.userKeypairService,
this.followingsRepository,
this.queueService,
+ this.accountUpdateService,
+ this.logger,
actor,
activity,
);
manager.addFollowersRecipe();
- await manager.execute();
+ await manager.execute({ privateKey });
}
/**
@@ -186,9 +254,11 @@ export class ApDeliverManagerService {
@bindThis
public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise<void> {
const manager = new DeliverManager(
- this.userEntityService,
+ this.userKeypairService,
this.followingsRepository,
this.queueService,
+ this.accountUpdateService,
+ this.logger,
actor,
activity,
);
@@ -199,10 +269,11 @@ export class ApDeliverManagerService {
@bindThis
public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager {
return new DeliverManager(
- this.userEntityService,
+ this.userKeypairService,
this.followingsRepository,
this.queueService,
-
+ this.accountUpdateService,
+ this.logger,
actor,
activity,
);
diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts
index e2164fec1d..1bef9fe071 100644
--- a/packages/backend/src/core/activitypub/ApInboxService.ts
+++ b/packages/backend/src/core/activitypub/ApInboxService.ts
@@ -114,15 +114,8 @@ export class ApInboxService {
result = await this.performOneActivity(actor, activity);
}
- // ついでにリモートユーザーの情報が古かったら更新しておく
- if (actor.uri) {
- if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
- setImmediate(() => {
- this.apPersonService.updatePerson(actor.uri);
- });
- }
- }
- return result;
+ // ついでにリモートユーザーの情報が古かったら更新しておく?
+ // → No, この関数が呼び出される前に署名検証で更新されているはず
}
@bindThis
diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts
index 98e944f347..5d7419f934 100644
--- a/packages/backend/src/core/activitypub/ApRendererService.ts
+++ b/packages/backend/src/core/activitypub/ApRendererService.ts
@@ -22,7 +22,6 @@ import { UserKeypairService } from '@/core/UserKeypairService.js';
import { MfmService } from '@/core/MfmService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
-import type { MiUserKeypair } from '@/models/UserKeypair.js';
import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
@@ -31,6 +30,7 @@ import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js';
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
+import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures';
@Injectable()
export class ApRendererService {
@@ -251,15 +251,15 @@ export class ApRendererService {
}
@bindThis
- public renderKey(user: MiLocalUser, key: MiUserKeypair, postfix?: string): IKey {
+ public renderKey(user: MiLocalUser, publicKey: string, postfix?: string): IKey {
return {
- id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`,
+ id: `${this.userEntityService.genLocalUserUri(user.id)}${postfix ?? '/publickey'}`,
type: 'Key',
owner: this.userEntityService.genLocalUserUri(user.id),
- publicKeyPem: createPublicKey(key.publicKey).export({
+ publicKeyPem: createPublicKey(publicKey).export({
type: 'spki',
format: 'pem',
- }),
+ }) as string,
};
}
@@ -499,7 +499,10 @@ export class ApRendererService {
tag,
manuallyApprovesFollowers: user.isLocked,
discoverable: user.isExplorable,
- publicKey: this.renderKey(user, keypair, '#main-key'),
+ publicKey: this.renderKey(user, keypair.publicKey, '#main-key'),
+ additionalPublicKeys: [
+ ...(keypair.ed25519PublicKey ? [this.renderKey(user, keypair.ed25519PublicKey, '#ed25519-key')] : []),
+ ],
isCat: user.isCat,
attachment: attachment.length ? attachment : undefined,
};
@@ -622,12 +625,10 @@ export class ApRendererService {
}
@bindThis
- public async attachLdSignature(activity: any, user: { id: MiUser['id']; host: null; }): Promise<IActivity> {
- const keypair = await this.userKeypairService.getUserKeypair(user.id);
-
+ public async attachLdSignature(activity: any, key: PrivateKeyWithPem): Promise<IActivity> {
const jsonLd = this.jsonLdService.use();
jsonLd.debug = false;
- activity = await jsonLd.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`);
+ activity = await jsonLd.signRsaSignature2017(activity, key.privateKeyPem, key.keyId);
return activity;
}
diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts
index 93ac8ce9a7..0cae91316b 100644
--- a/packages/backend/src/core/activitypub/ApRequestService.ts
+++ b/packages/backend/src/core/activitypub/ApRequestService.ts
@@ -3,9 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import * as crypto from 'node:crypto';
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
+import { genRFC3230DigestHeader, signAsDraftToRequest } from '@misskey-dev/node-http-message-signatures';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js';
@@ -15,122 +15,61 @@ import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import type Logger from '@/logger.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
+import type { PrivateKeyWithPem, PrivateKey } from '@misskey-dev/node-http-message-signatures';
-type Request = {
- url: string;
- method: string;
- headers: Record<string, string>;
-};
+export async function createSignedPost(args: { level: string; key: PrivateKey; url: string; body: string; digest?: string, additionalHeaders: Record<string, string> }) {
+ const u = new URL(args.url);
+ const request = {
+ url: u.href,
+ method: 'POST',
+ headers: {
+ 'Date': new Date().toUTCString(),
+ 'Host': u.host,
+ 'Content-Type': 'application/activity+json',
+ ...args.additionalHeaders,
+ } as Record<string, string>,
+ };
-type Signed = {
- request: Request;
- signingString: string;
- signature: string;
- signatureHeader: string;
-};
+ // TODO: httpMessageSignaturesImplementationLevelによって新規格で通信をするようにする
+ const digestHeader = args.digest ?? await genRFC3230DigestHeader(args.body, 'SHA-256');
+ request.headers['Digest'] = digestHeader;
-type PrivateKey = {
- privateKeyPem: string;
- keyId: string;
-};
+ const result = await signAsDraftToRequest(
+ request,
+ args.key,
+ ['(request-target)', 'date', 'host', 'digest'],
+ );
-export class ApRequestCreator {
- static createSignedPost(args: { key: PrivateKey, url: string, body: string, digest?: string, additionalHeaders: Record<string, string> }): Signed {
- const u = new URL(args.url);
- const digestHeader = args.digest ?? this.createDigest(args.body);
-
- const request: Request = {
- url: u.href,
- method: 'POST',
- headers: this.#objectAssignWithLcKey({
- 'Date': new Date().toUTCString(),
- 'Host': u.host,
- 'Content-Type': 'application/activity+json',
- 'Digest': digestHeader,
- }, args.additionalHeaders),
- };
-
- const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']);
-
- return {
- request,
- signingString: result.signingString,
- signature: result.signature,
- signatureHeader: result.signatureHeader,
- };
- }
-
- static createDigest(body: string) {
- return `SHA-256=${crypto.createHash('sha256').update(body).digest('base64')}`;
- }
-
- static createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record<string, string> }): Signed {
- const u = new URL(args.url);
-
- const request: Request = {
- url: u.href,
- method: 'GET',
- headers: this.#objectAssignWithLcKey({
- 'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
- 'Date': new Date().toUTCString(),
- 'Host': new URL(args.url).host,
- }, args.additionalHeaders),
- };
-
- const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']);
-
- return {
- request,
- signingString: result.signingString,
- signature: result.signature,
- signatureHeader: result.signatureHeader,
- };
- }
-
- static #signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed {
- const signingString = this.#genSigningString(request, includeHeaders);
- 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}"`;
-
- request.headers = this.#objectAssignWithLcKey(request.headers, {
- Signature: signatureHeader,
- });
- // node-fetch will generate this for us. if we keep 'Host', it won't change with redirects!
- delete request.headers['host'];
-
- return {
- request,
- signingString,
- signature,
- signatureHeader,
- };
- }
-
- static #genSigningString(request: Request, includeHeaders: string[]): string {
- request.headers = this.#lcObjectKey(request.headers);
-
- const results: string[] = [];
+ return {
+ request,
+ ...result,
+ };
+}
- 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]}`);
- }
- }
+export async function createSignedGet(args: { level: string; key: PrivateKey; url: string; additionalHeaders: Record<string, string> }) {
+ const u = new URL(args.url);
+ const request = {
+ url: u.href,
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
+ 'Date': new Date().toUTCString(),
+ 'Host': new URL(args.url).host,
+ ...args.additionalHeaders,
+ } as Record<string, string>,
+ };
- return results.join('\n');
- }
+ // TODO: httpMessageSignaturesImplementationLevelによって新規格で通信をするようにする
+ const result = await signAsDraftToRequest(
+ request,
+ args.key,
+ ['(request-target)', 'date', 'host', 'accept'],
+ );
- static #lcObjectKey(src: Record<string, string>): Record<string, string> {
- 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;
- }
-
- static #objectAssignWithLcKey(a: Record<string, string>, b: Record<string, string>): Record<string, string> {
- return Object.assign(this.#lcObjectKey(a), this.#lcObjectKey(b));
- }
+ return {
+ request,
+ ...result,
+ };
}
@Injectable()
@@ -150,21 +89,28 @@ export class ApRequestService {
}
@bindThis
- public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, digest?: string): Promise<void> {
+ public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, level: string, digest?: string, key?: PrivateKeyWithPem): Promise<void> {
const body = typeof object === 'string' ? object : JSON.stringify(object);
-
- const keypair = await this.userKeypairService.getUserKeypair(user.id);
-
- const req = ApRequestCreator.createSignedPost({
- key: {
- privateKeyPem: keypair.privateKey,
- keyId: `${this.config.url}/users/${user.id}#main-key`,
- },
+ const keyFetched = await this.userKeypairService.getLocalUserPrivateKey(key ?? user.id, level);
+ const req = await createSignedPost({
+ level,
+ key: keyFetched,
url,
body,
- digest,
additionalHeaders: {
+ 'User-Agent': this.config.userAgent,
},
+ digest,
+ });
+
+ // node-fetch will generate this for us. if we keep 'Host', it won't change with redirects!
+ delete req.request.headers['Host'];
+
+ this.logger.debug('create signed post', {
+ version: 'draft',
+ level,
+ url,
+ keyId: keyFetched.keyId,
});
await this.httpRequestService.send(url, {
@@ -180,19 +126,27 @@ export class ApRequestService {
* @param url URL to fetch
*/
@bindThis
- public async signedGet(url: string, user: { id: MiUser['id'] }): Promise<unknown> {
- const keypair = await this.userKeypairService.getUserKeypair(user.id);
-
- const req = ApRequestCreator.createSignedGet({
- key: {
- privateKeyPem: keypair.privateKey,
- keyId: `${this.config.url}/users/${user.id}#main-key`,
- },
+ public async signedGet(url: string, user: { id: MiUser['id'] }, level: string): Promise<unknown> {
+ const key = await this.userKeypairService.getLocalUserPrivateKey(user.id, level);
+ const req = await createSignedGet({
+ level,
+ key,
url,
additionalHeaders: {
+ 'User-Agent': this.config.userAgent,
},
});
+ // node-fetch will generate this for us. if we keep 'Host', it won't change with redirects!
+ delete req.request.headers['Host'];
+
+ this.logger.debug('create signed get', {
+ version: 'draft',
+ level,
+ url,
+ keyId: key.keyId,
+ });
+
const res = await this.httpRequestService.send(url, {
method: req.request.method,
headers: req.request.headers,
diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts
index bb3c40f093..727ff6f956 100644
--- a/packages/backend/src/core/activitypub/ApResolverService.ts
+++ b/packages/backend/src/core/activitypub/ApResolverService.ts
@@ -16,6 +16,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
+import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { isCollectionOrOrderedCollection } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js';
@@ -41,6 +42,7 @@ export class Resolver {
private httpRequestService: HttpRequestService,
private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService,
+ private federatedInstanceService: FederatedInstanceService,
private loggerService: LoggerService,
private recursionLimit = 100,
) {
@@ -103,8 +105,10 @@ export class Resolver {
this.user = await this.instanceActorService.getInstanceActor();
}
+ const server = await this.federatedInstanceService.fetch(host);
+
const object = (this.user
- ? await this.apRequestService.signedGet(value, this.user) as IObject
+ ? await this.apRequestService.signedGet(value, this.user, server.httpMessageSignaturesImplementationLevel) as IObject
: await this.httpRequestService.getActivityJson(value)) as IObject;
if (
@@ -200,6 +204,7 @@ export class ApResolverService {
private httpRequestService: HttpRequestService,
private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService,
+ private federatedInstanceService: FederatedInstanceService,
private loggerService: LoggerService,
) {
}
@@ -220,6 +225,7 @@ export class ApResolverService {
this.httpRequestService,
this.apRendererService,
this.apDbResolverService,
+ this.federatedInstanceService,
this.loggerService,
);
}
diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts
index feb8c42c56..fc4e3e3bef 100644
--- a/packages/backend/src/core/activitypub/misc/contexts.ts
+++ b/packages/backend/src/core/activitypub/misc/contexts.ts
@@ -134,6 +134,7 @@ const security_v1 = {
'privateKey': { '@id': 'sec:privateKey', '@type': '@id' },
'privateKeyPem': 'sec:privateKeyPem',
'publicKey': { '@id': 'sec:publicKey', '@type': '@id' },
+ 'additionalPublicKeys': { '@id': 'sec:publicKey', '@type': '@id' },
'publicKeyBase58': 'sec:publicKeyBase58',
'publicKeyPem': 'sec:publicKeyPem',
'publicKeyWif': 'sec:publicKeyWif',
diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts
index 457205e023..c41fc713d5 100644
--- a/packages/backend/src/core/activitypub/models/ApPersonService.ts
+++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts
@@ -3,9 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
+import { verify } from 'crypto';
import { Inject, Injectable } from '@nestjs/common';
import promiseLimit from 'promise-limit';
-import { DataSource } from 'typeorm';
+import { DataSource, In, Not } from 'typeorm';
import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js';
import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js';
@@ -39,6 +40,7 @@ import { MetaService } from '@/core/MetaService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { AccountMoveService } from '@/core/AccountMoveService.js';
import { checkHttps } from '@/misc/check-https.js';
+import { REMOTE_USER_CACHE_TTL, REMOTE_USER_MOVE_COOLDOWN } from '@/const.js';
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
import { extractApHashtags } from './tag.js';
import type { OnModuleInit } from '@nestjs/common';
@@ -48,7 +50,7 @@ import type { ApResolverService, Resolver } from '../ApResolverService.js';
import type { ApLoggerService } from '../ApLoggerService.js';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { ApImageService } from './ApImageService.js';
-import type { IActor, IObject } from '../type.js';
+import type { IActor, IKey, IObject } from '../type.js';
const nameLength = 128;
const summaryLength = 2048;
@@ -185,13 +187,38 @@ export class ApPersonService implements OnModuleInit {
}
if (x.publicKey) {
- if (typeof x.publicKey.id !== 'string') {
- throw new Error('invalid Actor: publicKey.id is not a string');
+ const publicKeys = Array.isArray(x.publicKey) ? x.publicKey : [x.publicKey];
+
+ for (const publicKey of publicKeys) {
+ if (typeof publicKey.id !== 'string') {
+ throw new Error('invalid Actor: publicKey.id is not a string');
+ }
+
+ const publicKeyIdHost = this.punyHost(publicKey.id);
+ if (publicKeyIdHost !== expectHost) {
+ throw new Error('invalid Actor: publicKey.id has different host');
+ }
}
+ }
- const publicKeyIdHost = this.punyHost(x.publicKey.id);
- if (publicKeyIdHost !== expectHost) {
- throw new Error('invalid Actor: publicKey.id has different host');
+ if (x.additionalPublicKeys) {
+ if (!x.publicKey) {
+ throw new Error('invalid Actor: additionalPublicKeys is set but publicKey is not');
+ }
+
+ if (!Array.isArray(x.additionalPublicKeys)) {
+ throw new Error('invalid Actor: additionalPublicKeys is not an array');
+ }
+
+ for (const key of x.additionalPublicKeys) {
+ if (typeof key.id !== 'string') {
+ throw new Error('invalid Actor: additionalPublicKeys.id is not a string');
+ }
+
+ const keyIdHost = this.punyHost(key.id);
+ if (keyIdHost !== expectHost) {
+ throw new Error('invalid Actor: additionalPublicKeys.id has different host');
+ }
}
}
@@ -228,6 +255,33 @@ export class ApPersonService implements OnModuleInit {
return null;
}
+ /**
+ * uriからUser(Person)をフェッチします。
+ *
+ * Misskeyに対象のPersonが登録されていればそれを返し、登録がなければnullを返します。
+ * また、TTLが0でない場合、TTLを過ぎていた場合はupdatePersonを実行します。
+ */
+ @bindThis
+ async fetchPersonWithRenewal(uri: string, TTL = REMOTE_USER_CACHE_TTL): Promise<MiLocalUser | MiRemoteUser | null> {
+ const exist = await this.fetchPerson(uri);
+ if (exist == null) return null;
+
+ if (this.userEntityService.isRemoteUser(exist)) {
+ if (TTL === 0 || exist.lastFetchedAt == null || Date.now() - exist.lastFetchedAt.getTime() > TTL) {
+ this.logger.debug('fetchPersonWithRenewal: renew', { uri, TTL, lastFetchedAt: exist.lastFetchedAt });
+ try {
+ await this.updatePerson(exist.uri);
+ return await this.fetchPerson(uri);
+ } catch (err) {
+ this.logger.error('error occurred while renewing user', { err });
+ }
+ }
+ this.logger.debug('fetchPersonWithRenewal: use cache', { uri, TTL, lastFetchedAt: exist.lastFetchedAt });
+ }
+
+ return exist;
+ }
+
private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any): Promise<Partial<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'avatarUrl' | 'bannerUrl' | 'avatarBlurhash' | 'bannerBlurhash'>>> {
if (user == null) throw new Error('failed to create user: user is null');
@@ -363,11 +417,15 @@ export class ApPersonService implements OnModuleInit {
}));
if (person.publicKey) {
- await transactionalEntityManager.save(new MiUserPublickey({
- userId: user.id,
- keyId: person.publicKey.id,
- keyPem: person.publicKey.publicKeyPem,
- }));
+ const publicKeys = new Map<string, IKey>();
+ (person.additionalPublicKeys ?? []).forEach(key => publicKeys.set(key.id, key));
+ (Array.isArray(person.publicKey) ? person.publicKey : [person.publicKey]).forEach(key => publicKeys.set(key.id, key));
+
+ await transactionalEntityManager.save(Array.from(publicKeys.values(), key => new MiUserPublickey({
+ keyId: key.id,
+ userId: user!.id,
+ keyPem: key.publicKeyPem,
+ })));
}
});
} catch (e) {
@@ -513,11 +571,29 @@ export class ApPersonService implements OnModuleInit {
// Update user
await this.usersRepository.update(exist.id, updates);
- if (person.publicKey) {
- await this.userPublickeysRepository.update({ userId: exist.id }, {
- keyId: person.publicKey.id,
- keyPem: person.publicKey.publicKeyPem,
+ try {
+ // Deleteアクティビティ受信時にもここが走ってsaveがuserforeign key制約エラーを吐くことがある
+ // とりあえずtry-catchで囲っておく
+ const publicKeys = new Map<string, IKey>();
+ if (person.publicKey) {
+ (person.additionalPublicKeys ?? []).forEach(key => publicKeys.set(key.id, key));
+ (Array.isArray(person.publicKey) ? person.publicKey : [person.publicKey]).forEach(key => publicKeys.set(key.id, key));
+
+ await this.userPublickeysRepository.save(Array.from(publicKeys.values(), key => ({
+ keyId: key.id,
+ userId: exist.id,
+ keyPem: key.publicKeyPem,
+ })));
+ }
+
+ this.userPublickeysRepository.delete({
+ keyId: Not(In(Array.from(publicKeys.keys()))),
+ userId: exist.id,
+ }).catch(err => {
+ this.logger.error('something happened while deleting remote user public keys:', { userId: exist.id, err });
});
+ } catch (err) {
+ this.logger.error('something happened while updating remote user public keys:', { userId: exist.id, err });
}
let _description: string | null = null;
@@ -559,7 +635,7 @@ export class ApPersonService implements OnModuleInit {
exist.movedAt == null ||
// 以前のmovingから14日以上経過した場合のみ移行処理を許可
// (Mastodonのクールダウン期間は30日だが若干緩めに設定しておく)
- exist.movedAt.getTime() + 1000 * 60 * 60 * 24 * 14 < updated.movedAt.getTime()
+ exist.movedAt.getTime() + REMOTE_USER_MOVE_COOLDOWN < updated.movedAt.getTime()
)) {
this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`);
return this.processRemoteMove(updated, movePreventUris)
@@ -582,9 +658,9 @@ export class ApPersonService implements OnModuleInit {
* リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
*/
@bindThis
- public async resolvePerson(uri: string, resolver?: Resolver): Promise<MiLocalUser | MiRemoteUser> {
+ public async resolvePerson(uri: string, resolver?: Resolver, withRenewal = false): Promise<MiLocalUser | MiRemoteUser> {
//#region このサーバーに既に登録されていたらそれを返す
- const exist = await this.fetchPerson(uri);
+ const exist = withRenewal ? await this.fetchPersonWithRenewal(uri) : await this.fetchPerson(uri);
if (exist) return exist;
//#endregion
diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts
index 5b6c6c8ca6..1d55971660 100644
--- a/packages/backend/src/core/activitypub/type.ts
+++ b/packages/backend/src/core/activitypub/type.ts
@@ -55,7 +55,7 @@ export function getOneApId(value: ApObject): string {
export function getApId(value: string | IObject): string {
if (typeof value === 'string') return value;
if (typeof value.id === 'string') return value.id;
- throw new Error('cannot detemine id');
+ throw new Error('cannot determine id');
}
/**
@@ -169,10 +169,8 @@ export interface IActor extends IObject {
discoverable?: boolean;
inbox: string;
sharedInbox?: string; // 後方互換性のため
- publicKey?: {
- id: string;
- publicKeyPem: string;
- };
+ publicKey?: IKey | IKey[];
+ additionalPublicKeys?: IKey[];
followers?: string | ICollection | IOrderedCollection;
following?: string | ICollection | IOrderedCollection;
featured?: string | IOrderedCollection;
@@ -236,8 +234,9 @@ export const isEmoji = (object: IObject): object is IApEmoji =>
export interface IKey extends IObject {
type: 'Key';
+ id: string;
owner: string;
- publicKeyPem: string | Buffer;
+ publicKeyPem: string;
}
export interface IApDocument extends IObject {
diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts
index 9117b13914..fd0f55c6ab 100644
--- a/packages/backend/src/core/entities/InstanceEntityService.ts
+++ b/packages/backend/src/core/entities/InstanceEntityService.ts
@@ -56,6 +56,7 @@ export class InstanceEntityService {
infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null,
latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null,
moderationNote: iAmModerator ? instance.moderationNote : null,
+ httpMessageSignaturesImplementationLevel: instance.httpMessageSignaturesImplementationLevel,
};
}