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:
+3
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,32 +653,83 @@ 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.
|
||||
// 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;
|
||||
|
||||
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();
|
||||
|
||||
// Broadcast activation
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
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)
|
||||
.set({
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user