/** * 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 { 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 { // 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); } }