Code

/**
 * mcp/skills/listClientCertificates.ts — list_client_certificates
 *
 * Enumerates personal / machine client certificates in the endpoint's
 * certificate store.  Used by identity-auth-repair to spot expired or
 * soon-to-expire client certs that are blocking SSO / 802.1x / VPN.
 *
 * Platform strategy
 * -----------------
 * darwin  `security find-identity -v -p ssl-client` — valid client
 *         identities (certs paired with a private key).  We also issue
 *         `security find-certificate -c <CN> -p` to pull the PEM and
 *         extract NotAfter.
 * win32   PowerShell:
 *           Get-ChildItem Cert:\CurrentUser\My | Select-Object Subject, Issuer, NotAfter, Thumbprint
 *           Get-ChildItem Cert:\LocalMachine\My | …
 *
 * Output schema is normalised so the skill prose doesn't branch on OS.
 */
 
import { z } from "zod";
 
import { execAsync, isDarwin, isWin32, runPS } from "./_shared/platform";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "list_client_certificates",
  description:
    "Enumerates personal and machine client certificates stored on the " +
    "endpoint. Each entry includes subject, issuer, thumbprint, NotBefore, " +
    "and NotAfter with a computed expiry flag. Use when diagnosing SSO / " +
    "802.1x / VPN failures that are caused by expired or soon-to-expire " +
    "client certs. Read-only.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  schema: {
    expiryWarnDays: z
      .number()
      .int()
      .min(1)
      .max(365)
      .optional()
      .describe(
        "How many days before expiry to flag a cert as 'expiring soon'. " +
        "Defaults to 30.",
      ),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
export interface ClientCertificate {
  subject:      string;
  issuer:       string;
  /** Uppercase hex thumbprint (SHA-1); may be truncated on macOS if unavailable. */
  thumbprint:   string;
  notBefore:    string | null;
  notAfter:     string | null;
  expired:      boolean;
  expiringSoon: boolean;
  /** Where the cert lives — "CurrentUser\My", "LocalMachine\My", "login-keychain", "system-keychain". */
  store:        string;
}
 
export interface ListCertsResult {
  platform:    "darwin" | "win32" | "other";
  certificates: ClientCertificate[];
  summary: {
    total:    number;
    expired:  number;
    expiring: number;
    healthy:  number;
  };
  status:  "ok" | "expired" | "expiring" | "empty" | "error";
  message: string;
}
 
// -- darwin --------------------------------------------------------------------
 
async function listDarwin(warnMs: number, now: number): Promise<ClientCertificate[]> {
  const certs: ClientCertificate[] = [];
 
  try {
    // find-identity -v -p ssl-client lists usable client identities:
    //   1) 1A2B3C4D… "alice@example.com"
    const { stdout } = await execAsync(
      `security find-identity -v -p ssl-client 2>&1`,
      { maxBuffer: 2 * 1024 * 1024, timeout: 5_000 },
    );
    const matches = stdout.match(/^\s+\d+\)\s+([0-9A-F]+)\s+"([^"]+)"/gm) ?? [];
    for (const line of matches) {
      const m = line.match(/^\s+\d+\)\s+([0-9A-F]+)\s+"([^"]+)"/);
      if (!m) continue;
      const thumbprint = m[1];
      const subject    = m[2];
 
      // Best-effort NotBefore / NotAfter via openssl parsing of the cert.
      let notBefore: string | null = null;
      let notAfter:  string | null = null;
      try {
        const pem = await execAsync(
          `security find-certificate -c ${shellQuote(subject)} -p 2>/dev/null | ` +
          `openssl x509 -noout -dates 2>/dev/null`,
          { maxBuffer: 1 * 1024 * 1024, timeout: 5_000 },
        );
        const nbMatch = pem.stdout.match(/notBefore=(.+)/);
        const naMatch = pem.stdout.match(/notAfter=(.+)/);
        if (nbMatch) notBefore = parseOpensslDate(nbMatch[1]);
        if (naMatch) notAfter  = parseOpensslDate(naMatch[1]);
      } catch {
        // openssl not available or cert not readable — leave dates null.
      }
 
      const naMs = notAfter ? new Date(notAfter).getTime() : NaN;
      const valid = !isNaN(naMs);
      certs.push({
        subject, issuer: "(unknown — macOS security tool does not expose issuer here)",
        thumbprint, notBefore, notAfter,
        expired:      valid ? naMs < now : false,
        expiringSoon: valid ? naMs - now < warnMs && naMs > now : false,
        store:        "login-keychain",
      });
    }
  } catch {
    // Fallthrough — security command unavailable.
  }
 
  return certs;
}
 
function parseOpensslDate(s: string): string | null {
  // openssl dates look like: "Apr 21 13:45:00 2025 GMT"
  const d = new Date(s);
  if (isNaN(d.getTime())) return null;
  return d.toISOString();
}
 
// -- win32 --------------------------------------------------------------------
 
async function listWin32(warnMs: number, now: number): Promise<ClientCertificate[]> {
  const script = `
$ErrorActionPreference = 'SilentlyContinue'
$stores = @(
  @{ Path = 'Cert:\\CurrentUser\\My';   Name = 'CurrentUser\\My' },
  @{ Path = 'Cert:\\LocalMachine\\My';  Name = 'LocalMachine\\My' }
)
$out = @()
foreach ($s in $stores) {
  try {
    $items = Get-ChildItem -Path $s.Path
    foreach ($c in $items) {
      $out += [PSCustomObject]@{
        subject    = $c.Subject
        issuer     = $c.Issuer
        thumbprint = $c.Thumbprint
        notBefore  = $c.NotBefore.ToString('o')
        notAfter   = $c.NotAfter.ToString('o')
        store      = $s.Name
      }
    }
  } catch { }
}
$out | ConvertTo-Json -Compress -Depth 4`.trim();
 
  try {
    const raw = await runPS(script, { timeoutMs: 10_000 });
    if (!raw) return [];
    const parsed = JSON.parse(raw) as unknown;
    const arr = Array.isArray(parsed) ? parsed : [parsed];
    return arr.map((o): ClientCertificate => {
      const r = o as Record<string, unknown>;
      const naStr = typeof r["notAfter"] === "string" ? (r["notAfter"] as string) : null;
      const nbStr = typeof r["notBefore"] === "string" ? (r["notBefore"] as string) : null;
      const naMs  = naStr ? new Date(naStr).getTime() : NaN;
      const valid = !isNaN(naMs);
      return {
        subject:    String(r["subject"] ?? ""),
        issuer:     String(r["issuer"] ?? ""),
        thumbprint: String(r["thumbprint"] ?? ""),
        notBefore:  nbStr,
        notAfter:   naStr,
        expired:      valid ? naMs < now : false,
        expiringSoon: valid ? naMs - now < warnMs && naMs > now : false,
        store:      String(r["store"] ?? ""),
      };
    });
  } catch {
    return [];
  }
}
 
// -- Helpers ------------------------------------------------------------------
 
function shellQuote(s: string): string {
  return `'${s.replace(/'/g, `'\\''`)}'`;
}
 
// Exported for unit tests.
export const __testing = { listDarwin, listWin32, parseOpensslDate };
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  expiryWarnDays = 30,
}: {
  expiryWarnDays?: number;
} = {}): Promise<ListCertsResult> {
  const platform: "darwin" | "win32" | "other" =
    isDarwin() ? "darwin" : isWin32() ? "win32" : "other";
 
  if (platform === "other") {
    return {
      platform, certificates: [],
      summary: { total: 0, expired: 0, expiring: 0, healthy: 0 },
      status: "error",
      message: "Unsupported platform — certificate enumeration not available.",
    };
  }
 
  const warnMs = expiryWarnDays * 24 * 60 * 60 * 1_000;
  const now    = Date.now();
  const certificates = platform === "darwin"
    ? await listDarwin(warnMs, now)
    : await listWin32(warnMs, now);
 
  const expired  = certificates.filter((c) => c.expired).length;
  const expiring = certificates.filter((c) => c.expiringSoon).length;
  const healthy  = certificates.filter((c) => !c.expired && !c.expiringSoon).length;
 
  const status: ListCertsResult["status"] =
    certificates.length === 0 ? "empty"
      : expired > 0 ? "expired"
      : expiring > 0 ? "expiring"
      : "ok";
 
  const message =
    status === "empty"
      ? "No client certificates found in the personal / machine store(s)."
      : status === "expired"
        ? `${expired} client certificate(s) have EXPIRED.`
        : status === "expiring"
          ? `${expiring} client certificate(s) expire within ${expiryWarnDays} day(s).`
          : `${healthy} client certificate(s) are healthy.`;
 
  return {
    platform, certificates,
    summary: { total: certificates.length, expired, expiring, healthy },
    status, message,
  };
}
 
// -- CLI smoke test -----------------------------------------------------------
 
if (false) {
  run()
    .then((r) => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}