Merge branch 'feat/peer-approval-token'

Closes the receiver-side trust-bypass class in /peer/accept's
awaiting_approval branch via single-use cryptographic approval tokens.
Builds on the cheap fix (4533e36) that landed earlier today.

Schema: nullable approval_token columns on federation_peers and
peer_approval_requests. Wire format: optional approvalToken field on
/peer/accept request body and 202 response body. Receiver verifies
token before promoting awaiting_approval → active. /approve forwards
the stored token in its outbound /peer/accept; performHandshake,
/peer/initiate, and /approve all capture the returned token from 202.

Backward compat: legacy peers (no token) fall through the existing
autoAccept gate — autoAccept=0 queues, autoAccept=1 promotes (no
regression vs prior behavior). Existing active peers untouched.

Live verification on nova + orbit (autoAccept=0 scenario):
- Bypass attempt without token → 202 (queued), peer row untouched.
- Positive case with matching token → 200, secret rotated, token cleared,
  stale approval-request deleted.

Test counts: 289 → 307 (+18 across 4 new test files).
Spec: internal notes
Plan: internal notes
This commit is contained in:
Jannis Braun
2026-04-26 12:12:43 +02:00
14 changed files with 4399 additions and 81 deletions
+3 -1
View File
@@ -217,7 +217,7 @@ DELETE /admin/users/:id → { success }
## Federation (`routes/federation.ts`)
```
POST /federation/peer/initiate (admin) { remoteOrigin } → peer created
POST /federation/peer/accept (public, IP rate-limited 10/min) { sourceOrigin, challenge, hmacSecret } → accepted
POST /federation/peer/accept (public, IP rate-limited 10/min) { sourceOrigin, challenge, hmacSecret, instanceName?, approvalToken? } → accepted (200) | queued (202 + { approvalToken })
GET /federation/peers (admin) → { peers[] } (no secrets)
DELETE /federation/peers/:id (admin) → { success } + outbox cleanup
POST /federation/relay (HMAC-signed S2S) FederationRelayRequest → { accepted[], rejected[] }
@@ -225,6 +225,8 @@ POST /federation/sync (HMAC-signed S2S) { sinceTimestamp, limit?,
POST /federation/users/lookup (HMAC-signed S2S, rate-limited 60/min/peer) { username } → { found, user? }
```
**`POST /api/federation/peer/accept`** — public, IP-rate-limited. Optional `approvalToken` (64-hex) on the request body proves mutual admin approval; required to promote an `awaiting_approval` row to `active` when the receiver has `autoAcceptPeering=0`. The receiver returns it in the 202 body when queueing the request for admin review (`{ queued: true, message, approvalToken }`); the initiator stores it and the receiver's `/approve` later forwards it back. See `federation.md` §1 "Approval Token Verification" for the full lifecycle and threat model.
**`POST /api/federation/users/lookup`** — HMAC-authenticated S2S endpoint. Resolves a username on this instance to its canonical `(homeUserId, profile snapshot)`. Used by the cross-instance friend-add flow on the sender's home server before queuing a `friend_request_create` event. Responds to native, non-deleted users only; ignores `discoverable`. Returns `{ found: false, code: 'user_not_found' }` for stubs, tombstoned users, or unknown handles. See `federation.md` §1 "S2S User Lookup" for the full contract.
## Utilities (`routes/utils.ts`) — auth required
+2
View File
@@ -363,6 +363,7 @@ PK: (spaceId, userId, restrictionType)
| lastSyncedAt | integer | 0 | |
| remoteMaxUploadSize | integer | | Bytes, from peer |
| createdAt | integer NOT NULL | | |
| approvalToken | text | | Single-use 64-hex-char token stored when this row is in `awaiting_approval` (received from remote's 202 response). Verified against the inbound `/peer/accept` `approvalToken` field before promoting to `active`. Cleared (`NULL`) on promotion. See [federation.md → Approval Token Verification](federation.md#approval-token-verification). |
### peer_approval_requests
Holds incoming peering requests queued for admin review when `autoAcceptPeering` is `false`. One row per requesting origin (UNIQUE constraint). Rows expire after 30 days via janitor cleanup.
@@ -375,6 +376,7 @@ Holds incoming peering requests queued for admin review when `autoAcceptPeering`
| hmacSecret | text NOT NULL | | Requester's HMAC secret; used to sign denial notification |
| requestedAt | integer NOT NULL | | Epoch ms |
| expiresAt | integer NOT NULL | | Epoch ms; requestedAt + 30 days |
| approvalToken | text | | Single-use 64-hex-char token issued in the 202 response when this row is created. Forwarded by `/approve` in its outbound `/peer/accept` so the remote initiator can verify mutual admin approval. Deleted along with this row when `/approve` runs. See [federation.md → Approval Token Verification](federation.md#approval-token-verification). |
### federation_outbox
UNIQUE: (peerId, entityId)
+22 -1
View File
@@ -143,6 +143,7 @@ When `autoAcceptPeering` is `false` and an instance calls `POST /api/federation/
- `instance_name` — Instance name sent by requester
- `hmac_secret` — Requester's HMAC secret; used to sign the denial notification
- `requested_at` / `expires_at` — Epoch ms; expiry is `requested_at + 30 days`
- `approval_token` — Single-use 64-hex-char random token issued in the 202 response and forwarded by `/approve`. See [Approval Token Verification](#approval-token-verification).
**Approval flow** — Admin approves via `POST /api/federation/approval-requests/:id/approve`:
1. A fresh `federationPeer` record is created (or existing `rejected`/`awaiting_approval` record is upserted) with status `pending`
@@ -157,7 +158,27 @@ When `autoAcceptPeering` is `false` and an instance calls `POST /api/federation/
**Expiry** — The janitor (`federationJanitor.ts`) runs on its scheduled interval and deletes rows where `expires_at < now`. Expired requests do NOT create a `rejected` peer — the requesting instance can re-submit. Admin denial, by contrast, does create a `rejected` peer record, blocking re-requests until an admin clears it.
**Pre-handshake guard (`ensurePeered`)** — Before any outbound handshake, `ensurePeered(origin)` in `federationPeering.ts` refuses with `{ status: 'rejected', error: 'Local admin must resolve…' }` if a `peer_approval_requests` row exists for that origin. This blocks the auto-reconnect bypass: without the guard, any code path calling `ensurePeered` (e.g., the silent reconnect in `stores/instanceStore.ts`) could initiate a fresh outbound handshake to a peer that has a pending inbound approval request, and the receiver's `/peer/accept` `awaiting_approval` branch would activate the relationship without admin involvement. The legitimate approve flow (`POST /api/federation/approval-requests/:id/approve`) does NOT call `ensurePeered` — it deletes the approval-request row and does its own direct `fetch` to `/peer/accept` — so the guard does not block legitimate approvals. Note: the guard closes the trigger only; the receiver-side trust assumption in the `/peer/accept` `awaiting_approval` branch is still permissive and is tracked separately at `docs/superpowers/problems/2026-04-26-peer-handshake-trust-model.md`.
**Pre-handshake guard (`ensurePeered`)** — Before any outbound handshake, `ensurePeered(origin)` in `federationPeering.ts` refuses with `{ status: 'rejected', error: 'Local admin must resolve…' }` if a `peer_approval_requests` row exists for that origin. This blocks the auto-reconnect trigger: without the guard, any code path calling `ensurePeered` (e.g., the silent reconnect in `stores/instanceStore.ts`) could initiate a fresh outbound handshake to a peer that has a pending inbound approval request. The legitimate approve flow (`POST /api/federation/approval-requests/:id/approve`) does NOT call `ensurePeered` — it deletes the approval-request row and does its own direct `fetch` to `/peer/accept` — so the guard does not block legitimate approvals.
### Approval Token Verification
The pre-handshake guard above closes the most reliable trigger but cannot prevent every adversarial code path on a remote side from sending an inbound `/peer/accept` against a row in `awaiting_approval`. To close that broader class, the protocol adds a cryptographic single-use **approval token** verified on the receiver before promoting `awaiting_approval → active`. Spec: `docs/superpowers/specs/2026-04-26-peer-approval-token.md`.
**Issuance.** When `/peer/accept` returns 202 (queue-as-approval-request, `autoAcceptPeering=0`), the server generates a 32-byte random hex token via `crypto.randomBytes(32).toString('hex')`, stores it on the new `peer_approval_requests.approval_token` column, and returns it in the 202 body as `{ queued: true, message, approvalToken }`.
**Storage on the initiator.** When the initiator's outbound `/peer/accept` (from `performHandshake`, `/peer/initiate`, or `/approve`) receives 202, it parses `approvalToken` from the response body and stores it on the local `federation_peers.approval_token` column alongside `status='awaiting_approval'`.
**Forwarding from `/approve`.** When the local admin approves an inbound queued request, the `/approve` endpoint reads `approvalToken` from the queued `peer_approval_requests` row and includes it in its outbound `/peer/accept` body. No other code path forwards a token — the security property is "only `/approve` reads the stored token from the DB."
**Verification on the initiator's `/peer/accept` handler.** When an inbound request matches an existing `awaiting_approval` peer row, the handler verifies `existing.approval_token === request.body.approvalToken` (length-checked, plain `===` — see spec §3.1 on why constant-time comparison isn't required). On match, the row is promoted to `active`, the stored token is cleared (`approval_token=NULL`), and any stale `peer_approval_requests` row for the origin is deleted. On mismatch (or missing token), behavior depends on the receiver's `autoAcceptPeering`:
- `autoAcceptPeering=0``queueApprovalRequest()` is invoked: a new `peer_approval_requests` row is upserted with a fresh token, returns 202. The existing `awaiting_approval` row is left untouched. **No bypass.**
- `autoAcceptPeering=1` → fallback promote to `active` (no security regression vs. prior behavior — `autoAccept=1` would accept any inbound `/peer/accept` regardless).
**Single-use lifecycle.** The token is consumed on first successful match: cleared from `federation_peers.approval_token` at the receiver's promote step, cleared from the initiator's `federation_peers.approval_token` when its own outbound returns 200, and deleted along with the approval-request row when `/approve` completes. This bounds replay of a leaked token (DB snapshot, log capture) to the period before the legitimate `/approve` runs.
**Backward compatibility.** Both schema columns are nullable. Older peers that don't include `approvalToken` in the request body or 202 response result in `null` storage; the receiver's verification then falls through the `autoAcceptPeering` gate. Existing `active` peers and existing `awaiting_approval` rows in production at upgrade time are unaffected — the verification only runs on the receiver's `awaiting_approval` branch. Stalled legacy `awaiting_approval` rows (no stored token) cannot complete via inbound `/peer/accept` from a legacy initiator unless the receiver is `autoAccept=1`; admins should re-initiate them through the standard flow if needed.
**What this does NOT defend against** — a remote operator running custom code with full DB access can read their stored token and forge `/peer/accept`. That is the inherent trust radius of federation peering. The threat model is bug-prone code paths (auto-reconnect, voice-call peering races, future `ensurePeered` callers) on otherwise-honest peers, not adversarial operators. Sender-side outbound gating for `autoAcceptPeering=0` is a separate concern tracked as a follow-up.
### Admin Endpoints
@@ -0,0 +1,2 @@
ALTER TABLE `federation_peers` ADD `approval_token` text;--> statement-breakpoint
ALTER TABLE `peer_approval_requests` ADD `approval_token` text;
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,13 @@
"when": 1777144927946,
"tag": "0001_complex_screwball",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1777196627239,
"tag": "0002_peer_approval_token",
"breakpoints": true
}
]
}
+2
View File
@@ -374,6 +374,7 @@ export const federationPeers = sqliteTable('federation_peers', {
secretRotatedAt: integer('secret_rotated_at'),
autoRotateIntervalDays: integer('auto_rotate_interval_days').notNull().default(90),
createdAt: integer('created_at').notNull(),
approvalToken: text('approval_token'),
});
export const peerApprovalRequests = sqliteTable('peer_approval_requests', {
@@ -383,6 +384,7 @@ export const peerApprovalRequests = sqliteTable('peer_approval_requests', {
hmacSecret: text('hmac_secret').notNull(),
requestedAt: integer('requested_at').notNull(),
expiresAt: integer('expires_at').notNull(),
approvalToken: text('approval_token'),
});
export const federationOutbox = sqliteTable('federation_outbox', {
@@ -0,0 +1,239 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('../config.js', () => ({
config: {
domain: 'local.example',
port: 3000,
host: '0.0.0.0',
jwtSecret: 'test-secret-12345678901234567890123456789012',
maxUploadSize: 100 * 1024 * 1024,
registrationOpen: true,
},
}));
vi.mock('../utils/auth.js', () => ({
authenticate: async (req: { userId?: string }) => {
req.userId = 'admin-user';
},
requireAdmin: async () => {},
}));
vi.mock('../utils/federationAuth.js', async () => {
const actual = await vi.importActual<typeof import('../utils/federationAuth.js')>('../utils/federationAuth.js');
return {
...actual,
getOurOrigin: () => 'https://local.example',
generateHmacSecret: () => 'mock-generated-secret',
};
});
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToAdmins: vi.fn(),
getAllOnlineUserIds: () => [],
sendToUser: vi.fn(),
sendToDmMembers: vi.fn(),
},
}));
vi.mock('../utils/federationPeerActivation.js', () => ({
onPeerActivated: vi.fn(async () => undefined),
onPeerDeactivated: vi.fn(async () => undefined),
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedInstanceSettings(): void {
testDb.insert(schema.instanceSettings).values({
id: 1,
instanceName: 'Local Backspace',
autoAcceptPeering: 0,
registrationOpen: 1,
updatedAt: Date.now(),
}).run();
}
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
const { federationRoutes } = await import('./federation.js');
await app.register(federationRoutes);
await app.ready();
return app;
}
describe('POST /api/federation/approval-requests/:id/approve — outbound token forwarding & 202 capture', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedInstanceSettings();
app = await buildApp();
});
afterEach(() => {
vi.restoreAllMocks();
sqlite.close();
});
it('forwards approvalToken from peer_approval_requests in the outbound body', async () => {
const now = Date.now();
const token = 'a'.repeat(64);
testDb.insert(schema.peerApprovalRequests).values({
id: 'req-1',
origin: 'https://remote.example',
instanceName: 'Remote',
hmacSecret: 'remote-secret',
requestedAt: now,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
approvalToken: token,
}).run();
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ accepted: true, instanceName: 'Remote' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
const response = await app.inject({
method: 'POST',
url: '/api/federation/approval-requests/req-1/approve',
});
expect(response.statusCode).toBe(200);
expect(fetchSpy).toHaveBeenCalledTimes(1);
const init = fetchSpy.mock.calls[0]?.[1];
const body = JSON.parse(init?.body as string) as { approvalToken?: string };
expect(body.approvalToken).toBe(token);
});
it('omits approvalToken from outbound body when approval-request has null token (legacy)', async () => {
const now = Date.now();
testDb.insert(schema.peerApprovalRequests).values({
id: 'req-2',
origin: 'https://remote.example',
instanceName: 'Remote',
hmacSecret: 'remote-secret',
requestedAt: now,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
approvalToken: null,
}).run();
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ accepted: true, instanceName: 'Remote' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
const response = await app.inject({
method: 'POST',
url: '/api/federation/approval-requests/req-2/approve',
});
expect(response.statusCode).toBe(200);
const init = fetchSpy.mock.calls[0]?.[1];
const body = JSON.parse(init?.body as string) as { approvalToken?: string };
expect(body.approvalToken).toBeUndefined();
});
it('on 200 success, clears approvalToken on the new federation_peers row', async () => {
const now = Date.now();
testDb.insert(schema.peerApprovalRequests).values({
id: 'req-200',
origin: 'https://remote.example',
instanceName: 'Remote',
hmacSecret: 'remote-secret',
requestedAt: now,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
approvalToken: 'a'.repeat(64),
}).run();
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ accepted: true, instanceName: 'Remote' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
const response = await app.inject({
method: 'POST',
url: '/api/federation/approval-requests/req-200/approve',
});
expect(response.statusCode).toBe(200);
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('active');
expect(peer?.approvalToken).toBeNull();
});
it('on 202 from remote, transitions local peer to awaiting_approval and stores returned approvalToken', async () => {
const now = Date.now();
const inboundToken = 'a'.repeat(64);
testDb.insert(schema.peerApprovalRequests).values({
id: 'req-3',
origin: 'https://remote.example',
instanceName: 'Remote',
hmacSecret: 'remote-secret',
requestedAt: now,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
approvalToken: inboundToken,
}).run();
const remoteToken = 'b'.repeat(64);
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ queued: true, message: 'queued', approvalToken: remoteToken }),
{ status: 202, headers: { 'Content-Type': 'application/json' } },
),
);
const response = await app.inject({
method: 'POST',
url: '/api/federation/approval-requests/req-3/approve',
});
expect(response.statusCode).toBe(200);
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('awaiting_approval');
expect(peer?.approvalToken).toBe(remoteToken);
});
});
@@ -0,0 +1,345 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('../utils/auth.js', () => ({
authenticate: async (req: { userId?: string }) => {
req.userId = 'admin-user';
},
requireAdmin: async () => {},
}));
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToAdmins: vi.fn(),
getAllOnlineUserIds: () => [],
sendToUser: vi.fn(),
sendToDmMembers: vi.fn(),
},
}));
vi.mock('../utils/federationPeerActivation.js', () => ({
onPeerActivated: vi.fn(async () => undefined),
onPeerDeactivated: vi.fn(async () => undefined),
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedInstanceSettings(autoAccept: 0 | 1): void {
testDb.insert(schema.instanceSettings).values({
id: 1,
instanceName: 'Local Backspace',
autoAcceptPeering: autoAccept,
registrationOpen: 1,
updatedAt: Date.now(),
}).run();
}
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
const { federationRoutes } = await import('./federation.js');
await app.register(federationRoutes);
await app.ready();
return app;
}
describe('POST /api/federation/peer/accept — approval token verification', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
});
async function setupAutoAccept(value: 0 | 1): Promise<void> {
seedInstanceSettings(value);
app = await buildApp();
}
it('queueing path stores token on peer_approval_requests and returns it in the 202 body (autoAccept=0, no existing peer)', async () => {
await setupAutoAccept(0);
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/accept',
payload: {
sourceOrigin: 'https://remote.example',
hmacSecret: 'remote-secret',
instanceName: 'Remote',
},
});
expect(response.statusCode).toBe(202);
const body = response.json() as { queued: boolean; approvalToken?: string };
expect(body.queued).toBe(true);
expect(typeof body.approvalToken).toBe('string');
expect(body.approvalToken).toMatch(/^[0-9a-f]{64}$/);
const row = testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get();
expect(row?.approvalToken).toBe(body.approvalToken);
});
it('regenerates token on re-handshake (existing approval-request row gets new token)', async () => {
await setupAutoAccept(0);
const now = Date.now();
const oldToken = 'old-token-' + 'a'.repeat(53);
testDb.insert(schema.peerApprovalRequests).values({
id: 'approval-old',
origin: 'https://remote.example',
instanceName: 'Remote',
hmacSecret: 'old-secret',
requestedAt: now - 1000,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
approvalToken: oldToken,
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/accept',
payload: {
sourceOrigin: 'https://remote.example',
hmacSecret: 'fresh-secret',
instanceName: 'Remote',
},
});
expect(response.statusCode).toBe(202);
const body = response.json() as { queued: boolean; approvalToken?: string };
expect(body.approvalToken).toMatch(/^[0-9a-f]{64}$/);
expect(body.approvalToken).not.toBe(oldToken);
const row = testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get();
expect(row?.approvalToken).toBe(body.approvalToken);
expect(row?.hmacSecret).toBe('fresh-secret');
});
it('promotes awaiting_approval → active when token matches (autoAccept=0)', async () => {
await setupAutoAccept(0);
const token = 'a'.repeat(64);
testDb.insert(schema.federationPeers).values({
id: 'peer-pending',
origin: 'https://remote.example',
hmacSecret: 'old-secret',
status: 'awaiting_approval',
approvalToken: token,
createdAt: Date.now(),
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/accept',
payload: {
sourceOrigin: 'https://remote.example',
hmacSecret: 'fresh-secret',
instanceName: 'Remote',
approvalToken: token,
},
});
expect(response.statusCode).toBe(200);
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('active');
expect(peer?.hmacSecret).toBe('fresh-secret');
expect(peer?.approvalToken).toBeNull();
});
it('on successful match, deletes any stale approval-request row for the same origin', async () => {
await setupAutoAccept(0);
const token = 'b'.repeat(64);
const now = Date.now();
testDb.insert(schema.federationPeers).values({
id: 'peer-pending',
origin: 'https://remote.example',
hmacSecret: 'old-secret',
status: 'awaiting_approval',
approvalToken: token,
createdAt: now,
}).run();
// Stale approval-request from a prior bypass attempt (e.g., bug-prone code path).
testDb.insert(schema.peerApprovalRequests).values({
id: 'stale-request',
origin: 'https://remote.example',
instanceName: 'Remote',
hmacSecret: 'bypass-secret',
requestedAt: now,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
approvalToken: 'stale-token',
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/accept',
payload: {
sourceOrigin: 'https://remote.example',
hmacSecret: 'fresh-secret',
approvalToken: token,
},
});
expect(response.statusCode).toBe(200);
const stale = testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get();
expect(stale).toBeUndefined();
});
it('autoAccept=0 + missing token → does NOT promote, queues new approval-request', async () => {
await setupAutoAccept(0);
const token = 'c'.repeat(64);
testDb.insert(schema.federationPeers).values({
id: 'peer-pending',
origin: 'https://remote.example',
hmacSecret: 'old-secret',
status: 'awaiting_approval',
approvalToken: token,
createdAt: Date.now(),
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/accept',
payload: {
sourceOrigin: 'https://remote.example',
hmacSecret: 'bypass-secret',
// no approvalToken
},
});
expect(response.statusCode).toBe(202);
const body = response.json() as { queued: boolean; approvalToken?: string };
expect(body.queued).toBe(true);
expect(typeof body.approvalToken).toBe('string');
// Existing awaiting_approval peer row UNCHANGED.
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('awaiting_approval');
expect(peer?.hmacSecret).toBe('old-secret');
expect(peer?.approvalToken).toBe(token);
// New approval-request row exists with a fresh token (different from the existing peer's).
const req = testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get();
expect(req?.approvalToken).toBe(body.approvalToken);
expect(req?.approvalToken).not.toBe(token);
});
it('autoAccept=0 + mismatched token → behaves the same as missing token (queues)', async () => {
await setupAutoAccept(0);
testDb.insert(schema.federationPeers).values({
id: 'peer-pending',
origin: 'https://remote.example',
hmacSecret: 'old-secret',
status: 'awaiting_approval',
approvalToken: 'd'.repeat(64),
createdAt: Date.now(),
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/accept',
payload: {
sourceOrigin: 'https://remote.example',
hmacSecret: 'bypass-secret',
approvalToken: 'e'.repeat(64),
},
});
expect(response.statusCode).toBe(202);
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('awaiting_approval');
});
it('autoAccept=0 + null stored token (legacy) + no inbound token → queues (does not promote)', async () => {
await setupAutoAccept(0);
testDb.insert(schema.federationPeers).values({
id: 'peer-legacy',
origin: 'https://remote.example',
hmacSecret: 'old-secret',
status: 'awaiting_approval',
approvalToken: null,
createdAt: Date.now(),
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/accept',
payload: {
sourceOrigin: 'https://remote.example',
hmacSecret: 'fresh-secret',
},
});
expect(response.statusCode).toBe(202);
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('awaiting_approval');
});
it('autoAccept=1 + missing/mismatched token → fallback promotes (no security regression)', async () => {
await setupAutoAccept(1);
testDb.insert(schema.federationPeers).values({
id: 'peer-pending',
origin: 'https://remote.example',
hmacSecret: 'old-secret',
status: 'awaiting_approval',
approvalToken: 'f'.repeat(64),
createdAt: Date.now(),
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/accept',
payload: {
sourceOrigin: 'https://remote.example',
hmacSecret: 'fresh-secret',
// no approvalToken
},
});
expect(response.statusCode).toBe(200);
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('active');
expect(peer?.hmacSecret).toBe('fresh-secret');
expect(peer?.approvalToken).toBeNull();
});
});
@@ -0,0 +1,156 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('../config.js', () => ({
config: {
domain: 'local.example',
port: 3000,
host: '0.0.0.0',
jwtSecret: 'test-secret-12345678901234567890123456789012',
maxUploadSize: 100 * 1024 * 1024,
registrationOpen: true,
},
}));
vi.mock('../utils/auth.js', () => ({
authenticate: async (req: { userId?: string }) => {
req.userId = 'admin-user';
},
requireAdmin: async () => {},
}));
vi.mock('../utils/federationAuth.js', async () => {
const actual = await vi.importActual<typeof import('../utils/federationAuth.js')>('../utils/federationAuth.js');
return {
...actual,
getOurOrigin: () => 'https://local.example',
generateHmacSecret: () => 'mock-generated-secret',
};
});
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToAdmins: vi.fn(),
getAllOnlineUserIds: () => [],
sendToUser: vi.fn(),
sendToDmMembers: vi.fn(),
},
}));
vi.mock('../utils/federationPeerActivation.js', () => ({
onPeerActivated: vi.fn(async () => undefined),
onPeerDeactivated: vi.fn(async () => undefined),
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedInstanceSettings(): void {
testDb.insert(schema.instanceSettings).values({
id: 1,
instanceName: 'Local Backspace',
autoAcceptPeering: 0,
registrationOpen: 1,
updatedAt: Date.now(),
}).run();
}
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
const { federationRoutes } = await import('./federation.js');
await app.register(federationRoutes);
await app.ready();
return app;
}
describe('POST /api/federation/peer/initiate — 202 token capture & 200 clear', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedInstanceSettings();
app = await buildApp();
});
afterEach(() => {
vi.restoreAllMocks();
sqlite.close();
});
it('on 202 from remote, transitions local peer to awaiting_approval and stores returned approvalToken', async () => {
const remoteToken = 'c'.repeat(64);
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ queued: true, message: 'queued', approvalToken: remoteToken }),
{ status: 202, headers: { 'Content-Type': 'application/json' } },
),
);
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/initiate',
payload: { remoteOrigin: 'https://remote.example' },
});
expect(response.statusCode).toBe(202);
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('awaiting_approval');
expect(peer?.approvalToken).toBe(remoteToken);
});
it('on 200 from remote, activates and clears approvalToken', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ accepted: true, instanceName: 'Remote' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/initiate',
payload: { remoteOrigin: 'https://remote.example' },
});
expect(response.statusCode).toBe(200);
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('active');
expect(peer?.approvalToken).toBeNull();
});
});
+179 -76
View File
@@ -1,4 +1,4 @@
import type { FastifyInstance } from 'fastify';
import type { FastifyInstance, FastifyReply } from 'fastify';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
@@ -246,6 +246,77 @@ setInterval(() => {
}
}, ACCEPT_RATE_WINDOW_MS).unref();
/**
* Queue an inbound peer/accept request for local-admin approval.
*
* Called from `/peer/accept` when:
* (a) `autoAcceptPeering=0` and no `pending`/`awaiting_approval` peer row
* exists for the source origin (first-contact request from remote), OR
* (b) the receiver is in `awaiting_approval` for this origin but the
* inbound `/peer/accept` cannot be cryptographically verified
* (token absent or mismatched) — see spec §3.5.
*
* Generates a fresh single-use approval token, upserts the
* `peer_approval_requests` row, notifies admins, and returns 202 with the
* token in the body. The initiator stores the token alongside its
* `awaiting_approval` row so a future `/peer/accept` from this side's
* `/approve` endpoint can verify mutual admin approval.
*/
function queueApprovalRequest(
db: ReturnType<typeof getDb>,
reply: FastifyReply,
sourceOrigin: string,
hmacSecret: string,
reqInstanceName: string | null,
): FastifyReply {
const now = Date.now();
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const approvalToken = randomBytes(32).toString('hex');
const existingRequest = db
.select({ id: schema.peerApprovalRequests.id })
.from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
.get();
if (existingRequest) {
db.update(schema.peerApprovalRequests)
.set({
instanceName: reqInstanceName,
hmacSecret,
requestedAt: now,
expiresAt: now + THIRTY_DAYS_MS,
approvalToken,
})
.where(eq(schema.peerApprovalRequests.id, existingRequest.id))
.run();
} else {
db.insert(schema.peerApprovalRequests)
.values({
id: generateSnowflake(),
origin: sourceOrigin,
instanceName: reqInstanceName,
hmacSecret,
requestedAt: now,
expiresAt: now + THIRTY_DAYS_MS,
approvalToken,
})
.run();
}
connectionManager.sendToAdmins({
type: 'federation_approval_request_received' as const,
origin: sourceOrigin,
instanceName: reqInstanceName ?? undefined,
});
return reply.code(202).send({
queued: true,
message: 'Request queued for admin approval',
approvalToken,
});
}
export async function federationRoutes(app: FastifyInstance): Promise<void> {
// ─── POST /api/federation/peer/initiate ────────────────────────────────────
// Admin-only: start a peering handshake with a remote instance.
@@ -338,12 +409,20 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
// (autoAcceptPeering is off on their side). Do NOT activate the
// local peer — mirror the auto-peer flow in federationPeering.ts
// by transitioning the pending record to awaiting_approval.
// Without this branch the local peer would flip to `active`
// (because response.ok is true for 202) while the remote had us
// pending, producing a local-active / remote-pending split that
// only self-heals when the remote admin approves.
// Capture the approval token they returned so the next inbound
// /peer/accept (when their admin approves) can be verified. §3.7.
let returnedToken: string | null = null;
try {
const body = (await response.json()) as { approvalToken?: string };
if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) {
returnedToken = body.approvalToken;
}
} catch {
// Non-JSON / empty body — legacy peer.
}
db.update(schema.federationPeers)
.set({ status: 'awaiting_approval' })
.set({ status: 'awaiting_approval', approvalToken: returnedToken })
.where(eq(schema.federationPeers.id, peerId))
.run();
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
@@ -391,7 +470,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
}
db.update(schema.federationPeers)
.set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName })
.set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName, approvalToken: null })
.where(eq(schema.federationPeers.id, peerId))
.run();
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
@@ -432,7 +511,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
// ─── POST /api/federation/peer/accept ──────────────────────────────────────
// Server-to-server: accept a peering request from a remote instance.
// No JWT auth — this is first contact. Rate-limited by IP.
app.post<{ Body: { sourceOrigin: string; challenge?: string; hmacSecret: string; instanceName?: string } }>(
app.post<{ Body: { sourceOrigin: string; challenge?: string; hmacSecret: string; instanceName?: string; approvalToken?: string } }>(
'/api/federation/peer/accept',
async (request, reply) => {
const clientIp = request.ip;
@@ -443,7 +522,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
});
}
const { sourceOrigin: rawOrigin, hmacSecret, instanceName: reqInstanceName } = request.body ?? {};
const { sourceOrigin: rawOrigin, hmacSecret, instanceName: reqInstanceName, approvalToken: inboundToken } = request.body ?? {};
if (!rawOrigin || typeof rawOrigin !== 'string') {
return reply.code(400).send({ error: 'sourceOrigin is required', statusCode: 400 });
@@ -514,50 +593,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
});
}
// Queue for admin approval — upsert into peer_approval_requests
const now = Date.now();
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const existingRequest = db
.select({ id: schema.peerApprovalRequests.id })
.from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
.get();
if (existingRequest) {
db.update(schema.peerApprovalRequests)
.set({
instanceName: reqInstanceName ?? null,
hmacSecret,
requestedAt: now,
expiresAt: now + THIRTY_DAYS_MS,
})
.where(eq(schema.peerApprovalRequests.id, existingRequest.id))
.run();
} else {
db.insert(schema.peerApprovalRequests)
.values({
id: generateSnowflake(),
origin: sourceOrigin,
instanceName: reqInstanceName ?? null,
hmacSecret,
requestedAt: now,
expiresAt: now + THIRTY_DAYS_MS,
})
.run();
}
// Notify admin users that a new approval request arrived
connectionManager.sendToAdmins({
type: 'federation_approval_request_received' as const,
origin: sourceOrigin,
instanceName: reqInstanceName ?? undefined,
});
return reply.code(202).send({
queued: true,
message: 'Request queued for admin approval',
});
return queueApprovalRequest(db, reply, sourceOrigin, hmacSecret, reqInstanceName ?? null);
}
}
@@ -617,31 +653,82 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
}
if (existing.status === 'awaiting_approval') {
// Remote admin approved — this is a fresh handshake from them.
db.update(schema.federationPeers)
.set({
hmacSecret,
instanceName: reqInstanceName ?? null,
status: 'active',
lastSeenAt: Date.now(),
})
.where(eq(schema.federationPeers.id, existing.id))
.run();
// Spec §3.5: token verification gates the awaiting_approval → active
// promotion. Without proof the inbound came from the remote's
// /approve endpoint, an adversarial timing-knowledge attack or a
// bug-prone background code path could falsely flip this row to
// active. The token is single-use entropy issued in the 202 we
// returned when the remote's outbound /peer/accept first hit our
// queue — only their /approve endpoint forwards it.
const tokenValid =
typeof existing.approvalToken === 'string' &&
existing.approvalToken.length > 0 &&
existing.approvalToken === inboundToken;
// Broadcast activation
for (const uid of connectionManager.getAllOnlineUserIds()) {
connectionManager.sendToUser(uid, {
type: 'federation_peer_active' as const,
peerOrigin: sourceOrigin,
});
if (tokenValid) {
db.update(schema.federationPeers)
.set({
hmacSecret,
instanceName: reqInstanceName ?? null,
status: 'active',
lastSeenAt: Date.now(),
approvalToken: null,
})
.where(eq(schema.federationPeers.id, existing.id))
.run();
// Clean up any stale approval-request row for this origin (e.g.,
// queued debris from a prior bypass attempt that did not promote).
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
.run();
for (const uid of connectionManager.getAllOnlineUserIds()) {
connectionManager.sendToUser(uid, {
type: 'federation_peer_active' as const,
peerOrigin: sourceOrigin,
});
}
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
onPeerActivated(existing.id, 'accept_awaiting_approval').catch(err =>
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval) failed:', err)
);
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
}
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
onPeerActivated(existing.id, 'accept_awaiting_approval').catch(err =>
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval) failed:', err)
);
// Token absent or mismatched. Cannot prove mutual approval.
if (autoAccept === 1) {
// We accept any inbound anyway — promoting here is no weaker than
// accepting a fresh handshake from a new peer. Clear the stored
// token (moot now) and proceed.
db.update(schema.federationPeers)
.set({
hmacSecret,
instanceName: reqInstanceName ?? null,
status: 'active',
lastSeenAt: Date.now(),
approvalToken: null,
})
.where(eq(schema.federationPeers.id, existing.id))
.run();
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
for (const uid of connectionManager.getAllOnlineUserIds()) {
connectionManager.sendToUser(uid, {
type: 'federation_peer_active' as const,
peerOrigin: sourceOrigin,
});
}
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
onPeerActivated(existing.id, 'accept_awaiting_approval_fallback').catch(err =>
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval fallback) failed:', err)
);
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
}
// autoAccept=0 + unverifiable inbound → queue as new approval-request.
// Existing awaiting_approval row stays untouched; the new approval-
// request lets the local admin decide whether to honor this inbound.
return queueApprovalRequest(db, reply, sourceOrigin, hmacSecret, reqInstanceName ?? null);
}
// Pending — update with new secret and activate
db.update(schema.federationPeers)
@@ -1160,6 +1247,10 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
sourceOrigin: localOrigin,
hmacSecret,
instanceName,
// Forward the stored token (issued in our 202 response when the
// remote first sent /peer/accept). Lets the remote verify mutual
// admin approval. Spec §3.7.
...(approvalReq.approvalToken ? { approvalToken: approvalReq.approvalToken } : {}),
}),
signal: AbortSignal.timeout(10_000),
});
@@ -1167,8 +1258,20 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
if (response.status === 202) {
// Remote instance also has autoAcceptPeering off — they queued our request.
// Don't activate our peer. Set to awaiting_approval until their admin also approves.
// Capture the approval token they returned so the next inbound
// /peer/accept (when their admin approves) can be verified. §3.7.
let returnedToken: string | null = null;
try {
const body = (await response.json()) as { approvalToken?: string };
if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) {
returnedToken = body.approvalToken;
}
} catch {
// Non-JSON / empty body — legacy peer.
}
db.update(schema.federationPeers)
.set({ status: 'awaiting_approval' })
.set({ status: 'awaiting_approval', approvalToken: returnedToken })
.where(eq(schema.federationPeers.id, peerId))
.run();
// Delete the approval request since we already acted on it
@@ -1209,7 +1312,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
}
db.update(schema.federationPeers)
.set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName })
.set({ status: 'active', lastSeenAt: now, instanceName: remoteInstanceName, approvalToken: null })
.where(eq(schema.federationPeers.id, peerId))
.run();
@@ -9,6 +9,7 @@ export type PeerActivationReason =
| 'initiate_accepted'
| 'accept_rejected_override'
| 'accept_awaiting_approval'
| 'accept_awaiting_approval_fallback'
| 'accept_pending'
| 'accept_new'
| 'approval_handshake'
@@ -0,0 +1,172 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { setWorkerId } from './snowflake.js';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
schema,
}));
vi.mock('./federationAuth.js', async () => {
const actual = await vi.importActual<typeof import('./federationAuth.js')>('./federationAuth.js');
return {
...actual,
getOurOrigin: () => 'https://local.example',
generateHmacSecret: () => 'mock-hmac-secret',
};
});
vi.mock('../routes/federation.js', () => ({
validateOrigin: (raw: string) => {
try {
const url = new URL(raw);
return url.origin;
} catch {
return null;
}
},
}));
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToAdmins: vi.fn(),
sendToUser: vi.fn(),
getAllOnlineUserIds: () => [],
},
}));
vi.mock('./federationPeerActivation.js', () => ({
onPeerActivated: vi.fn(async () => undefined),
onPeerDeactivated: vi.fn(async () => undefined),
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedInstanceSettings(): void {
testDb.insert(schema.instanceSettings).values({
id: 1,
instanceName: 'Local Backspace',
autoAcceptPeering: 1,
registrationOpen: 1,
updatedAt: Date.now(),
}).run();
}
describe('performHandshake — approval token capture & clear', () => {
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedInstanceSettings();
const { _clearInFlightPeering } = await import('./federationPeering.js');
_clearInFlightPeering();
});
afterEach(() => {
vi.restoreAllMocks();
sqlite.close();
});
it('stores approvalToken on the peer row when remote returns 202 with token', async () => {
const fakeToken = 'a'.repeat(64);
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ queued: true, message: 'queued', approvalToken: fakeToken }),
{ status: 202, headers: { 'Content-Type': 'application/json' } },
),
);
const { ensurePeered } = await import('./federationPeering.js');
const result = await ensurePeered('https://remote.example');
expect(result.status).toBe('pending');
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('awaiting_approval');
expect(peer?.approvalToken).toBe(fakeToken);
});
it('stores null approvalToken when 202 omits the field (legacy receiver)', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ queued: true, message: 'queued' }),
{ status: 202, headers: { 'Content-Type': 'application/json' } },
),
);
const { ensurePeered } = await import('./federationPeering.js');
const result = await ensurePeered('https://legacy.example');
expect(result.status).toBe('pending');
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://legacy.example')).get();
expect(peer?.status).toBe('awaiting_approval');
expect(peer?.approvalToken).toBeNull();
});
it('handles non-JSON 202 body gracefully (token stays null)', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('', { status: 202 }),
);
const { ensurePeered } = await import('./federationPeering.js');
const result = await ensurePeered('https://empty.example');
expect(result.status).toBe('pending');
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://empty.example')).get();
expect(peer?.approvalToken).toBeNull();
});
it('clears approvalToken on 200 activation even if previously stored', async () => {
testDb.insert(schema.federationPeers).values({
id: 'peer-existing',
origin: 'https://remote.example',
hmacSecret: 'old-secret',
status: 'pending',
approvalToken: 'old-token',
createdAt: Date.now(),
}).run();
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({ accepted: true, instanceName: 'Remote' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
const { ensurePeered } = await import('./federationPeering.js');
const result = await ensurePeered('https://remote.example');
expect(result.status).toBe('active');
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('active');
expect(peer?.approvalToken).toBeNull();
});
});
+16 -3
View File
@@ -166,9 +166,22 @@ async function performHandshake(
});
if (response.status === 202) {
// Request queued for admin approval on the remote side
// Capture the approval token from the 202 body if present. Stored on
// our federation_peers row so a subsequent inbound /peer/accept (from
// the remote's /approve flow) can be cryptographically verified before
// we promote awaiting_approval → active. Spec §3.7.
let approvalToken: string | null = null;
try {
const body = (await response.json()) as { approvalToken?: string };
if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) {
approvalToken = body.approvalToken;
}
} catch {
// Non-JSON or empty body — legacy receiver, leave null.
}
db.update(schema.federationPeers)
.set({ status: 'awaiting_approval' })
.set({ status: 'awaiting_approval', approvalToken })
.where(eq(schema.federationPeers.id, peerId))
.run();
const { connectionManager } = await import('../ws/handler.js');
@@ -191,7 +204,7 @@ async function performHandshake(
}
db.update(schema.federationPeers)
.set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName })
.set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName, approvalToken: null })
.where(eq(schema.federationPeers.id, peerId))
.run();
const { connectionManager } = await import('../ws/handler.js');