diff --git a/headlamp-sealed-secrets/src/components/EncryptDialog.tsx b/headlamp-sealed-secrets/src/components/EncryptDialog.tsx index aa13e0c..87267c7 100644 --- a/headlamp-sealed-secrets/src/components/EncryptDialog.tsx +++ b/headlamp-sealed-secrets/src/components/EncryptDialog.tsx @@ -25,7 +25,12 @@ import { import { useSnackbar } from 'notistack'; import React from 'react'; import { fetchPublicCertificate, getPluginConfig } from '../lib/controller'; -import { encryptKeyValues, parsePublicKeyFromCert } from '../lib/crypto'; +import { + encryptKeyValues, + isCertificateExpiringSoon, + parseCertificateInfo, + parsePublicKeyFromCert, +} from '../lib/crypto'; import { SealedSecret } from '../lib/SealedSecretCRD'; import { validateSecretKey, validateSecretName, validateSecretValue } from '../lib/validators'; import { PlaintextValue, SealedSecretScope, SecretKeyValue } from '../types'; @@ -125,7 +130,27 @@ export function EncryptDialog({ open, onClose }: EncryptDialogProps) { return; } - // 2. Parse the public key + // 2. Check certificate expiry + const certInfoResult = parseCertificateInfo(certResult.value); + if (certInfoResult.ok) { + const certInfo = certInfoResult.value; + + if (certInfo.isExpired) { + enqueueSnackbar( + `Warning: Controller certificate expired on ${certInfo.validTo.toLocaleDateString()}. ` + + 'Secrets may not be decryptable.', + { variant: 'warning' } + ); + } else if (isCertificateExpiringSoon(certInfo, 30)) { + enqueueSnackbar( + `Warning: Controller certificate expires in ${certInfo.daysUntilExpiry} days ` + + `(${certInfo.validTo.toLocaleDateString()}).`, + { variant: 'warning' } + ); + } + } + + // 3. Parse the public key const keyResult = parsePublicKeyFromCert(certResult.value); if (keyResult.ok === false) { @@ -133,7 +158,7 @@ export function EncryptDialog({ open, onClose }: EncryptDialogProps) { return; } - // 3. Encrypt all values client-side + // 4. Encrypt all values client-side const encryptResult = encryptKeyValues( keyResult.value, validKeyValues.map(kv => ({ key: kv.key, value: PlaintextValue(kv.value) })), @@ -147,7 +172,7 @@ export function EncryptDialog({ open, onClose }: EncryptDialogProps) { return; } - // 4. Construct the SealedSecret object + // 5. Construct the SealedSecret object const sealedSecretData: any = { apiVersion: 'bitnami.com/v1alpha1', kind: 'SealedSecret', @@ -171,7 +196,7 @@ export function EncryptDialog({ open, onClose }: EncryptDialogProps) { sealedSecretData.metadata.annotations['sealedsecrets.bitnami.com/cluster-wide'] = 'true'; } - // 5. Apply to the cluster + // 6. Apply to the cluster await SealedSecret.apiEndpoint.post(sealedSecretData); enqueueSnackbar('SealedSecret created successfully', { variant: 'success' }); diff --git a/headlamp-sealed-secrets/src/components/SealingKeysView.tsx b/headlamp-sealed-secrets/src/components/SealingKeysView.tsx index ca04138..b15442e 100644 --- a/headlamp-sealed-secrets/src/components/SealingKeysView.tsx +++ b/headlamp-sealed-secrets/src/components/SealingKeysView.tsx @@ -6,34 +6,18 @@ import { K8s } from '@kinvolk/headlamp-plugin/lib'; import { SectionBox, SimpleTable, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; -import { Box, Button } from '@mui/material'; -import forge from 'node-forge'; +import { Box, Button, Chip } from '@mui/material'; import { useSnackbar } from 'notistack'; import React from 'react'; import { fetchPublicCertificate, getPluginConfig } from '../lib/controller'; -import { PEMCertificate } from '../types'; +import { isCertificateExpiringSoon, parseCertificateInfo } from '../lib/crypto'; +import { CertificateInfo, PEMCertificate } from '../types'; interface SealingKey { name: string; status: 'active' | 'compromised'; created: string; - notBefore?: string; - notAfter?: string; -} - -/** - * Parse certificate dates from TLS secret - */ -function parseCertificateDates(certPem: PEMCertificate): { notBefore?: string; notAfter?: string } { - try { - const cert = forge.pki.certificateFromPem(certPem); - return { - notBefore: cert.validity.notBefore.toISOString(), - notAfter: cert.validity.notAfter.toISOString(), - }; - } catch { - return {}; - } + certInfo?: CertificateInfo; } /** @@ -58,13 +42,20 @@ export function SealingKeysView() { | 'active' | 'compromised'; const certPem = secret.data?.['tls.crt'] ? atob(secret.data['tls.crt']) : ''; - const dates = certPem ? parseCertificateDates(PEMCertificate(certPem)) : {}; + + let certInfo: CertificateInfo | undefined; + if (certPem) { + const infoResult = parseCertificateInfo(PEMCertificate(certPem)); + if (infoResult.ok) { + certInfo = infoResult.value; + } + } return { name: secret.metadata.name!, status, created: secret.metadata.creationTimestamp!, - ...dates, + certInfo, }; }) .sort((a, b) => { @@ -148,14 +139,44 @@ export function SealingKeysView() { getter: (key: SealingKey) => new Date(key.created).toLocaleString(), }, { - label: 'Valid From', - getter: (key: SealingKey) => - key.notBefore ? new Date(key.notBefore).toLocaleString() : 'N/A', - }, - { - label: 'Valid Until', - getter: (key: SealingKey) => - key.notAfter ? new Date(key.notAfter).toLocaleString() : 'N/A', + label: 'Certificate Expiry', + getter: (key: SealingKey) => { + if (!key.certInfo) return 'N/A'; + + const { certInfo } = key; + const expiryDate = certInfo.validTo.toLocaleDateString(); + + if (certInfo.isExpired) { + return ( + + + {expiryDate} + + ); + } + + if (isCertificateExpiringSoon(certInfo, 30)) { + return ( + + + {expiryDate} + + ); + } + + return ( + + {expiryDate} + + ({certInfo.daysUntilExpiry} days) + + + ); + }, }, ]} /> diff --git a/headlamp-sealed-secrets/src/lib/crypto.ts b/headlamp-sealed-secrets/src/lib/crypto.ts index 4b4f3a7..30618de 100644 --- a/headlamp-sealed-secrets/src/lib/crypto.ts +++ b/headlamp-sealed-secrets/src/lib/crypto.ts @@ -14,6 +14,7 @@ import forge from 'node-forge'; import { Base64String, + CertificateInfo, Err, Ok, PEMCertificate, @@ -154,3 +155,68 @@ export function validateCertificate(pemCert: PEMCertificate): boolean { const result = parsePublicKeyFromCert(pemCert); return result.ok; } + +/** + * Parse certificate and extract metadata + * + * Extracts validity dates, issuer/subject information, and calculates + * expiration status and fingerprint. + * + * @param pemCert PEM-encoded certificate string (branded type) + * @returns Result containing certificate information or error message + */ +export function parseCertificateInfo(pemCert: PEMCertificate): Result { + try { + const cert = forge.pki.certificateFromPem(pemCert); + const now = new Date(); + + // Extract validity dates + const validFrom = cert.validity.notBefore; + const validTo = cert.validity.notAfter; + + // Calculate expiration status + const isExpired = now > validTo; + const daysUntilExpiry = Math.floor((validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + + // Format issuer and subject + const formatDN = (attributes: forge.pki.CertificateField[]): string => { + return attributes.map(a => `${a.shortName}=${a.value}`).join(', '); + }; + + const issuer = formatDN(cert.issuer.attributes); + const subject = formatDN(cert.subject.attributes); + + // Calculate SHA-256 fingerprint + const der = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(); + const md = forge.md.sha256.create(); + md.update(der); + const fingerprint = md.digest().toHex().toUpperCase(); + + // Get serial number + const serialNumber = cert.serialNumber; + + return Ok({ + validFrom, + validTo, + isExpired, + daysUntilExpiry, + issuer, + subject, + fingerprint, + serialNumber, + }); + } catch (error) { + return Err(`Failed to parse certificate info: ${error}`); + } +} + +/** + * Check if certificate will expire soon (within threshold) + * + * @param info Certificate information + * @param daysThreshold Number of days to consider "expiring soon" (default: 30) + * @returns true if certificate will expire within threshold days + */ +export function isCertificateExpiringSoon(info: CertificateInfo, daysThreshold = 30): boolean { + return !info.isExpired && info.daysUntilExpiry <= daysThreshold; +} diff --git a/headlamp-sealed-secrets/src/types.ts b/headlamp-sealed-secrets/src/types.ts index 731f088..f1e6978 100644 --- a/headlamp-sealed-secrets/src/types.ts +++ b/headlamp-sealed-secrets/src/types.ts @@ -259,3 +259,25 @@ export interface EncryptionRequest { scope: SealedSecretScope; keyValues: SecretKeyValue[]; } + +/** + * Certificate information extracted from PEM certificate + */ +export interface CertificateInfo { + /** Validity period start date */ + validFrom: Date; + /** Validity period end date */ + validTo: Date; + /** Whether certificate is currently expired */ + isExpired: boolean; + /** Days until expiry (negative if expired) */ + daysUntilExpiry: number; + /** Certificate issuer (formatted as DN string) */ + issuer: string; + /** Certificate subject (formatted as DN string) */ + subject: string; + /** SHA-256 fingerprint of certificate */ + fingerprint: string; + /** Serial number of certificate */ + serialNumber: string; +}