Files
stack/apps/gateway/src/federation/oid.util.ts
Jarvis 0af3e218a1
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
fix(federation/auth-guard): remediate CRIT-1/CRIT-2 + HIGH-1..4 review findings
- CRIT-1: Validate cert subjectUserId against grant.subjectUserId from DB;
  use authoritative DB value in FederationContext
- CRIT-2: Add @Inject(GrantsService) decorator (tsx/esbuild requirement)
- HIGH-1: Validate UTF8String TLV tag, length, and bounds in OID parser
- HIGH-2: Collapse all 403 wire messages to a generic string to prevent
  grant enumeration; keep internal logger detail
- HIGH-3: Assert federation wire envelope shape in all guard tests
- HIGH-4: Regression test for subjectUserId cert/DB mismatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 06:33:37 -05:00

147 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shared OID extraction helpers for Mosaic federation certificates.
*
* Custom OID registry (PRD §6, docs/federation/SETUP.md):
* 1.3.6.1.4.1.99999.1 — mosaic_grant_id
* 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id
*
* The encoding convention: each extension value is an OCTET STRING wrapping
* an ASN.1 UTF8String TLV:
* 0x0C (tag) + 1-byte length + UTF-8 bytes
*
* CaService encodes values this way via encodeUtf8String(), and this module
* decodes them with the corresponding `.slice(2)` to skip tag + length byte.
*
* This module is intentionally pure — no NestJS, no DB, no network I/O.
*/
import { X509Certificate } from '@peculiar/x509';
// ---------------------------------------------------------------------------
// OID constants
// ---------------------------------------------------------------------------
export const OID_MOSAIC_GRANT_ID = '1.3.6.1.4.1.99999.1';
export const OID_MOSAIC_SUBJECT_USER_ID = '1.3.6.1.4.1.99999.2';
// ---------------------------------------------------------------------------
// Extraction result types
// ---------------------------------------------------------------------------
export interface MosaicOids {
grantId: string;
subjectUserId: string;
}
export type OidExtractionResult =
| { ok: true; value: MosaicOids }
| {
ok: false;
error: 'MISSING_GRANT_ID' | 'MISSING_SUBJECT_USER_ID' | 'PARSE_ERROR';
detail?: string;
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const decoder = new TextDecoder();
/**
* Decode an extension value encoded as ASN.1 UTF8String TLV
* (tag 0x0C + 1-byte length + UTF-8 bytes).
* Validates tag, length byte, and buffer bounds before decoding.
* Throws a descriptive Error on malformed input; caller wraps in try/catch.
*/
function decodeUtf8StringTlv(value: ArrayBuffer): string {
const bytes = new Uint8Array(value);
// Need at least tag + length bytes
if (bytes.length < 2) {
throw new Error(`UTF8String TLV too short: expected at least 2 bytes, got ${bytes.length}`);
}
// Tag byte must be 0x0C (ASN.1 UTF8String)
if (bytes[0] !== 0x0c) {
throw new Error(
`UTF8String TLV tag mismatch: expected 0x0C, got 0x${bytes[0]!.toString(16).toUpperCase()}`,
);
}
// Only single-byte length form is supported (values 0127); long form not needed
// for OID strings of this length.
const declaredLength = bytes[1]!;
if (declaredLength > 127) {
throw new Error(
`UTF8String TLV uses long-form length (0x${declaredLength.toString(16).toUpperCase()}), which is not supported`,
);
}
// Declared length must match actual remaining bytes
if (declaredLength !== bytes.length - 2) {
throw new Error(
`UTF8String TLV length mismatch: declared ${declaredLength}, actual ${bytes.length - 2}`,
);
}
// Skip: tag (1 byte) + length (1 byte)
return decoder.decode(bytes.slice(2));
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Extract Mosaic custom OIDs (grantId, subjectUserId) from an X.509 certificate
* already parsed via @peculiar/x509.
*
* Returns `{ ok: true, value: MosaicOids }` on success, or
* `{ ok: false, error: <code>, detail? }` on any failure — never throws.
*/
export function extractMosaicOids(cert: X509Certificate): OidExtractionResult {
try {
const grantIdExt = cert.getExtension(OID_MOSAIC_GRANT_ID);
if (!grantIdExt) {
return { ok: false, error: 'MISSING_GRANT_ID' };
}
const subjectUserIdExt = cert.getExtension(OID_MOSAIC_SUBJECT_USER_ID);
if (!subjectUserIdExt) {
return { ok: false, error: 'MISSING_SUBJECT_USER_ID' };
}
const grantId = decodeUtf8StringTlv(grantIdExt.value);
const subjectUserId = decodeUtf8StringTlv(subjectUserIdExt.value);
return {
ok: true,
value: { grantId, subjectUserId },
};
} catch (err) {
return {
ok: false,
error: 'PARSE_ERROR',
detail: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Parse a PEM-encoded certificate and extract Mosaic OIDs.
* Returns an OidExtractionResult — never throws.
*/
export function extractMosaicOidsFromPem(certPem: string): OidExtractionResult {
let cert: X509Certificate;
try {
cert = new X509Certificate(certPem);
} catch (err) {
return {
ok: false,
error: 'PARSE_ERROR',
detail: err instanceof Error ? err.message : String(err),
};
}
return extractMosaicOids(cert);
}