Files
registry/ts/services/crypto.service.ts

179 lines
4.5 KiB
TypeScript

/**
* Crypto Service for Stack.Gallery Registry
* Handles AES-256-GCM encryption/decryption of secrets
*/
export class CryptoService {
private masterKey: CryptoKey | null = null;
private initialized = false;
/**
* Initialize the crypto service with the master key
* The key should be a 64-character hex string (32 bytes = 256 bits)
*/
public async initialize(): Promise<void> {
if (this.initialized) return;
const keyHex = Deno.env.get('AUTH_ENCRYPTION_KEY');
if (!keyHex) {
console.warn(
'[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)'
);
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
this.masterKey = await this.importKey(this.bytesToHex(randomBytes));
} else {
if (keyHex.length !== 64) {
throw new Error('AUTH_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)');
}
this.masterKey = await this.importKey(keyHex);
}
this.initialized = true;
}
/**
* Encrypt a plaintext string
* Returns format: base64(iv):base64(ciphertext)
*/
public async encrypt(plaintext: string): Promise<string> {
await this.initialize();
if (!this.masterKey) {
throw new Error('CryptoService not initialized');
}
// Generate random IV (12 bytes for AES-GCM)
const iv = crypto.getRandomValues(new Uint8Array(12));
// Encode plaintext to bytes
const encoded = new TextEncoder().encode(plaintext);
// Encrypt
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
this.masterKey,
encoded
);
// Format: iv:ciphertext (both base64)
const ivBase64 = this.bytesToBase64(iv);
const ciphertextBase64 = this.bytesToBase64(new Uint8Array(encrypted));
return `${ivBase64}:${ciphertextBase64}`;
}
/**
* Decrypt an encrypted string
* Expects format: base64(iv):base64(ciphertext)
*/
public async decrypt(ciphertext: string): Promise<string> {
await this.initialize();
if (!this.masterKey) {
throw new Error('CryptoService not initialized');
}
const parts = ciphertext.split(':');
if (parts.length !== 2) {
throw new Error('Invalid ciphertext format');
}
const [ivBase64, encryptedBase64] = parts;
// Decode from base64
const iv = this.base64ToBytes(ivBase64);
const encrypted = this.base64ToBytes(encryptedBase64);
// Decrypt
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
this.masterKey,
encrypted
);
// Decode to string
return new TextDecoder().decode(decrypted);
}
/**
* Check if a string is already encrypted (contains the iv:ciphertext format)
*/
public isEncrypted(value: string): boolean {
if (!value || typeof value !== 'string') return false;
const parts = value.split(':');
if (parts.length !== 2) return false;
// Check if both parts look like base64
try {
this.base64ToBytes(parts[0]);
this.base64ToBytes(parts[1]);
return true;
} catch {
return false;
}
}
/**
* Import a hex key as CryptoKey
*/
private async importKey(keyHex: string): Promise<CryptoKey> {
const keyBytes = this.hexToBytes(keyHex);
return await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt']
);
}
/**
* Convert bytes to hex string
*/
private bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Convert hex string to bytes
*/
private hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
/**
* Convert bytes to base64
*/
private bytesToBase64(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes));
}
/**
* Convert base64 to bytes
*/
private base64ToBytes(base64: string): Uint8Array {
const binary = atob(base64);
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
}
/**
* Generate a new encryption key (for setup)
* Returns a 64-character hex string
*/
public static generateKey(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
}
// Singleton instance
export const cryptoService = new CryptoService();