/** * 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 { 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 { 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, string> { const encryptedData: Record = {}; 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 { 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; }