feat: implement certificate validation and expiry detection (Phase 2.1)
Add comprehensive certificate metadata parsing and expiry warnings. ## Changes ### Types (src/types.ts) - Add CertificateInfo interface with validity dates, expiry status, issuer/subject, fingerprint ### Crypto Module (src/lib/crypto.ts) - Add parseCertificateInfo() to extract certificate metadata - Add isCertificateExpiringSoon() helper (default 30 days threshold) - Calculate SHA-256 fingerprint, parse DN fields, compute days until expiry ### SealingKeysView (src/components/SealingKeysView.tsx) - Display certificate expiry information in table - Show visual indicators: Expired (red), Expiring Soon (warning), Valid (normal) - Display days remaining for expiring certificates ### EncryptDialog (src/components/EncryptDialog.tsx) - Add expiry warning before encryption - Warn if certificate expired or expiring within 30 days - Show specific expiry date in warning message ## Features - **Certificate Parsing:** Extract all metadata from X.509 certificates - **Expiry Detection:** Automatic detection of expired/expiring certificates - **Visual Indicators:** Color-coded chips for expiry status - **Proactive Warnings:** Alert users before creating secrets with expiring certs - **SHA-256 Fingerprint:** Unique certificate identification ## Verification - TypeScript: 0 errors - Linting: 0 errors - Build: Success (343.95 kB, 94.58 kB gzipped) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -25,7 +25,12 @@ import {
|
|||||||
import { useSnackbar } from 'notistack';
|
import { useSnackbar } from 'notistack';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { fetchPublicCertificate, getPluginConfig } from '../lib/controller';
|
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 { SealedSecret } from '../lib/SealedSecretCRD';
|
||||||
import { validateSecretKey, validateSecretName, validateSecretValue } from '../lib/validators';
|
import { validateSecretKey, validateSecretName, validateSecretValue } from '../lib/validators';
|
||||||
import { PlaintextValue, SealedSecretScope, SecretKeyValue } from '../types';
|
import { PlaintextValue, SealedSecretScope, SecretKeyValue } from '../types';
|
||||||
@@ -125,7 +130,27 @@ export function EncryptDialog({ open, onClose }: EncryptDialogProps) {
|
|||||||
return;
|
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);
|
const keyResult = parsePublicKeyFromCert(certResult.value);
|
||||||
|
|
||||||
if (keyResult.ok === false) {
|
if (keyResult.ok === false) {
|
||||||
@@ -133,7 +158,7 @@ export function EncryptDialog({ open, onClose }: EncryptDialogProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Encrypt all values client-side
|
// 4. Encrypt all values client-side
|
||||||
const encryptResult = encryptKeyValues(
|
const encryptResult = encryptKeyValues(
|
||||||
keyResult.value,
|
keyResult.value,
|
||||||
validKeyValues.map(kv => ({ key: kv.key, value: PlaintextValue(kv.value) })),
|
validKeyValues.map(kv => ({ key: kv.key, value: PlaintextValue(kv.value) })),
|
||||||
@@ -147,7 +172,7 @@ export function EncryptDialog({ open, onClose }: EncryptDialogProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Construct the SealedSecret object
|
// 5. Construct the SealedSecret object
|
||||||
const sealedSecretData: any = {
|
const sealedSecretData: any = {
|
||||||
apiVersion: 'bitnami.com/v1alpha1',
|
apiVersion: 'bitnami.com/v1alpha1',
|
||||||
kind: 'SealedSecret',
|
kind: 'SealedSecret',
|
||||||
@@ -171,7 +196,7 @@ export function EncryptDialog({ open, onClose }: EncryptDialogProps) {
|
|||||||
sealedSecretData.metadata.annotations['sealedsecrets.bitnami.com/cluster-wide'] = 'true';
|
sealedSecretData.metadata.annotations['sealedsecrets.bitnami.com/cluster-wide'] = 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Apply to the cluster
|
// 6. Apply to the cluster
|
||||||
await SealedSecret.apiEndpoint.post(sealedSecretData);
|
await SealedSecret.apiEndpoint.post(sealedSecretData);
|
||||||
|
|
||||||
enqueueSnackbar('SealedSecret created successfully', { variant: 'success' });
|
enqueueSnackbar('SealedSecret created successfully', { variant: 'success' });
|
||||||
|
|||||||
@@ -6,34 +6,18 @@
|
|||||||
|
|
||||||
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||||
import { SectionBox, SimpleTable, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
import { SectionBox, SimpleTable, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||||
import { Box, Button } from '@mui/material';
|
import { Box, Button, Chip } from '@mui/material';
|
||||||
import forge from 'node-forge';
|
|
||||||
import { useSnackbar } from 'notistack';
|
import { useSnackbar } from 'notistack';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { fetchPublicCertificate, getPluginConfig } from '../lib/controller';
|
import { fetchPublicCertificate, getPluginConfig } from '../lib/controller';
|
||||||
import { PEMCertificate } from '../types';
|
import { isCertificateExpiringSoon, parseCertificateInfo } from '../lib/crypto';
|
||||||
|
import { CertificateInfo, PEMCertificate } from '../types';
|
||||||
|
|
||||||
interface SealingKey {
|
interface SealingKey {
|
||||||
name: string;
|
name: string;
|
||||||
status: 'active' | 'compromised';
|
status: 'active' | 'compromised';
|
||||||
created: string;
|
created: string;
|
||||||
notBefore?: string;
|
certInfo?: CertificateInfo;
|
||||||
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 {};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,13 +42,20 @@ export function SealingKeysView() {
|
|||||||
| 'active'
|
| 'active'
|
||||||
| 'compromised';
|
| 'compromised';
|
||||||
const certPem = secret.data?.['tls.crt'] ? atob(secret.data['tls.crt']) : '';
|
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 {
|
return {
|
||||||
name: secret.metadata.name!,
|
name: secret.metadata.name!,
|
||||||
status,
|
status,
|
||||||
created: secret.metadata.creationTimestamp!,
|
created: secret.metadata.creationTimestamp!,
|
||||||
...dates,
|
certInfo,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -148,14 +139,44 @@ export function SealingKeysView() {
|
|||||||
getter: (key: SealingKey) => new Date(key.created).toLocaleString(),
|
getter: (key: SealingKey) => new Date(key.created).toLocaleString(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Valid From',
|
label: 'Certificate Expiry',
|
||||||
getter: (key: SealingKey) =>
|
getter: (key: SealingKey) => {
|
||||||
key.notBefore ? new Date(key.notBefore).toLocaleString() : 'N/A',
|
if (!key.certInfo) return 'N/A';
|
||||||
},
|
|
||||||
{
|
const { certInfo } = key;
|
||||||
label: 'Valid Until',
|
const expiryDate = certInfo.validTo.toLocaleDateString();
|
||||||
getter: (key: SealingKey) =>
|
|
||||||
key.notAfter ? new Date(key.notAfter).toLocaleString() : 'N/A',
|
if (certInfo.isExpired) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Chip label="Expired" color="error" size="small" />
|
||||||
|
<span>{expiryDate}</span>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCertificateExpiringSoon(certInfo, 30)) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Chip
|
||||||
|
label={`${certInfo.daysUntilExpiry} days left`}
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<span>{expiryDate}</span>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<span>{expiryDate}</span>
|
||||||
|
<span style={{ color: '#666', fontSize: '0.9em' }}>
|
||||||
|
({certInfo.daysUntilExpiry} days)
|
||||||
|
</span>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import forge from 'node-forge';
|
import forge from 'node-forge';
|
||||||
import {
|
import {
|
||||||
Base64String,
|
Base64String,
|
||||||
|
CertificateInfo,
|
||||||
Err,
|
Err,
|
||||||
Ok,
|
Ok,
|
||||||
PEMCertificate,
|
PEMCertificate,
|
||||||
@@ -154,3 +155,68 @@ export function validateCertificate(pemCert: PEMCertificate): boolean {
|
|||||||
const result = parsePublicKeyFromCert(pemCert);
|
const result = parsePublicKeyFromCert(pemCert);
|
||||||
return result.ok;
|
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<CertificateInfo, string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -259,3 +259,25 @@ export interface EncryptionRequest {
|
|||||||
scope: SealedSecretScope;
|
scope: SealedSecretScope;
|
||||||
keyValues: SecretKeyValue[];
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user