diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index ffe0a358a9..10bbe0a747 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -502,6 +502,10 @@ sidebar: "サイドバー" divider: "分割線" addItem: "項目を追加" rooms: "ルーム" +relays: "リレー" +addRelay: "リレーの追加" +inboxUrl: "inboxのURL" +addedRelays: "追加済みのリレー" _theme: explore: "テーマを探す" @@ -1090,3 +1094,8 @@ _pages: enviromentVariables: "環境変数" pageVariables: "ページ要素" argVariables: "入力スロット" + +_relayStatus: + requesting: "承認待ち" + accepted: "承認済み" + rejected: "拒否済み" diff --git a/migration/1589023282116-pubRelay.ts b/migration/1589023282116-pubRelay.ts new file mode 100644 index 0000000000..3b9d359915 --- /dev/null +++ b/migration/1589023282116-pubRelay.ts @@ -0,0 +1,18 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class pubRelay1589023282116 implements MigrationInterface { + name = 'pubRelay1589023282116' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "relay_status_enum" AS ENUM('requesting', 'accepted', 'rejected')`, undefined); + await queryRunner.query(`CREATE TABLE "relay" ("id" character varying(32) NOT NULL, "inbox" character varying(512) NOT NULL, "status" "relay_status_enum" NOT NULL, CONSTRAINT "PK_78ebc9cfddf4292633b7ba57aee" PRIMARY KEY ("id"))`, undefined); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_0d9a1738f2cf7f3b1c3334dfab" ON "relay" ("inbox") `, undefined); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_0d9a1738f2cf7f3b1c3334dfab"`, undefined); + await queryRunner.query(`DROP TABLE "relay"`, undefined); + await queryRunner.query(`DROP TYPE "relay_status_enum"`, undefined); + } + +} diff --git a/src/client/app.vue b/src/client/app.vue index 170ba9365d..5e7396205b 100644 --- a/src/client/app.vue +++ b/src/client/app.vue @@ -132,7 +132,7 @@ + + diff --git a/src/client/router.ts b/src/client/router.ts index e997d2db99..cf98c57bd7 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -58,6 +58,7 @@ export const router = new VueRouter({ { path: '/instance/queue', component: page('instance/queue') }, { path: '/instance/settings', component: page('instance/settings') }, { path: '/instance/federation', component: page('instance/federation') }, + { path: '/instance/relays', component: page('instance/relays') }, { path: '/instance/announcements', component: page('instance/announcements') }, { path: '/notes/:note', name: 'note', component: page('note') }, { path: '/tags/:tag', component: page('tag') }, diff --git a/src/db/postgre.ts b/src/db/postgre.ts index 9e3eb3f7d6..81fb92f684 100644 --- a/src/db/postgre.ts +++ b/src/db/postgre.ts @@ -58,6 +58,7 @@ import { AntennaNote } from '../models/entities/antenna-note'; import { PromoNote } from '../models/entities/promo-note'; import { PromoRead } from '../models/entities/promo-read'; import { program } from '../argv'; +import { Relay } from '../models/entities/relay'; const sqlLogger = dbLogger.createSubLogger('sql', 'white', false); @@ -149,6 +150,7 @@ export const entities = [ PromoRead, ReversiGame, ReversiMatching, + Relay, ...charts as any ]; diff --git a/src/misc/gen-key-pair.ts b/src/misc/gen-key-pair.ts new file mode 100644 index 0000000000..d4a8fa7534 --- /dev/null +++ b/src/misc/gen-key-pair.ts @@ -0,0 +1,36 @@ +import * as crypto from 'crypto'; +import * as util from 'util'; + +const generateKeyPair = util.promisify(crypto.generateKeyPair); + +export async function genRsaKeyPair(modulusLength = 2048) { + return await generateKeyPair('rsa', { + modulusLength, + publicKeyEncoding: { + type: 'spki', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: undefined, + passphrase: undefined + } + }); +} + +export async function genEcKeyPair(namedCurve: 'prime256v1' | 'secp384r1' | 'secp521r1' | 'curve25519' = 'prime256v1') { + return await generateKeyPair('ec', { + namedCurve, + publicKeyEncoding: { + type: 'spki', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: undefined, + passphrase: undefined + } + }); +} diff --git a/src/models/entities/relay.ts b/src/models/entities/relay.ts new file mode 100644 index 0000000000..4c82ccb125 --- /dev/null +++ b/src/models/entities/relay.ts @@ -0,0 +1,19 @@ +import { PrimaryColumn, Entity, Index, Column } from 'typeorm'; +import { id } from '../id'; + +@Entity() +export class Relay { + @PrimaryColumn(id()) + public id: string; + + @Index({ unique: true }) + @Column('varchar', { + length: 512, nullable: false, + }) + public inbox: string; + + @Column('enum', { + enum: ['requesting', 'accepted', 'rejected'], + }) + public status: 'requesting' | 'accepted' | 'rejected'; +} diff --git a/src/models/index.ts b/src/models/index.ts index c3b329f4f8..e1389e7353 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -52,6 +52,7 @@ import { AntennaNote } from './entities/antenna-note'; import { PromoNote } from './entities/promo-note'; import { PromoRead } from './entities/promo-read'; import { EmojiRepository } from './repositories/emoji'; +import { RelayRepository } from './repositories/relay'; export const Announcements = getRepository(Announcement); export const AnnouncementReads = getRepository(AnnouncementRead); @@ -106,3 +107,4 @@ export const Antennas = getCustomRepository(AntennaRepository); export const AntennaNotes = getRepository(AntennaNote); export const PromoNotes = getRepository(PromoNote); export const PromoReads = getRepository(PromoRead); +export const Relays = getCustomRepository(RelayRepository); diff --git a/src/models/repositories/relay.ts b/src/models/repositories/relay.ts new file mode 100644 index 0000000000..601bb5eb39 --- /dev/null +++ b/src/models/repositories/relay.ts @@ -0,0 +1,6 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Relay } from '../entities/relay'; + +@EntityRepository(Relay) +export class RelayRepository extends Repository { +} diff --git a/src/queue/processors/inbox.ts b/src/queue/processors/inbox.ts index f37f663ed5..3a0bdbe28a 100644 --- a/src/queue/processors/inbox.ts +++ b/src/queue/processors/inbox.ts @@ -56,12 +56,10 @@ export default async (job: Bull.Job): Promise => { } // HTTP-Signatureの検証 - if (!httpSignature.verifySignature(signature, authUser.key.keyPem)) { - return 'signature verification failed'; - } + const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); - // signatureのsignerは、activity.actorと一致する必要がある - if (authUser.user.uri !== activity.actor) { + // また、signatureのsignerは、activity.actorと一致する必要がある + if (!httpSignatureValidated || authUser.user.uri !== activity.actor) { // 一致しなくても、でもLD-Signatureがありそうならそっちも見る if (activity.signature) { if (activity.signature.type !== 'RsaSignature2017') { @@ -93,7 +91,7 @@ export default async (job: Bull.Job): Promise => { return `skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`; } } else { - return 'signature verification failed'; + throw `skip: http-signature verification failed.`; } } diff --git a/src/remote/activitypub/kernel/accept/follow.ts b/src/remote/activitypub/kernel/accept/follow.ts index c067f7622a..71c1bed9de 100644 --- a/src/remote/activitypub/kernel/accept/follow.ts +++ b/src/remote/activitypub/kernel/accept/follow.ts @@ -2,6 +2,7 @@ import { IRemoteUser } from '../../../../models/entities/user'; import accept from '../../../../services/following/requests/accept'; import { IFollow } from '../../type'; import DbResolver from '../../db-resolver'; +import { relayAccepted } from '../../../../services/relay'; export default async (actor: IRemoteUser, activity: IFollow): Promise => { // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある @@ -17,6 +18,12 @@ export default async (actor: IRemoteUser, activity: IFollow): Promise => return `skip: follower is not a local user`; } + // relay + const match = activity.id?.match(/follow-relay\/(\w+)/); + if (match) { + return await relayAccepted(match[1]); + } + await accept(actor, follower); return `ok`; }; diff --git a/src/remote/activitypub/kernel/reject/follow.ts b/src/remote/activitypub/kernel/reject/follow.ts index 49e82c7afc..d97ced46b3 100644 --- a/src/remote/activitypub/kernel/reject/follow.ts +++ b/src/remote/activitypub/kernel/reject/follow.ts @@ -2,6 +2,7 @@ import { IRemoteUser } from '../../../../models/entities/user'; import reject from '../../../../services/following/requests/reject'; import { IFollow } from '../../type'; import DbResolver from '../../db-resolver'; +import { relayRejected } from '../../../../services/relay'; export default async (actor: IRemoteUser, activity: IFollow): Promise => { // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある @@ -17,6 +18,12 @@ export default async (actor: IRemoteUser, activity: IFollow): Promise => return `skip: follower is not a local user`; } + // relay + const match = activity.id?.match(/follow-relay\/(\w+)/); + if (match) { + return await relayRejected(match[1]); + } + await reject(actor, follower); return `ok`; }; diff --git a/src/remote/activitypub/misc/ld-signature.ts b/src/remote/activitypub/misc/ld-signature.ts index d61b430f7a..070e39edfb 100644 --- a/src/remote/activitypub/misc/ld-signature.ts +++ b/src/remote/activitypub/misc/ld-signature.ts @@ -70,6 +70,7 @@ export class LdSignature { const transformedData = { ...data }; delete transformedData['signature']; const cannonidedData = await this.normalize(transformedData); + if (this.debug) console.debug(`cannonidedData: ${cannonidedData}`); const documentHash = this.sha256(cannonidedData); const verifyData = `${optionsHash}${documentHash}`; return verifyData; diff --git a/src/remote/activitypub/renderer/follow-relay.ts b/src/remote/activitypub/renderer/follow-relay.ts new file mode 100644 index 0000000000..58bc0c90c3 --- /dev/null +++ b/src/remote/activitypub/renderer/follow-relay.ts @@ -0,0 +1,14 @@ +import config from '../../../config'; +import { Relay } from '../../../models/entities/relay'; +import { ILocalUser } from '../../../models/entities/user'; + +export function renderFollowRelay(relay: Relay, relayActor: ILocalUser) { + const follow = { + id: `${config.url}/activities/follow-relay/${relay.id}`, + type: 'Follow', + actor: `${config.url}/users/${relayActor.id}`, + object: 'https://www.w3.org/ns/activitystreams#Public' + }; + + return follow; +} diff --git a/src/remote/activitypub/renderer/index.ts b/src/remote/activitypub/renderer/index.ts index 63447b0c43..e84a7d90ac 100644 --- a/src/remote/activitypub/renderer/index.ts +++ b/src/remote/activitypub/renderer/index.ts @@ -1,7 +1,12 @@ import config from '../../../config'; import { v4 as uuid } from 'uuid'; +import { IActivity } from '../type'; +import { LdSignature } from '../misc/ld-signature'; +import { ILocalUser } from '../../../models/entities/user'; +import { UserKeypairs } from '../../../models'; +import { ensure } from '../../../prelude/ensure'; -export const renderActivity = (x: any) => { +export const renderActivity = (x: any): IActivity | null => { if (x == null) return null; if (x !== null && typeof x === 'object' && x.id == null) { @@ -11,8 +16,46 @@ export const renderActivity = (x: any) => { return Object.assign({ '@context': [ 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', - { Hashtag: 'as:Hashtag' } + 'https://w3id.org/security/v1' ] }, x); }; + +export const attachLdSignature = async (activity: any, user: ILocalUser): Promise => { + if (activity == null) return null; + + const keypair = await UserKeypairs.findOne({ + userId: user.id + }).then(ensure); + + const obj = { + // as non-standards + manuallyApprovesFollowers: 'as:manuallyApprovesFollowers', + sensitive: 'as:sensitive', + Hashtag: 'as:Hashtag', + quoteUrl: 'as:quoteUrl', + // Mastodon + toot: 'http://joinmastodon.org/ns#', + Emoji: 'toot:Emoji', + featured: 'toot:featured', + // schema + schema: 'http://schema.org#', + PropertyValue: 'schema:PropertyValue', + value: 'schema:value', + // Misskey + misskey: `${config.url}/ns#`, + '_misskey_content': 'misskey:_misskey_content', + '_misskey_quote': 'misskey:_misskey_quote', + '_misskey_reaction': 'misskey:_misskey_reaction', + '_misskey_votes': 'misskey:_misskey_votes', + '_misskey_talk': 'misskey:_misskey_talk', + }; + + activity['@context'].push(obj); + + const ldSignature = new LdSignature(); + ldSignature.debug = false; + activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${config.url}/users/${user.id}#main-key`); + + return activity; +}; diff --git a/src/remote/activitypub/renderer/person.ts b/src/remote/activitypub/renderer/person.ts index 56ff10319a..bc8a462d2e 100644 --- a/src/remote/activitypub/renderer/person.ts +++ b/src/remote/activitypub/renderer/person.ts @@ -13,6 +13,7 @@ import { ensure } from '../../../prelude/ensure'; export async function renderPerson(user: ILocalUser) { const id = `${config.url}/users/${user.id}`; + const isSystem = !!user.username.match(/\./); const [avatar, banner, profile] = await Promise.all([ user.avatarId ? DriveFiles.findOne(user.avatarId) : Promise.resolve(undefined), @@ -52,7 +53,7 @@ export async function renderPerson(user: ILocalUser) { const keypair = await UserKeypairs.findOne(user.id).then(ensure); return { - type: user.isBot ? 'Service' : 'Person', + type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person', id, inbox: `${id}/inbox`, outbox: `${id}/outbox`, diff --git a/src/server/api/endpoints/admin/relays/add.ts b/src/server/api/endpoints/admin/relays/add.ts new file mode 100644 index 0000000000..3ea6bcc73b --- /dev/null +++ b/src/server/api/endpoints/admin/relays/add.ts @@ -0,0 +1,24 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { addRelay } from '../../../../../services/relay'; + +export const meta = { + desc: { + 'ja-JP': 'Add relay' + }, + + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true as const, + + params: { + inbox: { + validator: $.str + }, + }, +}; + +export default define(meta, async (ps, user) => { + return await addRelay(ps.inbox); +}); diff --git a/src/server/api/endpoints/admin/relays/list.ts b/src/server/api/endpoints/admin/relays/list.ts new file mode 100644 index 0000000000..3b132f73b3 --- /dev/null +++ b/src/server/api/endpoints/admin/relays/list.ts @@ -0,0 +1,20 @@ +import define from '../../../define'; +import { listRelay } from '../../../../../services/relay'; + +export const meta = { + desc: { + 'ja-JP': 'List relay' + }, + + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true as const, + + params: { + }, +}; + +export default define(meta, async (ps, user) => { + return await listRelay(); +}); diff --git a/src/server/api/endpoints/admin/relays/remove.ts b/src/server/api/endpoints/admin/relays/remove.ts new file mode 100644 index 0000000000..df95e0329a --- /dev/null +++ b/src/server/api/endpoints/admin/relays/remove.ts @@ -0,0 +1,24 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { removeRelay } from '../../../../../services/relay'; + +export const meta = { + desc: { + 'ja-JP': 'Remove relay' + }, + + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true as const, + + params: { + inbox: { + validator: $.str + }, + }, +}; + +export default define(meta, async (ps, user) => { + return await removeRelay(ps.inbox); +}); diff --git a/src/services/create-system-user.ts b/src/services/create-system-user.ts new file mode 100644 index 0000000000..7f59efb448 --- /dev/null +++ b/src/services/create-system-user.ts @@ -0,0 +1,59 @@ +import * as bcrypt from 'bcryptjs'; +import { v4 as uuid } from 'uuid'; +import generateNativeUserToken from '../server/api/common/generate-native-user-token'; +import { genRsaKeyPair } from '../misc/gen-key-pair'; +import { User } from '../models/entities/user'; +import { UserProfile } from '../models/entities/user-profile'; +import { getConnection } from 'typeorm'; +import { genId } from '../misc/gen-id'; +import { UserKeypair } from '../models/entities/user-keypair'; +import { UsedUsername } from '../models/entities/used-username'; + +export async function createSystemUser(username: string) { + const password = uuid(); + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(password, salt); + + // Generate secret + const secret = generateNativeUserToken(); + + const keyPair = await genRsaKeyPair(4096); + + let account!: User; + + // Start transaction + await getConnection().transaction(async transactionalEntityManager => { + account = await transactionalEntityManager.save(new User({ + id: genId(), + createdAt: new Date(), + username: username, + usernameLower: username.toLowerCase(), + host: null, + token: secret, + isAdmin: false, + isLocked: true, + isBot: true, + })); + + await transactionalEntityManager.save(new UserKeypair({ + publicKey: keyPair.publicKey, + privateKey: keyPair.privateKey, + userId: account.id + })); + + await transactionalEntityManager.save(new UserProfile({ + userId: account.id, + autoAcceptFollowed: false, + password: hash, + })); + + await transactionalEntityManager.save(new UsedUsername({ + createdAt: new Date(), + username: username.toLowerCase(), + })); + }); + + return account; +} diff --git a/src/services/i/pin.ts b/src/services/i/pin.ts index 9fd7263ffc..fcddc50636 100644 --- a/src/services/i/pin.ts +++ b/src/services/i/pin.ts @@ -9,6 +9,7 @@ import { Notes, UserNotePinings, Users } from '../../models'; import { UserNotePining } from '../../models/entities/user-note-pinings'; import { genId } from '../../misc/gen-id'; import { deliverToFollowers } from '../../remote/activitypub/deliver-manager'; +import { deliverToRelays } from '../relay'; /** * 指定した投稿をピン留めします @@ -87,4 +88,5 @@ export async function deliverPinnedChange(userId: User['id'], noteId: Note['id'] const content = renderActivity(isAddition ? renderAdd(user, target, item) : renderRemove(user, target, item)); deliverToFollowers(user, content); + deliverToRelays(user, content); } diff --git a/src/services/i/update.ts b/src/services/i/update.ts index ae72e91345..8d40b08a85 100644 --- a/src/services/i/update.ts +++ b/src/services/i/update.ts @@ -4,6 +4,7 @@ import { Users } from '../../models'; import { User } from '../../models/entities/user'; import { renderPerson } from '../../remote/activitypub/renderer/person'; import { deliverToFollowers } from '../../remote/activitypub/deliver-manager'; +import { deliverToRelays } from '../relay'; export async function publishToFollowers(userId: User['id']) { const user = await Users.findOne(userId); @@ -13,5 +14,6 @@ export async function publishToFollowers(userId: User['id']) { if (Users.isLocalUser(user)) { const content = renderActivity(renderUpdate(await renderPerson(user), user)); deliverToFollowers(user, content); + deliverToRelays(user, content); } } diff --git a/src/services/note/create.ts b/src/services/note/create.ts index f506337924..60a62dcdff 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -31,6 +31,7 @@ import { ensure } from '../../prelude/ensure'; import { checkHitAntenna } from '../../misc/check-hit-antenna'; import { addNoteToAntenna } from '../add-note-to-antenna'; import { countSameRenotes } from '../../misc/count-same-renotes'; +import { deliverToRelays } from '../relay'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -349,6 +350,10 @@ export default async (user: User, data: Option, silent = false) => new Promise { + const user = await Users.findOne({ + host: null, + username: ACTOR_USERNAME + }); + + if (user) return user as ILocalUser; + + const created = await createSystemUser(ACTOR_USERNAME); + return created as ILocalUser; +} + +export async function addRelay(inbox: string) { + const relay = await Relays.save({ + id: genId(), + inbox, + status: 'requesting' + }); + + const relayActor = await getRelayActor(); + const follow = await renderFollowRelay(relay, relayActor); + const activity = renderActivity(follow); + deliver(relayActor, activity, relay.inbox); + + return relay; +} + +export async function removeRelay(inbox: string) { + const relay = await Relays.findOne({ + inbox + }); + + if (relay == null) { + throw 'relay not found'; + } + + const relayActor = await getRelayActor(); + const follow = renderFollowRelay(relay, relayActor); + const undo = renderUndo(follow, relayActor); + const activity = renderActivity(undo); + deliver(relayActor, activity, relay.inbox); + + await Relays.delete(relay.id); +} + +export async function listRelay() { + const relays = await Relays.find(); + return relays; +} + +export async function relayAccepted(id: string) { + const result = await Relays.update(id, { + status: 'accepted' + }); + + return JSON.stringify(result); +} + +export async function relayRejected(id: string) { + const result = await Relays.update(id, { + status: 'rejected' + }); + + return JSON.stringify(result); +} + +export async function deliverToRelays(user: ILocalUser, activity: any) { + if (activity == null) return; + + const relays = await Relays.find({ + status: 'accepted' + }); + if (relays.length === 0) return; + + const relayActor = await getRelayActor(); + + const copy = JSON.parse(JSON.stringify(activity)); + if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public']; + + const signed = await attachLdSignature(copy, user); + + for (const relay of relays) { + deliver(relayActor, signed, relay.inbox); + } +}