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