refactor(federation): ♻️ Correctly handle bridge requests and instance signatures in user inboxes

This commit is contained in:
Jesse Wierzbinski 2024-11-23 23:02:18 +01:00
parent afc5a74a40
commit ace6921447
No known key found for this signature in database
8 changed files with 2310 additions and 36 deletions

View file

@ -2,7 +2,7 @@ import { apiRoute, applyConfig } from "@/api";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
import type { Entity } from "@versia/federation/types"; import type { Entity } from "@versia/federation/types";
import { User } from "@versia/kit/db"; import { Instance, User } from "@versia/kit/db";
import { z } from "zod"; import { z } from "zod";
import { InboxProcessor } from "~/classes/inbox/processor"; import { InboxProcessor } from "~/classes/inbox/processor";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
@ -23,9 +23,9 @@ export const schemas = {
uuid: z.string().uuid(), uuid: z.string().uuid(),
}), }),
header: z.object({ header: z.object({
"x-signature": z.string(), "x-signature": z.string().optional(),
"x-nonce": z.string(), "x-nonce": z.string().optional(),
"x-signed-by": z.string().url().or(z.literal("instance")), "x-signed-by": z.string().url().or(z.string().startsWith("instance ")),
authorization: z.string().optional(), authorization: z.string().optional(),
}), }),
body: z.any(), body: z.any(),
@ -113,24 +113,37 @@ export default apiRoute((app) =>
const sender = await User.resolve(signedBy); const sender = await User.resolve(signedBy);
if (!sender) { if (!(sender || signedBy.startsWith("instance "))) {
return context.json( return context.json(
{ error: `Couldn't resolve sender ${signedBy}` }, { error: `Couldn't resolve sender URI ${signedBy}` },
404, 404,
); );
} }
if (sender?.isLocal()) { if (sender?.isLocal()) {
return context.json( return context.json(
{ error: "Cannot send federation requests to local users" }, {
error: "Cannot process federation requests from local users",
},
400, 400,
); );
} }
const remoteInstance = sender
? await Instance.fromUser(sender)
: await Instance.resolveFromHost(signedBy.split(" ")[1]);
if (!remoteInstance) {
return context.json(
{ error: "Could not resolve the remote instance." },
500,
);
}
const processor = new InboxProcessor( const processor = new InboxProcessor(
context, context,
body, body,
sender, remoteInstance,
{ {
signature, signature,
nonce, nonce,

View file

@ -319,6 +319,18 @@ export class Instance extends BaseInterface<typeof Instances> {
} }
} }
public static resolveFromHost(host: string): Promise<Instance> {
if (host.startsWith("http")) {
const url = new URL(host).host;
return Instance.resolve(url);
}
const url = new URL(`https://${host}`);
return Instance.resolve(url.origin);
}
public static async resolve(url: string): Promise<Instance> { public static async resolve(url: string): Promise<Instance> {
const logger = getLogger("federation"); const logger = getLogger("federation");
const host = new URL(url).host; const host = new URL(url).host;
@ -346,6 +358,7 @@ export class Instance extends BaseInterface<typeof Instances> {
version: metadata.software.version, version: metadata.software.version,
logo: metadata.logo, logo: metadata.logo,
protocol, protocol,
publicKey: metadata.public_key,
}); });
} }

View file

@ -1,7 +1,13 @@
import { beforeEach, describe, expect, jest, mock, test } from "bun:test"; import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
import { SignatureValidator } from "@versia/federation"; import { SignatureValidator } from "@versia/federation";
import type { Entity, Note as VersiaNote } from "@versia/federation/types"; import type { Entity, Note as VersiaNote } from "@versia/federation/types";
import { Note, Notification, Relationship, User } from "@versia/kit/db"; import {
type Instance,
Note,
Notification,
Relationship,
User,
} from "@versia/kit/db";
import type { Context } from "hono"; import type { Context } from "hono";
import { ValidationError } from "zod-validation-error"; import { ValidationError } from "zod-validation-error";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/packages/config-manager/index.ts";
@ -71,7 +77,7 @@ mock.module("~/packages/config-manager/index.ts", () => ({
describe("InboxProcessor", () => { describe("InboxProcessor", () => {
let mockContext: Context; let mockContext: Context;
let mockBody: Entity; let mockBody: Entity;
let mockSender: User; let mockSenderInstance: Instance;
let mockHeaders: { let mockHeaders: {
signature: string; signature: string;
nonce: string; nonce: string;
@ -98,12 +104,14 @@ describe("InboxProcessor", () => {
} as unknown as Context; } as unknown as Context;
// Setup basic mock sender // Setup basic mock sender
mockSender = { mockSenderInstance = {
id: "test-id", id: "test-id",
data: { data: {
publicKey: "test-key", publicKey: {
key: "test-key",
}, },
} as unknown as User; },
} as unknown as Instance;
// Setup basic mock headers // Setup basic mock headers
mockHeaders = { mockHeaders = {
@ -118,7 +126,7 @@ describe("InboxProcessor", () => {
processor = new InboxProcessor( processor = new InboxProcessor(
mockContext, mockContext,
mockBody, mockBody,
mockSender, mockSenderInstance,
mockHeaders, mockHeaders,
); );
}); });

View file

@ -16,7 +16,7 @@ import type {
User as VersiaUser, User as VersiaUser,
} from "@versia/federation/types"; } from "@versia/federation/types";
import { import {
Instance, type Instance,
Like, Like,
Note, Note,
Notification, Notification,
@ -70,7 +70,7 @@ export class InboxProcessor {
* *
* @param context Hono request context. * @param context Hono request context.
* @param body Entity JSON body. * @param body Entity JSON body.
* @param sender Sender of the request (from X-Signed-By header). * @param senderInstance Sender of the request's instance (from X-Signed-By header).
* @param headers Various request headers. * @param headers Various request headers.
* @param logger LogTape logger instance. * @param logger LogTape logger instance.
* @param requestIp Request IP address. Grabs it from the Hono context if not provided. * @param requestIp Request IP address. Grabs it from the Hono context if not provided.
@ -78,10 +78,10 @@ export class InboxProcessor {
public constructor( public constructor(
private context: Context, private context: Context,
private body: Entity, private body: Entity,
private sender: User, private senderInstance: Instance,
private headers: { private headers: {
signature: string; signature?: string;
nonce: string; nonce?: string;
authorization?: string; authorization?: string;
}, },
private logger: Logger = getLogger(["federation", "inbox"]), private logger: Logger = getLogger(["federation", "inbox"]),
@ -94,14 +94,25 @@ export class InboxProcessor {
* @returns {Promise<boolean>} - Whether the signature is valid. * @returns {Promise<boolean>} - Whether the signature is valid.
*/ */
private async isSignatureValid(): Promise<boolean> { private async isSignatureValid(): Promise<boolean> {
if (!this.senderInstance.data.publicKey?.key) {
throw new Error(
`Instance ${this.senderInstance.data.baseUrl} has no public key stored in database`,
);
}
if (config.debug.federation) { if (config.debug.federation) {
this.logger.debug`Sender public key: ${this.sender.data.publicKey}`; this.logger
.debug`Sender public key: ${this.senderInstance.data.publicKey.key}`;
} }
const validator = await SignatureValidator.fromStringKey( const validator = await SignatureValidator.fromStringKey(
this.sender.data.publicKey, this.senderInstance.data.publicKey.key,
); );
if (!(this.headers.signature && this.headers.nonce)) {
throw new Error("Missing signature or nonce");
}
// HACK: Making a fake Request object instead of passing the values directly is necessary because otherwise the validation breaks for some unknown reason // HACK: Making a fake Request object instead of passing the values directly is necessary because otherwise the validation breaks for some unknown reason
const isValid = await validator.validate( const isValid = await validator.validate(
new Request(this.context.req.url, { new Request(this.context.req.url, {
@ -185,16 +196,7 @@ export class InboxProcessor {
public async process(): Promise< public async process(): Promise<
(Response & TypedResponse<{ error: string }, 500, "json">) | Response (Response & TypedResponse<{ error: string }, 500, "json">) | Response
> { > {
const remoteInstance = await Instance.fromUser(this.sender); if (isDefederated(this.senderInstance.data.baseUrl)) {
if (!remoteInstance) {
return this.context.json(
{ error: "Could not resolve the remote instance." },
500,
);
}
if (isDefederated(remoteInstance.data.baseUrl)) {
// Return 201 to avoid // Return 201 to avoid
// 1. Leaking defederated instance information // 1. Leaking defederated instance information
// 2. Preventing the sender from thinking the message was not delivered and retrying // 2. Preventing the sender from thinking the message was not delivered and retrying
@ -439,11 +441,15 @@ export class InboxProcessor {
const delete_ = this.body as unknown as VersiaDelete; const delete_ = this.body as unknown as VersiaDelete;
const toDelete = delete_.deleted; const toDelete = delete_.deleted;
const author = delete_.author
? await User.resolve(delete_.author)
: null;
switch (delete_.deleted_type) { switch (delete_.deleted_type) {
case "Note": { case "Note": {
const note = await Note.fromSql( const note = await Note.fromSql(
eq(Notes.uri, toDelete), eq(Notes.uri, toDelete),
eq(Notes.authorId, this.sender.id), author ? eq(Notes.authorId, author.id) : undefined,
); );
if (!note) { if (!note) {
@ -468,8 +474,8 @@ export class InboxProcessor {
); );
} }
if (userToDelete.id === this.sender.id) { if (!author || userToDelete.id === author.id) {
await this.sender.delete(); await userToDelete.delete();
return this.context.text( return this.context.text(
"Account deleted, goodbye 👋", "Account deleted, goodbye 👋",
200, 200,
@ -486,7 +492,7 @@ export class InboxProcessor {
case "pub.versia:likes/Like": { case "pub.versia:likes/Like": {
const like = await Like.fromSql( const like = await Like.fromSql(
eq(Likes.uri, toDelete), eq(Likes.uri, toDelete),
eq(Likes.likerId, this.sender.id), author ? eq(Likes.likerId, author.id) : undefined,
); );
if (!like) { if (!like) {

View file

@ -0,0 +1 @@
ALTER TABLE "Instances" ADD COLUMN "public_key" jsonb;

File diff suppressed because it is too large Load diff

View file

@ -246,6 +246,13 @@
"when": 1729789587213, "when": 1729789587213,
"tag": "0034_jittery_proemial_gods", "tag": "0034_jittery_proemial_gods",
"breakpoints": true "breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1732398961365,
"tag": "0035_pretty_whiplash",
"breakpoints": true
} }
] ]
} }

View file

@ -1,5 +1,5 @@
import type { Source as ApiSource } from "@versia/client/types"; import type { Source as ApiSource } from "@versia/client/types";
import type { ContentFormat } from "@versia/federation/types"; import type { ContentFormat, InstanceMetadata } from "@versia/federation/types";
import type { Challenge } from "altcha-lib/types"; import type { Challenge } from "altcha-lib/types";
import { relations, sql } from "drizzle-orm"; import { relations, sql } from "drizzle-orm";
import { import {
@ -351,6 +351,7 @@ export const Instances = pgTable("Instances", {
.notNull() .notNull()
.$type<"versia" | "activitypub">() .$type<"versia" | "activitypub">()
.default("versia"), .default("versia"),
publicKey: jsonb("public_key").$type<InstanceMetadata["public_key"]>(),
}); });
export const OpenIdAccounts = pgTable("OpenIdAccounts", { export const OpenIdAccounts = pgTable("OpenIdAccounts", {