- 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>
147 lines
4.6 KiB
TypeScript
147 lines
4.6 KiB
TypeScript
/**
|
||
* 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 0–127); 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);
|
||
}
|