af95c3795c
Phase 1 — Structural overhaul: - Move all source from headlamp-sealed-secrets/ subdirectory to repo root - Delete 23 AI-generated docs, 8 pre-built tarballs, release snapshots dir - Remove all working-directory refs from CI/release workflows - Update install-plugin.sh and typedoc.json paths Phase 2 — Config standardization: - Create .eslintrc.js and .prettierrc.js (standard Headlamp configs) - Remove inline eslintConfig/prettier from package.json (drop jsx-a11y, prettier extends) - Rewrite tsconfig.json (package name extend, add compilerOptions.types) - Create vitest.config.mts and vitest.setup.ts (standard from polaris) - Replace headlamp-plugin CLI scripts with direct tool invocation - Rewrite .gitignore with standard baseline Phase 3 — MCP & Claude settings: - Create .mcp.json with github/kubernetes/flux/playwright servers - Create .claude/settings.local.json - Remove 7 specialized agents, keep 3 meta-orchestration agents Phase 4 — Documentation: - Rewrite CLAUDE.md (remove subdirectory refs, standard format) - Add ArtifactHub badge, Architecture section, standardized install methods to README.md - Create CONTRIBUTING.md and SECURITY.md - Fix pre-existing test bugs in validators.test.ts (isValidNamespace returns boolean, not ValidationResult; error message string mismatches) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
223 lines
7.0 KiB
TypeScript
223 lines
7.0 KiB
TypeScript
/**
|
|
* Client-side encryption utilities for Sealed Secrets
|
|
*
|
|
* This module handles the encryption of secret values using the sealed-secrets
|
|
* controller's public key. The encryption process matches the kubeseal CLI tool:
|
|
*
|
|
* 1. Generate a random AES-256-GCM session key
|
|
* 2. Encrypt the secret value with the session key
|
|
* 3. Encrypt the session key with the RSA public key (OAEP + SHA-256)
|
|
* 4. Construct the payload: 2-byte length prefix + encrypted session key + encrypted data
|
|
* 5. Base64-encode the result
|
|
*/
|
|
|
|
import forge from 'node-forge';
|
|
import {
|
|
Base64String,
|
|
CertificateInfo,
|
|
Err,
|
|
Ok,
|
|
PEMCertificate,
|
|
PlaintextValue,
|
|
Result,
|
|
SealedSecretScope,
|
|
} from '../types';
|
|
|
|
/**
|
|
* Parse a PEM certificate and extract the RSA public key
|
|
*
|
|
* @param pemCert PEM-encoded certificate string (branded type)
|
|
* @returns Result containing the public key or an error message
|
|
*/
|
|
export function parsePublicKeyFromCert(
|
|
pemCert: PEMCertificate
|
|
): Result<forge.pki.rsa.PublicKey, string> {
|
|
try {
|
|
const cert = forge.pki.certificateFromPem(pemCert);
|
|
const publicKey = cert.publicKey as forge.pki.rsa.PublicKey;
|
|
return Ok(publicKey);
|
|
} catch (error) {
|
|
return Err(`Failed to parse certificate: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Encrypt a secret value using the kubeseal format
|
|
*
|
|
* @param publicKey RSA public key from the controller's certificate
|
|
* @param value The plaintext secret value to encrypt (branded type)
|
|
* @param namespace The namespace (for strict/namespace-wide scoping)
|
|
* @param name The secret name (for strict scoping)
|
|
* @param key The key name within the secret
|
|
* @param scope The encryption scope
|
|
* @returns Result containing base64-encoded encrypted value or error message
|
|
*/
|
|
export function encryptValue(
|
|
publicKey: forge.pki.rsa.PublicKey,
|
|
value: PlaintextValue,
|
|
namespace: string,
|
|
name: string,
|
|
key: string,
|
|
scope: SealedSecretScope
|
|
): Result<Base64String, string> {
|
|
try {
|
|
// Generate a random 32-byte (256-bit) AES session key
|
|
const sessionKey = forge.random.getBytesSync(32);
|
|
|
|
// Construct the label for RSA-OAEP based on scope
|
|
// This binds the encryption to specific namespace/name/key depending on scope
|
|
let label = '';
|
|
if (scope === 'strict') {
|
|
// Strict scope: namespace.name.key
|
|
label = `${namespace}.${name}.${key}`;
|
|
} else if (scope === 'namespace-wide') {
|
|
// Namespace-wide scope: namespace.key
|
|
label = `${namespace}.${key}`;
|
|
} else {
|
|
// Cluster-wide scope: just the key
|
|
label = key;
|
|
}
|
|
|
|
// Encrypt the session key with RSA-OAEP (SHA-256)
|
|
const encryptedSessionKey = publicKey.encrypt(sessionKey, 'RSA-OAEP', {
|
|
md: forge.md.sha256.create(),
|
|
mgf1: {
|
|
md: forge.md.sha256.create(),
|
|
},
|
|
label: label,
|
|
});
|
|
|
|
// Encrypt the actual secret value with AES-256-GCM
|
|
const iv = forge.random.getBytesSync(12); // 12 bytes for GCM
|
|
const cipher = forge.cipher.createCipher('AES-GCM', sessionKey);
|
|
cipher.start({ iv: iv });
|
|
cipher.update(forge.util.createBuffer(value, 'utf8'));
|
|
cipher.finish();
|
|
|
|
const encryptedValue = cipher.output.getBytes();
|
|
const tag = (cipher.mode as any).tag.getBytes();
|
|
|
|
// Construct the sealed secret format:
|
|
// [2-byte length of encrypted session key][encrypted session key][IV][encrypted value][auth tag]
|
|
const sessionKeyLength = encryptedSessionKey.length;
|
|
const lengthBytes =
|
|
String.fromCharCode((sessionKeyLength >> 8) & 0xff) +
|
|
String.fromCharCode(sessionKeyLength & 0xff);
|
|
|
|
const payload = lengthBytes + encryptedSessionKey + iv + encryptedValue + tag;
|
|
|
|
// Base64 encode the final payload
|
|
return Ok(Base64String(forge.util.encode64(payload)));
|
|
} catch (error) {
|
|
return Err(`Encryption failed: ${error}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Encrypt multiple key-value pairs for a SealedSecret
|
|
*
|
|
* @param publicKey RSA public key from the controller's certificate
|
|
* @param keyValues Array of {key, value} pairs to encrypt (values are branded plaintext)
|
|
* @param namespace The namespace
|
|
* @param name The secret name
|
|
* @param scope The encryption scope
|
|
* @returns Result containing object mapping keys to encrypted values, or error message
|
|
*/
|
|
export function encryptKeyValues(
|
|
publicKey: forge.pki.rsa.PublicKey,
|
|
keyValues: Array<{ key: string; value: PlaintextValue }>,
|
|
namespace: string,
|
|
name: string,
|
|
scope: SealedSecretScope
|
|
): Result<Record<string, Base64String>, string> {
|
|
const encryptedData: Record<string, Base64String> = {};
|
|
|
|
for (const { key, value } of keyValues) {
|
|
const result = encryptValue(publicKey, value, namespace, name, key, scope);
|
|
|
|
if (result.ok === false) {
|
|
return Err(`Failed to encrypt key '${key}': ${result.error}`);
|
|
}
|
|
|
|
encryptedData[key] = result.value;
|
|
}
|
|
|
|
return Ok(encryptedData);
|
|
}
|
|
|
|
/**
|
|
* Validate a PEM certificate
|
|
*
|
|
* @param pemCert PEM-encoded certificate string (branded type)
|
|
* @returns true if certificate is valid, false otherwise
|
|
*/
|
|
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<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;
|
|
}
|