Files
stack/apps/gateway/src/federation/enrollment.service.ts
jason.woltje fc1600b738
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline failed
fix(federation): security hardening — OID verification, atomic activation, audit on failure (#501)
2026-04-22 06:02:52 +00:00

282 lines
9.4 KiB
TypeScript

/**
* EnrollmentService — single-use enrollment token lifecycle (FED-M2-07).
*
* Responsibilities:
* 1. Generate time-limited single-use enrollment tokens (admin action).
* 2. Redeem a token: validate → atomically claim token → issue cert via
* CaService → transactionally activate grant + update peer + write audit.
*
* Replay protection: the token is claimed (UPDATE WHERE used_at IS NULL) BEFORE
* cert issuance. This prevents double cert minting on concurrent requests.
* If cert issuance fails after claim, the token is consumed and the grant
* stays pending — admin must create a new grant.
*/
import {
BadRequestException,
ConflictException,
GoneException,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import * as crypto from 'node:crypto';
// X509Certificate is available as a named export in Node.js ≥ 15.6
const { X509Certificate } = crypto;
import {
type Db,
and,
eq,
isNull,
sql,
federationEnrollmentTokens,
federationGrants,
federationPeers,
federationAuditLog,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import { CaService } from './ca.service.js';
import { GrantsService } from './grants.service.js';
import { FederationScopeError } from './scope-schema.js';
import type { CreateEnrollmentTokenDto } from './enrollment.dto.js';
export interface EnrollmentTokenResult {
token: string;
expiresAt: string;
}
export interface RedeemResult {
certPem: string;
certChainPem: string;
}
@Injectable()
export class EnrollmentService {
private readonly logger = new Logger(EnrollmentService.name);
constructor(
@Inject(DB) private readonly db: Db,
private readonly caService: CaService,
private readonly grantsService: GrantsService,
) {}
/**
* Generate a single-use enrollment token for an admin to distribute
* out-of-band to the remote peer operator.
*/
async createToken(dto: CreateEnrollmentTokenDto): Promise<EnrollmentTokenResult> {
const ttl = Math.min(dto.ttlSeconds, 900);
// MED-3: Verify the grantId ↔ peerId binding — prevents attacker from
// cross-wiring grants to attacker-controlled peers.
const [grant] = await this.db
.select({ peerId: federationGrants.peerId })
.from(federationGrants)
.where(eq(federationGrants.id, dto.grantId))
.limit(1);
if (!grant) {
throw new NotFoundException(`Grant ${dto.grantId} not found`);
}
if (grant.peerId !== dto.peerId) {
throw new BadRequestException(`peerId does not match the grant's registered peer`);
}
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + ttl * 1000);
await this.db.insert(federationEnrollmentTokens).values({
token,
grantId: dto.grantId,
peerId: dto.peerId,
expiresAt,
});
this.logger.log(
`Enrollment token created — grantId=${dto.grantId} peerId=${dto.peerId} expiresAt=${expiresAt.toISOString()}`,
);
return { token, expiresAt: expiresAt.toISOString() };
}
/**
* Redeem an enrollment token.
*
* Full flow:
* 1. Fetch token row — NotFoundException if not found
* 2. usedAt set → GoneException (already used)
* 3. expiresAt < now → GoneException (expired)
* 4. Load grant — verify status is 'pending'
* 5. Atomically claim token (UPDATE WHERE used_at IS NULL RETURNING token)
* — if no rows returned, concurrent request won → GoneException
* 6. Issue cert via CaService (network call, outside transaction)
* — if this fails, token is consumed; grant stays pending; admin must recreate
* 7. Transaction: activate grant + update peer record + write audit log
* 8. Return { certPem, certChainPem }
*/
async redeem(token: string, csrPem: string): Promise<RedeemResult> {
// HIGH-5: Track outcome so we can write a failure audit row on any error.
let outcome: 'allowed' | 'denied' = 'denied';
// row may be undefined if the token is not found — used defensively in catch.
let row: typeof federationEnrollmentTokens.$inferSelect | undefined;
try {
// 1. Fetch token row
const [fetchedRow] = await this.db
.select()
.from(federationEnrollmentTokens)
.where(eq(federationEnrollmentTokens.token, token))
.limit(1);
if (!fetchedRow) {
throw new NotFoundException('Enrollment token not found');
}
row = fetchedRow;
// 2. Already used?
if (row.usedAt !== null) {
throw new GoneException('Enrollment token has already been used');
}
// 3. Expired?
if (row.expiresAt < new Date()) {
throw new GoneException('Enrollment token has expired');
}
// 4. Load grant and verify it is still pending
let grant;
try {
grant = await this.grantsService.getGrant(row.grantId);
} catch (err) {
if (err instanceof FederationScopeError) {
throw new BadRequestException(err.message);
}
throw err;
}
if (grant.status !== 'pending') {
throw new GoneException(
`Grant ${row.grantId} is no longer pending (status: ${grant.status})`,
);
}
// 5. Atomically claim the token BEFORE cert issuance to prevent double-minting.
// WHERE used_at IS NULL ensures only one concurrent request wins.
// Using .returning() works on both node-postgres and PGlite without rowCount inspection.
const claimed = await this.db
.update(federationEnrollmentTokens)
.set({ usedAt: sql`NOW()` })
.where(
and(
eq(federationEnrollmentTokens.token, token),
isNull(federationEnrollmentTokens.usedAt),
),
)
.returning({ token: federationEnrollmentTokens.token });
if (claimed.length === 0) {
throw new GoneException('Enrollment token has already been used (concurrent request)');
}
// 6. Issue certificate via CaService (network call — outside any transaction).
// If this throws, the token is already consumed. The grant stays pending.
// Admin must revoke the grant and create a new one.
let issued;
try {
issued = await this.caService.issueCert({
csrPem,
grantId: row.grantId,
subjectUserId: grant.subjectUserId,
ttlSeconds: 300,
});
} catch (err) {
// HIGH-4: Log only the first 8 hex chars of the token for correlation — never log the full token.
this.logger.error(
`issueCert failed after token ${token.slice(0, 8)}... was claimed — grant ${row.grantId} is stranded pending`,
err instanceof Error ? err.stack : String(err),
);
if (err instanceof FederationScopeError) {
throw new BadRequestException((err as Error).message);
}
throw err;
}
// 7. Atomically activate grant, update peer record, and write audit log.
const certNotAfter = this.extractCertNotAfter(issued.certPem);
await this.db.transaction(async (tx) => {
// CRIT-2: Guard activation with WHERE status='pending' to prevent double-activation.
const [activated] = await tx
.update(federationGrants)
.set({ status: 'active' })
.where(and(eq(federationGrants.id, row!.grantId), eq(federationGrants.status, 'pending')))
.returning({ id: federationGrants.id });
if (!activated) {
throw new ConflictException(
`Grant ${row!.grantId} is no longer pending — cannot activate`,
);
}
// CRIT-2: Guard peer update with WHERE state='pending'.
await tx
.update(federationPeers)
.set({
certPem: issued.certPem,
certSerial: issued.serialNumber,
certNotAfter,
state: 'active',
})
.where(and(eq(federationPeers.id, row!.peerId), eq(federationPeers.state, 'pending')));
await tx.insert(federationAuditLog).values({
requestId: crypto.randomUUID(),
peerId: row!.peerId,
grantId: row!.grantId,
verb: 'enrollment',
resource: 'federation_grant',
statusCode: 200,
outcome: 'allowed',
});
});
this.logger.log(
`Enrollment complete — peerId=${row.peerId} grantId=${row.grantId} serial=${issued.serialNumber}`,
);
outcome = 'allowed';
// 8. Return cert material
return {
certPem: issued.certPem,
certChainPem: issued.certChainPem,
};
} catch (err) {
// HIGH-5: Best-effort audit write on failure — do not let this throw.
if (outcome === 'denied') {
await this.db
.insert(federationAuditLog)
.values({
requestId: crypto.randomUUID(),
peerId: row?.peerId ?? null,
grantId: row?.grantId ?? null,
verb: 'enrollment',
resource: 'federation_grant',
statusCode:
err instanceof GoneException ? 410 : err instanceof NotFoundException ? 404 : 500,
outcome: 'denied',
})
.catch(() => {});
}
throw err;
}
}
/**
* Extract the notAfter date from a PEM certificate.
* HIGH-2: No silent fallback — a cert that cannot be parsed should fail loud.
*/
private extractCertNotAfter(certPem: string): Date {
const cert = new X509Certificate(certPem);
return new Date(cert.validTo);
}
}