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:
2026-02-11 21:17:45 -05:00
parent 286e88fece
commit fcf0ace106
6 changed files with 618 additions and 21 deletions
@@ -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) {
+21 -13
View File
@@ -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;
}