fix(federation/client): pin Step-CA root, fix lockfile, harden cache test
CRIT-1: regenerate pnpm-lock.yaml so apps/gateway resolves undici@7.24.6 (prior PR pushed package.json without lockfile update; CI failed with ERR_PNPM_OUTDATED_LOCKFILE). Incidentally cleans 57 lines of stale peer-dep entries. CRIT-2: cache-hit test no longer swallows resolveEntry errors. Calls the private method directly twice and asserts identity equality plus a single DB select, removing the silent-failure path the prior assertion allowed. HIGH-1: mTLS Agent now pins Step-CA root via STEP_CA_ROOT_CERT_PATH. Without the env var resolveEntry throws PEER_MISCONFIGURED, refusing to dial peers against the public trust store. PEM is read once and cached on the service instance. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -22,9 +22,12 @@
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
||||
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';
|
||||
import type { Dispatcher } from 'undici';
|
||||
import { writeFileSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
import { FederationClientService, FederationClientError } from '../federation-client.service.js';
|
||||
import { sealClientKey } from '../../peer-key.util.js';
|
||||
@@ -57,6 +60,15 @@ const TEST_CERT_SERIAL = 'ABCDEF1234567890';
|
||||
|
||||
let SEALED_KEY: string;
|
||||
|
||||
// Path to a stub Step-CA root cert file written in beforeAll. The cert is never
|
||||
// actually used to negotiate TLS in unit tests (MockAgent + spy on resolveEntry
|
||||
// short-circuit the network), but loadStepCaRoot() requires the file to exist.
|
||||
const STUB_CA_PEM_PATH = join(tmpdir(), 'federation-client-spec-ca.pem');
|
||||
const STUB_CA_PEM = `-----BEGIN CERTIFICATE-----
|
||||
MIIBdummyCAforFederationClientSpecOnly==
|
||||
-----END CERTIFICATE-----
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Peer row factory
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -167,17 +179,28 @@ beforeAll(() => {
|
||||
process.env['BETTER_AUTH_SECRET'] = saved;
|
||||
}
|
||||
}
|
||||
writeFileSync(STUB_CA_PEM_PATH, STUB_CA_PEM, 'utf8');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
unlinkSync(STUB_CA_PEM_PATH);
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
originalDispatcher = getGlobalDispatcher();
|
||||
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
|
||||
process.env['STEP_CA_ROOT_CERT_PATH'] = STUB_CA_PEM_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setGlobalDispatcher(originalDispatcher);
|
||||
vi.restoreAllMocks();
|
||||
delete process.env['BETTER_AUTH_SECRET'];
|
||||
delete process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -401,34 +424,22 @@ describe('FederationClientService', () => {
|
||||
|
||||
describe('cache behaviour', () => {
|
||||
it('hits cache on second call — only one DB lookup happens', async () => {
|
||||
// Verify cache by calling the private resolveEntry directly twice and
|
||||
// asserting the DB was queried only once. This avoids the HTTP layer,
|
||||
// which would require either a real network or per-peer Agent rewiring
|
||||
// that the cache invariant doesn't depend on.
|
||||
const db = makeDb();
|
||||
const { mockAgent, pool } = makeMockAgent();
|
||||
|
||||
// Use real resolveEntry — let it build cache from DB
|
||||
const svc = new FederationClientService(db);
|
||||
|
||||
// First capabilities call — will build + cache entry
|
||||
pool
|
||||
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
|
||||
.reply(200, CAP_BODY, { headers: { 'content-type': 'application/json' } })
|
||||
.times(2); // allow two HTTP calls (one per call below)
|
||||
|
||||
// Spy on the private resolveEntry to count DB calls via the DB select spy
|
||||
const selectSpy = vi.spyOn(db, 'select');
|
||||
const svc = new FederationClientService(db);
|
||||
const resolveEntry = (
|
||||
svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> }
|
||||
).resolveEntry.bind(svc);
|
||||
|
||||
// First call
|
||||
await svc.capabilities(PEER_ID).catch(() => {
|
||||
// May fail with PEER_MISCONFIGURED if key unseal fails in test — that's OK
|
||||
// for this specific test which only cares about select spy count
|
||||
});
|
||||
const first = await resolveEntry(PEER_ID);
|
||||
const second = await resolveEntry(PEER_ID);
|
||||
|
||||
// Second call — should use cache
|
||||
await svc.capabilities(PEER_ID).catch(() => {});
|
||||
|
||||
// DB select should have been called at most once (cache hit on second call)
|
||||
expect(first).toBe(second);
|
||||
expect(selectSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await mockAgent.close();
|
||||
});
|
||||
|
||||
it('flushPeer() invalidates cache — next call re-reads DB', async () => {
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
*/
|
||||
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import type { Dispatcher } from 'undici';
|
||||
import { z } from 'zod';
|
||||
@@ -123,6 +124,14 @@ export class FederationClientService {
|
||||
*/
|
||||
private readonly cache = new Map<string, AgentCacheEntry>();
|
||||
|
||||
/**
|
||||
* Step-CA root cert PEM, loaded once from `STEP_CA_ROOT_CERT_PATH`.
|
||||
* Used as the trust anchor for peer server certificates so federation TLS is
|
||||
* pinned to our PKI, not the public trust store. Lazily loaded on first use
|
||||
* so unit tests that don't exercise the agent path can run without the env var.
|
||||
*/
|
||||
private cachedCaPem: string | null = null;
|
||||
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -219,6 +228,38 @@ export class FederationClientService {
|
||||
// Internal helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load and cache the Step-CA root cert PEM from `STEP_CA_ROOT_CERT_PATH`.
|
||||
* Throws `FederationClientError` if the env var is unset or the file cannot
|
||||
* be read — mTLS to a peer without a pinned trust anchor would silently
|
||||
* fall back to the public trust store.
|
||||
*/
|
||||
private loadStepCaRoot(): string {
|
||||
if (this.cachedCaPem !== null) {
|
||||
return this.cachedCaPem;
|
||||
}
|
||||
const path = process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||
if (!path) {
|
||||
throw new FederationClientError({
|
||||
code: 'PEER_MISCONFIGURED',
|
||||
message: 'STEP_CA_ROOT_CERT_PATH is not set; refusing to dial peer without pinned CA trust',
|
||||
peerId: '',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const pem = readFileSync(path, 'utf8');
|
||||
this.cachedCaPem = pem;
|
||||
return pem;
|
||||
} catch (err) {
|
||||
throw new FederationClientError({
|
||||
code: 'PEER_MISCONFIGURED',
|
||||
message: `Failed to read STEP_CA_ROOT_CERT_PATH (${path})`,
|
||||
peerId: '',
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the cache entry for a peer, reading DB on miss.
|
||||
*
|
||||
@@ -275,11 +316,14 @@ export class FederationClientService {
|
||||
});
|
||||
}
|
||||
|
||||
// Build mTLS agent
|
||||
// Build mTLS agent — pin trust to Step-CA root so we never accept
|
||||
// a peer cert signed by a public CA (defense against MITM with a
|
||||
// publicly-trusted DV cert for the peer's hostname).
|
||||
const agent = new Agent({
|
||||
connect: {
|
||||
cert: peer.certPem,
|
||||
key: privateKeyPem,
|
||||
ca: this.loadStepCaRoot(),
|
||||
// rejectUnauthorized: true is the undici default for HTTPS
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user