encryption.service.ts raw

   1  import { Buffer } from 'buffer';
   2  import { CryptoHelper } from '../../helpers/crypto-helper';
   3  import {
   4    EncryptionContext,
   5    isV2Context,
   6  } from './encryption-context';
   7  
   8  /**
   9   * Service responsible for encrypting and decrypting data.
  10   * Abstracts away vault version differences (v1 PBKDF2 vs v2 Argon2id).
  11   *
  12   * This is an infrastructure service - it knows nothing about domain concepts,
  13   * only about cryptographic operations.
  14   */
  15  export class EncryptionService {
  16    constructor(private readonly context: EncryptionContext) {}
  17  
  18    /**
  19     * Encrypt a string value.
  20     */
  21    async encryptString(value: string): Promise<string> {
  22      if (isV2Context(this.context)) {
  23        return this.encryptWithKeyV2(value);
  24      }
  25      return CryptoHelper.encrypt(value, this.context.iv, this.context.password);
  26    }
  27  
  28    /**
  29     * Encrypt a number value (converts to string first).
  30     */
  31    async encryptNumber(value: number): Promise<string> {
  32      return this.encryptString(value.toString());
  33    }
  34  
  35    /**
  36     * Encrypt a boolean value (converts to string first).
  37     */
  38    async encryptBoolean(value: boolean): Promise<string> {
  39      return this.encryptString(value.toString());
  40    }
  41  
  42    /**
  43     * Decrypt a value to string.
  44     */
  45    async decryptString(encrypted: string): Promise<string> {
  46      if (isV2Context(this.context)) {
  47        return this.decryptWithKeyV2(encrypted);
  48      }
  49      return CryptoHelper.decrypt(encrypted, this.context.iv, this.context.password);
  50    }
  51  
  52    /**
  53     * Decrypt a value to number.
  54     */
  55    async decryptNumber(encrypted: string): Promise<number> {
  56      const decrypted = await this.decryptString(encrypted);
  57      return parseInt(decrypted, 10);
  58    }
  59  
  60    /**
  61     * Decrypt a value to boolean.
  62     */
  63    async decryptBoolean(encrypted: string): Promise<boolean> {
  64      const decrypted = await this.decryptString(encrypted);
  65      return decrypted === 'true';
  66    }
  67  
  68    /**
  69     * Get the encryption context (for serialization or passing to other services).
  70     */
  71    getContext(): EncryptionContext {
  72      return this.context;
  73    }
  74  
  75    // ─────────────────────────────────────────────────────────────────────────
  76    // V2 encryption/decryption using pre-derived Argon2id key
  77    // ─────────────────────────────────────────────────────────────────────────
  78  
  79    private async encryptWithKeyV2(text: string): Promise<string> {
  80      if (!isV2Context(this.context)) {
  81        throw new Error('V2 encryption requires keyBase64');
  82      }
  83  
  84      const keyBytes = Buffer.from(this.context.keyBase64, 'base64');
  85      const iv = Buffer.from(this.context.iv, 'base64');
  86  
  87      const key = await crypto.subtle.importKey(
  88        'raw',
  89        keyBytes,
  90        { name: 'AES-GCM' },
  91        false,
  92        ['encrypt']
  93      );
  94  
  95      const cipherText = await crypto.subtle.encrypt(
  96        { name: 'AES-GCM', iv },
  97        key,
  98        new TextEncoder().encode(text)
  99      );
 100  
 101      return Buffer.from(cipherText).toString('base64');
 102    }
 103  
 104    private async decryptWithKeyV2(encryptedBase64: string): Promise<string> {
 105      if (!isV2Context(this.context)) {
 106        throw new Error('V2 decryption requires keyBase64');
 107      }
 108  
 109      const keyBytes = Buffer.from(this.context.keyBase64, 'base64');
 110      const iv = Buffer.from(this.context.iv, 'base64');
 111      const cipherText = Buffer.from(encryptedBase64, 'base64');
 112  
 113      const key = await crypto.subtle.importKey(
 114        'raw',
 115        keyBytes,
 116        { name: 'AES-GCM' },
 117        false,
 118        ['decrypt']
 119      );
 120  
 121      const decrypted = await crypto.subtle.decrypt(
 122        { name: 'AES-GCM', iv },
 123        key,
 124        cipherText
 125      );
 126  
 127      return new TextDecoder().decode(decrypted);
 128    }
 129  }
 130  
 131  /**
 132   * Factory function to create an EncryptionService from session data.
 133   */
 134  export function createEncryptionService(params: {
 135    iv: string;
 136    vaultPassword?: string;
 137    vaultKey?: string;
 138  }): EncryptionService {
 139    if (params.vaultKey) {
 140      return new EncryptionService({
 141        version: 2,
 142        iv: params.iv,
 143        keyBase64: params.vaultKey,
 144      });
 145    }
 146  
 147    if (params.vaultPassword) {
 148      return new EncryptionService({
 149        version: 1,
 150        iv: params.iv,
 151        password: params.vaultPassword,
 152      });
 153    }
 154  
 155    throw new Error('Either vaultPassword or vaultKey must be provided');
 156  }
 157