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:
2026-02-11 21:30:48 -05:00
parent 2e19dd05e6
commit cc08e15f6a
4 changed files with 169 additions and 35 deletions
@@ -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' });
@@ -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 (
<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>
);
},
},
]}
/>