ActivityPubでリモートのオブジェクトをGETするときのリクエストをHTTP Signatureで署名するオプション (#6731)

* Sign ActivityPub GET

* Fix v12, v12.48.0 UI bug
This commit is contained in:
MeiMei 2020-10-18 01:46:40 +09:00 committed by GitHub
parent ba3c62bf9c
commit 85a0f696bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 298 additions and 8 deletions

View file

@ -12,6 +12,7 @@ export default defineComponent({
const acct = new URL(location.href).searchParams.get('acct');
if (acct == null) return;
/*
const dialog = os.dialog({
type: 'waiting',
text: this.$t('fetchingAsApObject') + '...',
@ -19,6 +20,7 @@ export default defineComponent({
showCancelButton: false,
cancelableByBgClick: false
});
*/
if (acct.startsWith('https://')) {
os.api('ap/show', {
@ -26,6 +28,8 @@ export default defineComponent({
}).then(res => {
if (res.type == 'User') {
this.follow(res.object);
} else if (res.type === 'Note') {
this.$router.push(`/notes/${res.object.id}`);
} else {
os.dialog({
type: 'error',
@ -42,7 +46,7 @@ export default defineComponent({
window.close();
});
}).finally(() => {
dialog.close();
//dialog.close();
});
} else {
os.api('users/show', parseAcct(acct)).then(user => {
@ -55,7 +59,7 @@ export default defineComponent({
window.close();
});
}).finally(() => {
dialog.close();
//dialog.close();
});
}
},

View file

@ -48,6 +48,7 @@ export async function search(q?: string | null | undefined) {
}
if (q.startsWith('https://')) {
/*
const dialog = os.dialog({
type: 'waiting',
text: i18n.global.t('fetchingAsApObject') + '...',
@ -55,19 +56,20 @@ export async function search(q?: string | null | undefined) {
showCancelButton: false,
cancelableByBgClick: false
});
*/
try {
const res = await os.api('ap/show', {
uri: q
});
dialog.cancel();
//dialog.cancel();
if (res.type === 'User') {
router.push(`/@${res.object.username}@${res.object.host}`);
} else if (res.type === 'Note') {
router.push(`/notes/${res.object.id}`);
}
} catch (e) {
dialog.cancel();
//dialog.cancel();
// TODO: Show error
}

View file

@ -58,6 +58,8 @@ export type Source = {
};
mediaProxy?: string;
signToActivityPubGet?: boolean;
};
/**

View file

@ -5,6 +5,7 @@ import fetch, { HeadersInit } from 'node-fetch';
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import config from '../config';
import { URL } from 'url';
export async function getJson(url: string, accept = 'application/json, */*', timeout = 10000, headers?: HeadersInit) {
const res = await fetch(url, {
@ -69,14 +70,14 @@ const _https = new https.Agent({
* Get http proxy or non-proxy agent
*/
export const httpAgent = config.proxy
? new HttpProxyAgent(config.proxy)
? new HttpProxyAgent(config.proxy) as unknown as http.Agent
: _http;
/**
* Get https proxy or non-proxy agent
*/
export const httpsAgent = config.proxy
? new HttpsProxyAgent(config.proxy)
? new HttpsProxyAgent(config.proxy) as unknown as https.Agent
: _https;
/**

View file

@ -1,3 +1,4 @@
import * as http from 'http';
import * as https from 'https';
import { sign } from 'http-signature';
import * as crypto from 'crypto';
@ -7,6 +8,9 @@ import { ILocalUser } from '../../models/entities/user';
import { UserKeypairs } from '../../models';
import { ensure } from '../../prelude/ensure';
import { getAgentByUrl } from '../../misc/fetch';
import { URL } from 'url';
import got from 'got';
import * as Got from 'got';
export default async (user: ILocalUser, url: string, object: any) => {
const timeout = 10 * 1000;
@ -62,3 +66,96 @@ export default async (user: ILocalUser, url: string, object: any) => {
req.end(data);
});
};
/**
* Get AP object with http-signature
* @param user http-signature user
* @param url URL to fetch
*/
export async function signedGet(url: string, user: ILocalUser) {
const timeout = 10 * 1000;
const keypair = await UserKeypairs.findOne({
userId: user.id
}).then(ensure);
const req = got.get<any>(url, {
headers: {
'Accept': 'application/activity+json, application/ld+json',
'User-Agent': config.userAgent,
},
responseType: 'json',
timeout,
hooks: {
beforeRequest: [
options => {
options.request = (url: URL, opt: http.RequestOptions, callback?: (response: any) => void) => {
// Select custom agent by URL
opt.agent = getAgentByUrl(url, false);
// Wrap original https?.request
const requestFunc = url.protocol === 'http:' ? http.request : https.request;
const clientRequest = requestFunc(url, opt, callback) as http.ClientRequest;
// HTTP-Signature
sign(clientRequest, {
authorizationHeaderName: 'Signature',
key: keypair.privateKey,
keyId: `${config.url}/users/${user.id}#main-key`,
headers: ['(request-target)', 'host', 'date', 'accept']
});
return clientRequest;
};
},
],
},
retry: 0,
});
const res = await receiveResponce(req, 10 * 1024 * 1024);
return res.body;
}
/**
* Receive response (with size limit)
* @param req Request
* @param maxSize size limit
*/
export async function receiveResponce<T>(req: Got.CancelableRequest<Got.Response<T>>, maxSize: number) {
// 応答ヘッダでサイズチェック
req.on('response', (res: Got.Response) => {
const contentLength = res.headers['content-length'];
if (contentLength != null) {
const size = Number(contentLength);
if (size > maxSize) {
req.cancel();
}
}
});
// 受信中のデータでサイズチェック
req.on('downloadProgress', (progress: Got.Progress) => {
if (progress.transferred > maxSize) {
req.cancel();
}
});
// 応答取得 with ステータスコードエラーの整形
const res = await req.catch(e => {
if (e.name === 'HTTPError') {
const statusCode = (e as Got.HTTPError).response.statusCode;
const statusMessage = (e as Got.HTTPError).response.statusMessage;
throw {
name: `StatusError`,
statusCode,
message: `${statusCode} ${statusMessage}`,
};
} else {
throw e;
}
});
return res;
}

View file

@ -1,8 +1,13 @@
import config from '../../config';
import { getJson } from '../../misc/fetch';
import { ILocalUser } from '../../models/entities/user';
import { getInstanceActor } from '../../services/instance-actor';
import { signedGet } from './request';
import { IObject, isCollectionOrOrderedCollection, ICollection, IOrderedCollection } from './type';
export default class Resolver {
private history: Set<string>;
private user?: ILocalUser;
constructor() {
this.history = new Set();
@ -39,7 +44,13 @@ export default class Resolver {
this.history.add(value);
const object = await getJson(value, 'application/activity+json, application/ld+json');
if (config.signToActivityPubGet && !this.user) {
this.user = await getInstanceActor();
}
const object = this.user
? await signedGet(value, this.user)
: await getJson(value, 'application/activity+json, application/ld+json');
if (object == null || (
Array.isArray(object['@context']) ?

View file

@ -0,0 +1,17 @@
import { createSystemUser } from './create-system-user';
import { ILocalUser } from '../models/entities/user';
import { Users } from '../models';
const ACTOR_USERNAME = 'instance.actor' as const;
export async function getInstanceActor(): Promise<ILocalUser> {
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;
}