2023-07-27 14:31:52 +09:00
|
|
|
/*
|
2024-02-14 00:59:27 +09:00
|
|
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
2023-07-27 14:31:52 +09:00
|
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
*/
|
|
|
|
|
2021-10-16 17:16:24 +09:00
|
|
|
import * as assert from 'assert';
|
2024-07-20 21:33:20 +09:00
|
|
|
import httpSignature from '@peertube/http-signature';
|
|
|
|
|
|
|
|
import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
|
|
|
|
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
|
2025-02-23 19:21:34 +09:00
|
|
|
import { assertActivityMatchesUrls, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
|
|
|
|
import { IObject } from '@/core/activitypub/type.js';
|
2021-10-16 17:16:24 +09:00
|
|
|
|
|
|
|
export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => {
|
|
|
|
return {
|
|
|
|
scheme: 'Signature',
|
|
|
|
params: {
|
|
|
|
keyId: 'KeyID', // dummy, not used for verify
|
|
|
|
algorithm: algorithm,
|
2023-02-24 16:10:48 +09:00
|
|
|
headers: ['(request-target)', 'date', 'host', 'digest'], // dummy, not used for verify
|
2021-10-16 17:16:24 +09:00
|
|
|
signature: signature,
|
|
|
|
},
|
|
|
|
signingString: signingString,
|
2022-05-21 22:21:41 +09:00
|
|
|
algorithm: algorithm.toUpperCase(),
|
2021-10-16 17:16:24 +09:00
|
|
|
keyId: 'KeyID', // dummy, not used for verify
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
2025-02-23 19:21:34 +09:00
|
|
|
function cartesianProduct<T, U>(a: T[], b: U[]): [T, U][] {
|
|
|
|
return a.flatMap(a => b.map(b => [a, b] as [T, U]));
|
|
|
|
}
|
|
|
|
|
2024-07-20 21:33:20 +09:00
|
|
|
describe('ap-request', () => {
|
|
|
|
test('createSignedPost with verify', async () => {
|
|
|
|
const keypair = await genRsaKeyPair();
|
|
|
|
const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey };
|
|
|
|
const url = 'https://example.com/inbox';
|
|
|
|
const activity = { a: 1 };
|
|
|
|
const body = JSON.stringify(activity);
|
|
|
|
const headers = {
|
|
|
|
'User-Agent': 'UA',
|
|
|
|
};
|
2021-10-16 17:16:24 +09:00
|
|
|
|
2024-07-20 21:33:20 +09:00
|
|
|
const req = ApRequestCreator.createSignedPost({ key, url, body, additionalHeaders: headers });
|
2021-10-16 17:16:24 +09:00
|
|
|
|
2024-07-20 21:33:20 +09:00
|
|
|
const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256');
|
2021-10-16 17:16:24 +09:00
|
|
|
|
2024-07-20 21:33:20 +09:00
|
|
|
const result = httpSignature.verifySignature(parsed, keypair.publicKey);
|
|
|
|
assert.deepStrictEqual(result, true);
|
2024-07-18 01:28:17 +09:00
|
|
|
});
|
2021-10-16 17:16:24 +09:00
|
|
|
|
2024-07-20 21:33:20 +09:00
|
|
|
test('createSignedGet with verify', async () => {
|
|
|
|
const keypair = await genRsaKeyPair();
|
|
|
|
const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey };
|
|
|
|
const url = 'https://example.com/outbox';
|
|
|
|
const headers = {
|
|
|
|
'User-Agent': 'UA',
|
|
|
|
};
|
|
|
|
|
|
|
|
const req = ApRequestCreator.createSignedGet({ key, url, additionalHeaders: headers });
|
2021-10-16 17:16:24 +09:00
|
|
|
|
2024-07-20 21:33:20 +09:00
|
|
|
const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256');
|
2021-10-16 17:16:24 +09:00
|
|
|
|
2024-07-20 21:33:20 +09:00
|
|
|
const result = httpSignature.verifySignature(parsed, keypair.publicKey);
|
|
|
|
assert.deepStrictEqual(result, true);
|
2021-10-16 17:16:24 +09:00
|
|
|
});
|
2025-02-23 19:21:34 +09:00
|
|
|
|
|
|
|
test('rejects non matching domain', () => {
|
|
|
|
assert.doesNotThrow(() => assertActivityMatchesUrls(
|
|
|
|
'https://alice.example.com/abc',
|
|
|
|
{ id: 'https://alice.example.com/abc' } as IObject,
|
|
|
|
[
|
|
|
|
'https://alice.example.com/abc',
|
|
|
|
],
|
|
|
|
FetchAllowSoftFailMask.Strict,
|
|
|
|
), 'validation should pass base case');
|
|
|
|
assert.throws(() => assertActivityMatchesUrls(
|
|
|
|
'https://alice.example.com/abc',
|
|
|
|
{ id: 'https://bob.example.com/abc' } as IObject,
|
|
|
|
[
|
|
|
|
'https://alice.example.com/abc',
|
|
|
|
],
|
|
|
|
FetchAllowSoftFailMask.Any,
|
|
|
|
), 'validation should fail no matter what if the response URL is inconsistent with the object ID');
|
|
|
|
|
|
|
|
// fix issues like threads
|
|
|
|
// https://github.com/misskey-dev/misskey/issues/15039
|
|
|
|
const withOrWithoutWWW = [
|
|
|
|
'https://alice.example.com/abc',
|
|
|
|
'https://www.alice.example.com/abc',
|
|
|
|
];
|
|
|
|
|
|
|
|
cartesianProduct(
|
|
|
|
cartesianProduct(
|
|
|
|
withOrWithoutWWW,
|
|
|
|
withOrWithoutWWW,
|
|
|
|
),
|
|
|
|
withOrWithoutWWW,
|
|
|
|
).forEach(([[a, b], c]) => {
|
|
|
|
assert.doesNotThrow(() => assertActivityMatchesUrls(
|
|
|
|
a,
|
|
|
|
{ id: b } as IObject,
|
|
|
|
[
|
|
|
|
c,
|
|
|
|
],
|
|
|
|
FetchAllowSoftFailMask.Strict,
|
|
|
|
), 'validation should pass with or without www. subdomain');
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
test('cross origin lookup', () => {
|
|
|
|
assert.doesNotThrow(() => assertActivityMatchesUrls(
|
|
|
|
'https://alice.example.com/abc',
|
|
|
|
{ id: 'https://bob.example.com/abc' } as IObject,
|
|
|
|
[
|
|
|
|
'https://bob.example.com/abc',
|
|
|
|
],
|
|
|
|
FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId,
|
|
|
|
), 'validation should pass if the response is otherwise consistent and cross-origin is allowed');
|
|
|
|
assert.throws(() => assertActivityMatchesUrls(
|
|
|
|
'https://alice.example.com/abc',
|
|
|
|
{ id: 'https://bob.example.com/abc' } as IObject,
|
|
|
|
[
|
|
|
|
'https://bob.example.com/abc',
|
|
|
|
],
|
|
|
|
FetchAllowSoftFailMask.Strict,
|
|
|
|
), 'validation should fail if the response is otherwise consistent and cross-origin is not allowed');
|
|
|
|
});
|
|
|
|
|
|
|
|
test('rejects non-canonical ID', () => {
|
|
|
|
assert.throws(() => assertActivityMatchesUrls(
|
|
|
|
'https://alice.example.com/@alice',
|
|
|
|
{ id: 'https://alice.example.com/users/alice' } as IObject,
|
|
|
|
[
|
|
|
|
'https://alice.example.com/users/alice'
|
|
|
|
],
|
|
|
|
FetchAllowSoftFailMask.Strict,
|
|
|
|
), 'throws if the response ID did not exactly match the expected ID');
|
|
|
|
assert.doesNotThrow(() => assertActivityMatchesUrls(
|
|
|
|
'https://alice.example.com/@alice',
|
|
|
|
{ id: 'https://alice.example.com/users/alice' } as IObject,
|
|
|
|
[
|
|
|
|
'https://alice.example.com/users/alice',
|
|
|
|
],
|
|
|
|
FetchAllowSoftFailMask.NonCanonicalId,
|
|
|
|
), 'does not throw if non-canonical ID is allowed');
|
|
|
|
});
|
|
|
|
|
|
|
|
test('origin relaxed alignment', () => {
|
|
|
|
assert.doesNotThrow(() => assertActivityMatchesUrls(
|
|
|
|
'https://alice.example.com/abc',
|
|
|
|
{ id: 'https://ap.alice.example.com/abc' } as IObject,
|
|
|
|
[
|
|
|
|
'https://ap.alice.example.com/abc',
|
|
|
|
],
|
|
|
|
FetchAllowSoftFailMask.MisalignedOrigin | FetchAllowSoftFailMask.NonCanonicalId,
|
|
|
|
), 'validation should pass if response is a subdomain of the expected origin');
|
|
|
|
assert.throws(() => assertActivityMatchesUrls(
|
|
|
|
'https://alice.multi-tenant.example.com/abc',
|
|
|
|
{ id: 'https://alice.multi-tenant.example.com/abc' } as IObject,
|
|
|
|
[
|
|
|
|
'https://bob.multi-tenant.example.com/abc',
|
|
|
|
],
|
|
|
|
FetchAllowSoftFailMask.MisalignedOrigin | FetchAllowSoftFailMask.NonCanonicalId,
|
|
|
|
), 'validation should fail if response is a disjoint domain of the expected origin');
|
|
|
|
assert.throws(() => assertActivityMatchesUrls(
|
|
|
|
'https://alice.example.com/abc',
|
|
|
|
{ id: 'https://ap.alice.example.com/abc' } as IObject,
|
|
|
|
[
|
|
|
|
'https://ap.alice.example.com/abc',
|
|
|
|
],
|
|
|
|
FetchAllowSoftFailMask.Strict,
|
|
|
|
), 'throws if relaxed origin is forbidden');
|
|
|
|
});
|
|
|
|
|
|
|
|
test('resist HTTP downgrade', () => {
|
|
|
|
assert.throws(() => assertActivityMatchesUrls(
|
|
|
|
'https://alice.example.com/abc',
|
|
|
|
{ id: 'https://alice.example.com/abc' } as IObject,
|
|
|
|
[
|
|
|
|
'http://alice.example.com/abc',
|
|
|
|
],
|
|
|
|
FetchAllowSoftFailMask.Strict,
|
|
|
|
), 'throws if HTTP downgrade is detected');
|
|
|
|
});
|
2021-10-16 17:16:24 +09:00
|
|
|
});
|