179 lines
4.5 KiB
TypeScript
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();
|