list_client_certificates
list_client_certificates
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.
Metadata
Name
list_client_certificates
Updated
2 months ago
Source
Risk
Low
Requires consent
false
Affected scope
user
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); });
}