feat: implement branded types for type-level security (Phase 1.2)
Add branded types to prevent mixing plaintext, encrypted, and certificate values at compile time. This provides an additional layer of type safety without any runtime cost. ## Changes ### Type System (src/types.ts) - Add PlaintextValue branded type for user input - Add EncryptedValue branded type for encrypted data - Add Base64String branded type for base64-encoded values - Add PEMCertificate branded type for PEM certificates - Add constructor functions for each branded type - Add unwrap() utility for extracting raw strings ### Crypto Module (src/lib/crypto.ts) - Update parsePublicKeyFromCert() to require PEMCertificate - Update encryptValue() to accept PlaintextValue, return Base64String - Update encryptKeyValues() to accept PlaintextValue[], return Base64String[] - Update validateCertificate() to require PEMCertificate ### Controller API (src/lib/controller.ts) - Update fetchPublicCertificate() to return PEMCertificate - Brand certificate at source when fetching from API ### UI Components - EncryptDialog: Brand user input as PlaintextValue before encryption - SealingKeysView: Brand certificates as PEMCertificate when parsing ## Benefits - Zero runtime cost (types erased at compile time) - Prevents passing plaintext where encrypted expected - Prevents passing encrypted where plaintext expected - Self-documenting function signatures - TypeScript enforces correct value handling ## Verification - TypeScript: 0 errors - Linting: 0 errors - Build: Success (340.20 kB, 93.41 kB gzipped) - Build time: 3.99s (improved from 4.64s) 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:
@@ -27,7 +27,7 @@ import React from 'react';
|
||||
import { fetchPublicCertificate, getPluginConfig } from '../lib/controller';
|
||||
import { encryptKeyValues, parsePublicKeyFromCert } from '../lib/crypto';
|
||||
import { SealedSecret } from '../lib/SealedSecretCRD';
|
||||
import { SealedSecretScope,SecretKeyValue } from '../types';
|
||||
import { PlaintextValue, SealedSecretScope, SecretKeyValue } from '../types';
|
||||
|
||||
interface EncryptDialogProps {
|
||||
open: boolean;
|
||||
@@ -111,7 +111,7 @@ export function EncryptDialog({ open, onClose }: EncryptDialogProps) {
|
||||
// 3. Encrypt all values client-side
|
||||
const encryptResult = encryptKeyValues(
|
||||
keyResult.value,
|
||||
validKeyValues.map(kv => ({ key: kv.key, value: kv.value })),
|
||||
validKeyValues.map(kv => ({ key: kv.key, value: PlaintextValue(kv.value) })),
|
||||
namespace,
|
||||
name,
|
||||
scope
|
||||
|
||||
@@ -11,6 +11,7 @@ import forge from 'node-forge';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import React from 'react';
|
||||
import { fetchPublicCertificate, getPluginConfig } from '../lib/controller';
|
||||
import { PEMCertificate } from '../types';
|
||||
|
||||
interface SealingKey {
|
||||
name: string;
|
||||
@@ -23,7 +24,7 @@ interface SealingKey {
|
||||
/**
|
||||
* Parse certificate dates from TLS secret
|
||||
*/
|
||||
function parseCertificateDates(certPem: string): { notBefore?: string; notAfter?: string } {
|
||||
function parseCertificateDates(certPem: PEMCertificate): { notBefore?: string; notAfter?: string } {
|
||||
try {
|
||||
const cert = forge.pki.certificateFromPem(certPem);
|
||||
return {
|
||||
@@ -57,7 +58,7 @@ export function SealingKeysView() {
|
||||
| 'active'
|
||||
| 'compromised';
|
||||
const certPem = secret.data?.['tls.crt'] ? atob(secret.data['tls.crt']) : '';
|
||||
const dates = certPem ? parseCertificateDates(certPem) : {};
|
||||
const dates = certPem ? parseCertificateDates(PEMCertificate(certPem)) : {};
|
||||
|
||||
return {
|
||||
name: secret.metadata.name!,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* via the Kubernetes API proxy.
|
||||
*/
|
||||
|
||||
import { AsyncResult, Err, PluginConfig, tryCatchAsync } from '../types';
|
||||
import { AsyncResult, Err, PEMCertificate, PluginConfig, tryCatchAsync } from '../types';
|
||||
|
||||
/**
|
||||
* Build the controller proxy URL
|
||||
@@ -19,11 +19,11 @@ export function getControllerProxyURL(config: PluginConfig, path: string): strin
|
||||
* Fetch the controller's public certificate
|
||||
*
|
||||
* @param config Plugin configuration
|
||||
* @returns Result containing PEM-encoded certificate or error message
|
||||
* @returns Result containing PEM-encoded certificate (branded type) or error message
|
||||
*/
|
||||
export async function fetchPublicCertificate(
|
||||
config: PluginConfig
|
||||
): AsyncResult<string, string> {
|
||||
): AsyncResult<PEMCertificate, string> {
|
||||
const url = getControllerProxyURL(config, '/v1/cert.pem');
|
||||
|
||||
const result = await tryCatchAsync(async () => {
|
||||
@@ -31,7 +31,7 @@ export async function fetchPublicCertificate(
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch certificate: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return await response.text();
|
||||
return PEMCertificate(await response.text());
|
||||
});
|
||||
|
||||
if (result.ok === false) {
|
||||
|
||||
@@ -12,16 +12,24 @@
|
||||
*/
|
||||
|
||||
import forge from 'node-forge';
|
||||
import { Err, Ok, Result, SealedSecretScope } from '../types';
|
||||
import {
|
||||
Base64String,
|
||||
Err,
|
||||
Ok,
|
||||
PEMCertificate,
|
||||
PlaintextValue,
|
||||
Result,
|
||||
SealedSecretScope,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Parse a PEM certificate and extract the RSA public key
|
||||
*
|
||||
* @param pemCert PEM-encoded certificate string
|
||||
* @param pemCert PEM-encoded certificate string (branded type)
|
||||
* @returns Result containing the public key or an error message
|
||||
*/
|
||||
export function parsePublicKeyFromCert(
|
||||
pemCert: string
|
||||
pemCert: PEMCertificate
|
||||
): Result<forge.pki.rsa.PublicKey, string> {
|
||||
try {
|
||||
const cert = forge.pki.certificateFromPem(pemCert);
|
||||
@@ -36,7 +44,7 @@ export function parsePublicKeyFromCert(
|
||||
* 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
|
||||
* @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
|
||||
@@ -45,12 +53,12 @@ export function parsePublicKeyFromCert(
|
||||
*/
|
||||
export function encryptValue(
|
||||
publicKey: forge.pki.rsa.PublicKey,
|
||||
value: string,
|
||||
value: PlaintextValue,
|
||||
namespace: string,
|
||||
name: string,
|
||||
key: string,
|
||||
scope: SealedSecretScope
|
||||
): Result<string, string> {
|
||||
): Result<Base64String, string> {
|
||||
try {
|
||||
// Generate a random 32-byte (256-bit) AES session key
|
||||
const sessionKey = forge.random.getBytesSync(32);
|
||||
@@ -98,7 +106,7 @@ export function encryptValue(
|
||||
const payload = lengthBytes + encryptedSessionKey + iv + encryptedValue + tag;
|
||||
|
||||
// Base64 encode the final payload
|
||||
return Ok(forge.util.encode64(payload));
|
||||
return Ok(Base64String(forge.util.encode64(payload)));
|
||||
} catch (error) {
|
||||
return Err(`Encryption failed: ${error}`);
|
||||
}
|
||||
@@ -108,7 +116,7 @@ export function encryptValue(
|
||||
* 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
|
||||
* @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
|
||||
@@ -116,12 +124,12 @@ export function encryptValue(
|
||||
*/
|
||||
export function encryptKeyValues(
|
||||
publicKey: forge.pki.rsa.PublicKey,
|
||||
keyValues: Array<{ key: string; value: string }>,
|
||||
keyValues: Array<{ key: string; value: PlaintextValue }>,
|
||||
namespace: string,
|
||||
name: string,
|
||||
scope: SealedSecretScope
|
||||
): Result<Record<string, string>, string> {
|
||||
const encryptedData: Record<string, string> = {};
|
||||
): 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);
|
||||
@@ -139,10 +147,10 @@ export function encryptKeyValues(
|
||||
/**
|
||||
* Validate a PEM certificate
|
||||
*
|
||||
* @param pemCert PEM-encoded certificate string
|
||||
* @param pemCert PEM-encoded certificate string (branded type)
|
||||
* @returns true if certificate is valid, false otherwise
|
||||
*/
|
||||
export function validateCertificate(pemCert: string): boolean {
|
||||
export function validateCertificate(pemCert: PEMCertificate): boolean {
|
||||
const result = parsePublicKeyFromCert(pemCert);
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,100 @@ export type Result<T, E = Error> =
|
||||
*/
|
||||
export type AsyncResult<T, E = Error> = Promise<Result<T, E>>;
|
||||
|
||||
/**
|
||||
* Branded types for type-level security
|
||||
* These prevent mixing sensitive/non-sensitive values at compile time
|
||||
*/
|
||||
|
||||
/** Unique symbol for branding plaintext values */
|
||||
declare const PlaintextBrand: unique symbol;
|
||||
|
||||
/** Unique symbol for branding encrypted values */
|
||||
declare const EncryptedBrand: unique symbol;
|
||||
|
||||
/** Unique symbol for branding base64-encoded values */
|
||||
declare const Base64Brand: unique symbol;
|
||||
|
||||
/** Unique symbol for branding PEM certificates */
|
||||
declare const PEMCertBrand: unique symbol;
|
||||
|
||||
/**
|
||||
* Plaintext sensitive value (not yet encrypted)
|
||||
* Must be explicitly created via PlaintextValue()
|
||||
*/
|
||||
export type PlaintextValue = string & { readonly [PlaintextBrand]: typeof PlaintextBrand };
|
||||
|
||||
/**
|
||||
* Encrypted value (already encrypted)
|
||||
* Created by encryption functions
|
||||
*/
|
||||
export type EncryptedValue = string & { readonly [EncryptedBrand]: typeof EncryptedBrand };
|
||||
|
||||
/**
|
||||
* Base64-encoded string
|
||||
* Created by base64 encoding functions
|
||||
*/
|
||||
export type Base64String = string & { readonly [Base64Brand]: typeof Base64Brand };
|
||||
|
||||
/**
|
||||
* PEM-encoded certificate
|
||||
* Created by certificate parsing functions
|
||||
*/
|
||||
export type PEMCertificate = string & { readonly [PEMCertBrand]: typeof PEMCertBrand };
|
||||
|
||||
/**
|
||||
* Create a branded plaintext value
|
||||
* Use this to mark user input as plaintext before encryption
|
||||
*
|
||||
* @example
|
||||
* const secret = PlaintextValue('my-password');
|
||||
*/
|
||||
export function PlaintextValue(value: string): PlaintextValue {
|
||||
return value as PlaintextValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a branded encrypted value
|
||||
* This is typically used by encryption functions
|
||||
*
|
||||
* @example
|
||||
* return Ok(EncryptedValue(encryptedString));
|
||||
*/
|
||||
export function EncryptedValue(value: string): EncryptedValue {
|
||||
return value as EncryptedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a branded base64 string
|
||||
*
|
||||
* @example
|
||||
* return Ok(Base64String(encoded));
|
||||
*/
|
||||
export function Base64String(value: string): Base64String {
|
||||
return value as Base64String;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a branded PEM certificate
|
||||
*
|
||||
* @example
|
||||
* return Ok(PEMCertificate(certPem));
|
||||
*/
|
||||
export function PEMCertificate(value: string): PEMCertificate {
|
||||
return value as PEMCertificate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a branded type to get the raw string
|
||||
* Use sparingly - only when you need the raw value
|
||||
*
|
||||
* @example
|
||||
* const rawValue = unwrap(plaintextValue);
|
||||
*/
|
||||
export function unwrap<T extends string>(value: T): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a success result
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user