Six S2S-HMAC endpoints repeated the same inbound-auth preamble verbatim
(parse federation headers -> resolve active peer -> optional per-peer rate
limit -> verify HMAC signature -> nonce replay protection). Extract it into
authenticateS2SPeer() so the trust boundary has a single, tested definition.
Adopters (preamble only; every post-auth side effect, body validation, and
response is unchanged):
- DELETE /api/federation/identity (no rate limiter; warns on missing nonce)
- POST /api/federation/relay (relay limiter; warns; keeps in-handler
epoch-baseline populate + nonce ratchet)
- POST /api/federation/sync (no limiter; warns with the [sync] tag;
keeps in-handler nonce ratchet)
- POST /api/federation/users/lookup (lookup limiter, Retry-After 60)
- POST /api/federation/users/by-home-id (same)
- POST /api/federation/verify-attach-proof(shares lookup bucket, Retry-After 60)
Deliberate non-adopters, each keeping a load-bearing gate the helper would
flatten (documented at each site + in the helper docstring):
- POST /api/federation/epoch gates status != 'revoked' (peer recovery),
400 on missing headers, no nonce check
- POST /api/federation/peer/rotate active-only but no nonce check
- POST /api/federation/peer/denied awaiting_approval gate (404/409), synthetic
no-grace secret verify
Behavior-preserving. The rate limiter is injected (plain { limited, retryAfter }),
so the limit still fires BEFORE signature verification. The only ordering change:
/relay's opportunistic epoch-baseline populate now runs just after the shared
preamble (i.e. after the nonce check) instead of between signature and nonce.
This is provably equivalent for every reachable honest-peer state (a duplicate
nonce means the baseline is already non-null; a valid-signature-but-no-nonce
request from a nonce-supporting peer is unreachable in transit and carries no
security/correctness consequence) and the populate is documented as not
affecting relay accept/reject.
Adds a dedicated unit test covering the full decision table (headers, peer
status, rate-limit + Retry-After, rate-limit-before-signature ordering,
signature, nonce duplicate/missing, log flag + context suffix, success). Full
server suite green (804 tests).
141 lines
5.3 KiB
TypeScript
141 lines
5.3 KiB
TypeScript
import { getDb, schema } from '../../../db/index.js';
|
|
import { and, eq, isNull, or } from 'drizzle-orm';
|
|
import type { FastifyInstance } from 'fastify';
|
|
import { isLookupRateLimited } from '../rateLimits.js';
|
|
import { authenticateS2SPeer } from './s2sAuth.js';
|
|
|
|
export function registerLookupRoutes(app: FastifyInstance): void {
|
|
// ─── POST /api/federation/users/lookup ─────────────────────────────────────
|
|
// Server-to-server: resolve a username on this instance to its canonical
|
|
// (homeUserId, profile snapshot). Used by another instance to construct a
|
|
// friend_request_create event without requiring a federated user account.
|
|
//
|
|
// Returns 200 with profile snapshot for native users (regardless of the
|
|
// user's `discoverable` setting — exact-handle resolution).
|
|
// Returns 404 for tombstoned users, replicated stubs, or unknown usernames.
|
|
app.post<{ Body: { username?: unknown } }>(
|
|
'/api/federation/users/lookup',
|
|
{ bodyLimit: 4 * 1024 },
|
|
async (request, reply) => {
|
|
const db = getDb();
|
|
|
|
// Shared inbound S2S-auth preamble: headers → active peer → rate limit →
|
|
// signature → nonce replay. The per-peer lookup rate limiter (60/min) runs
|
|
// BEFORE signature verification and sends `Retry-After: 60`. This endpoint
|
|
// never logged on a missing nonce (logMissingNonce omitted).
|
|
const auth = authenticateS2SPeer(request, reply, {
|
|
rateLimiter: { limited: isLookupRateLimited, retryAfterSeconds: 60 },
|
|
});
|
|
if (!auth.ok) return;
|
|
|
|
// 2. Validate body
|
|
const rawUsername = (request.body as { username?: unknown } | null)?.username;
|
|
if (typeof rawUsername !== 'string') {
|
|
return reply.code(400).send({ error: 'username is required (string)', statusCode: 400 });
|
|
}
|
|
const username = rawUsername.trim().toLowerCase();
|
|
if (!username) {
|
|
return reply.code(400).send({ error: 'username is required', statusCode: 400 });
|
|
}
|
|
|
|
// 3. Native-only lookup with isDeleted filter; discoverable is NOT consulted.
|
|
const user = db
|
|
.select()
|
|
.from(schema.users)
|
|
.where(
|
|
and(
|
|
eq(schema.users.username, username),
|
|
eq(schema.users.isDeleted, 0),
|
|
isNull(schema.users.homeInstance),
|
|
),
|
|
)
|
|
.get();
|
|
|
|
if (!user) {
|
|
return reply.code(404).send({ found: false, code: 'user_not_found' });
|
|
}
|
|
|
|
return reply.code(200).send({
|
|
found: true,
|
|
user: {
|
|
homeUserId: user.id,
|
|
username: user.username,
|
|
profile: {
|
|
displayName: user.displayName,
|
|
avatar: user.avatar,
|
|
avatarColor: user.avatarColor,
|
|
banner: user.banner,
|
|
bio: user.bio,
|
|
status: user.status as 'online' | 'idle' | 'dnd' | 'offline' | null,
|
|
},
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
// ─── POST /api/federation/users/by-home-id ──────────────────────────────────
|
|
// Server-to-server: reverse-lookup a homeUserId to its canonical username +
|
|
// profile snapshot. Used by the stub-username backfill worker on peers that
|
|
// hold legacy snowflake-named replicas of users now visible by their real
|
|
// handle. Same auth+rate-limit shape as /users/lookup.
|
|
app.post<{ Body: { homeUserId?: unknown } }>(
|
|
'/api/federation/users/by-home-id',
|
|
{ bodyLimit: 4 * 1024 },
|
|
async (request, reply) => {
|
|
const db = getDb();
|
|
|
|
// Shared inbound S2S-auth preamble: headers → active peer → rate limit →
|
|
// signature → nonce replay. Same shape as /users/lookup: per-peer lookup
|
|
// rate limiter (60/min) BEFORE signature, `Retry-After: 60`, no
|
|
// missing-nonce warning.
|
|
const auth = authenticateS2SPeer(request, reply, {
|
|
rateLimiter: { limited: isLookupRateLimited, retryAfterSeconds: 60 },
|
|
});
|
|
if (!auth.ok) return;
|
|
|
|
// 2. Validate body
|
|
const rawId = (request.body as { homeUserId?: unknown } | null)?.homeUserId;
|
|
if (typeof rawId !== 'string' || rawId.trim().length === 0) {
|
|
return reply.code(400).send({ error: 'homeUserId is required (string)', statusCode: 400 });
|
|
}
|
|
const homeUserId = rawId.trim();
|
|
|
|
// 3. Native-only lookup. Match by id (canonical native id) OR home_user_id
|
|
// (backfilled column natives carry to satisfy tier-1 lookups). Excludes
|
|
// tombstoned and replicated stubs.
|
|
const user = db
|
|
.select()
|
|
.from(schema.users)
|
|
.where(
|
|
and(
|
|
eq(schema.users.isDeleted, 0),
|
|
isNull(schema.users.homeInstance),
|
|
or(eq(schema.users.id, homeUserId), eq(schema.users.homeUserId, homeUserId)),
|
|
),
|
|
)
|
|
.get();
|
|
|
|
if (!user) {
|
|
return reply.code(200).send({ found: false });
|
|
}
|
|
|
|
return reply.code(200).send({
|
|
found: true,
|
|
user: {
|
|
homeUserId: user.homeUserId ?? user.id,
|
|
username: user.username,
|
|
profile: {
|
|
displayName: user.displayName,
|
|
avatar: user.avatar,
|
|
avatarColor: user.avatarColor,
|
|
status: user.status as 'online' | 'idle' | 'dnd' | 'offline' | null,
|
|
banner: user.banner,
|
|
bio: user.bio,
|
|
},
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
}
|