feat(auth): Add external authentication (OAuth/OIDC & LDAP) with admin management, UI, and encryption support
This commit is contained in:
178
ts/services/crypto.service.ts
Normal file
178
ts/services/crypto.service.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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();
|
||||
Reference in New Issue
Block a user