@angular-helpers/security 21.0.3 → 21.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.es.md +52 -0
- package/README.md +208 -0
- package/fesm2022/angular-helpers-security.mjs +639 -2
- package/package.json +12 -5
- package/types/angular-helpers-security.d.ts +211 -3
package/README.es.md
CHANGED
|
@@ -15,6 +15,13 @@ Paquete de seguridad para aplicaciones Angular que previene ataques comunes como
|
|
|
15
15
|
- **Análisis de Complejidad**: Detecta patrones peligrosos antes de la ejecución.
|
|
16
16
|
- **Modo Seguro**: Solo permite patrones verificados como seguros.
|
|
17
17
|
|
|
18
|
+
### **Web Crypto API**
|
|
19
|
+
|
|
20
|
+
- **Cifrado/Descifrado**: Soporte AES-GCM para manejo seguro de datos
|
|
21
|
+
- **Hashing**: SHA-256 y otros algoritmos
|
|
22
|
+
- **Gestión de Claves**: Generar, importar y exportar claves criptográficas
|
|
23
|
+
- **Aleatorio Seguro**: Valores aleatorios criptográficamente seguros
|
|
24
|
+
|
|
18
25
|
### **Patrón Builder**
|
|
19
26
|
|
|
20
27
|
- **API Fluida**: Construye expresiones regulares de forma intuitiva.
|
|
@@ -93,6 +100,51 @@ const result = await RegexSecurityService.builder()
|
|
|
93
100
|
.execute('12345', this.securityService);
|
|
94
101
|
```
|
|
95
102
|
|
|
103
|
+
### **WebCryptoService**
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
import { WebCryptoService } from '@angular-helpers/security';
|
|
107
|
+
|
|
108
|
+
export class SecureStorageComponent {
|
|
109
|
+
private cryptoService = inject(WebCryptoService);
|
|
110
|
+
|
|
111
|
+
async hashPassword(password: string): Promise<string> {
|
|
112
|
+
return await this.cryptoService.hash(password, 'SHA-256');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async encryptData(
|
|
116
|
+
data: string,
|
|
117
|
+
): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array; key: CryptoKey }> {
|
|
118
|
+
const key = await this.cryptoService.generateAesKey(256);
|
|
119
|
+
const { ciphertext, iv } = await this.cryptoService.encryptAes(key, data);
|
|
120
|
+
return { ciphertext, iv, key };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async decryptData(ciphertext: ArrayBuffer, iv: Uint8Array, key: CryptoKey): Promise<string> {
|
|
124
|
+
return await this.cryptoService.decryptAes(key, ciphertext, iv);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async exportKeyForStorage(key: CryptoKey): Promise<JsonWebKey> {
|
|
128
|
+
return await this.cryptoService.exportKey(key);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async importKeyFromStorage(jwk: JsonWebKey): Promise<CryptoKey> {
|
|
132
|
+
return await this.cryptoService.importAesKey(jwk);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
generateSecureToken(length: number = 32): string {
|
|
136
|
+
const bytes = this.cryptoService.generateRandomBytes(length);
|
|
137
|
+
return Array.from(bytes)
|
|
138
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
139
|
+
.join('');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
generateUUID(): string {
|
|
143
|
+
return this.cryptoService.randomUUID();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
96
148
|
## 📊 Niveles de Riesgo
|
|
97
149
|
|
|
98
150
|
| Nivel | Descripción | Acción |
|
package/README.md
CHANGED
|
@@ -15,6 +15,36 @@ Security package for Angular applications that prevents common attacks like ReDo
|
|
|
15
15
|
- **Complexity Analysis**: Detects dangerous patterns before execution.
|
|
16
16
|
- **Safe Mode**: Only allows patterns verified as safe.
|
|
17
17
|
|
|
18
|
+
### **Web Crypto API**
|
|
19
|
+
|
|
20
|
+
- **Encryption/Decryption**: AES-GCM support for secure data handling
|
|
21
|
+
- **Hashing**: SHA-256 and other algorithms
|
|
22
|
+
- **HMAC Signing**: HMAC-SHA-256/384/512 for message authentication
|
|
23
|
+
- **Key Management**: Generate, import, and export cryptographic keys
|
|
24
|
+
- **Secure Random**: Cryptographically secure random values
|
|
25
|
+
- **UUID Generation**: RFC4122 v4 UUIDs
|
|
26
|
+
|
|
27
|
+
### **Secure Storage**
|
|
28
|
+
|
|
29
|
+
- **Transparent Encryption**: AES-GCM encrypted localStorage/sessionStorage
|
|
30
|
+
- **Ephemeral Mode**: In-memory keys for single-session security
|
|
31
|
+
- **Passphrase Mode**: PBKDF2-derived keys for cross-session persistence
|
|
32
|
+
- **Namespace Isolation**: Organize stored data with prefixes
|
|
33
|
+
|
|
34
|
+
### **Input Sanitization**
|
|
35
|
+
|
|
36
|
+
- **XSS Prevention**: Strip dangerous tags and attributes from HTML
|
|
37
|
+
- **URL Validation**: Allow only http/https schemes
|
|
38
|
+
- **HTML Escaping**: Safe interpolation of user content
|
|
39
|
+
- **JSON Safety**: Safe parsing without eval
|
|
40
|
+
|
|
41
|
+
### **Password Strength**
|
|
42
|
+
|
|
43
|
+
- **Entropy-Based Scoring**: 0-4 score with labeled strength levels
|
|
44
|
+
- **Pattern Detection**: Detects sequences, repetitions, keyboard walks
|
|
45
|
+
- **Common Password Check**: Blocks frequently used passwords
|
|
46
|
+
- **Feedback Messages**: Actionable improvement suggestions
|
|
47
|
+
|
|
18
48
|
### **Builder Pattern**
|
|
19
49
|
|
|
20
50
|
- **Fluent API**: Intuitively build regular expressions.
|
|
@@ -37,7 +67,16 @@ import { provideSecurity } from '@angular-helpers/security';
|
|
|
37
67
|
bootstrapApplication(AppComponent, {
|
|
38
68
|
providers: [
|
|
39
69
|
provideSecurity({
|
|
70
|
+
// Core services (enabled by default)
|
|
40
71
|
enableRegexSecurity: true,
|
|
72
|
+
enableWebCrypto: true,
|
|
73
|
+
|
|
74
|
+
// New services (opt-in, disabled by default)
|
|
75
|
+
enableSecureStorage: true,
|
|
76
|
+
enableInputSanitizer: true,
|
|
77
|
+
enablePasswordStrength: true,
|
|
78
|
+
|
|
79
|
+
// Global settings
|
|
41
80
|
defaultTimeout: 5000,
|
|
42
81
|
safeMode: false,
|
|
43
82
|
}),
|
|
@@ -45,6 +84,27 @@ bootstrapApplication(AppComponent, {
|
|
|
45
84
|
});
|
|
46
85
|
```
|
|
47
86
|
|
|
87
|
+
### **Individual Providers**
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
import {
|
|
91
|
+
provideRegexSecurity,
|
|
92
|
+
provideWebCrypto,
|
|
93
|
+
provideSecureStorage,
|
|
94
|
+
provideInputSanitizer,
|
|
95
|
+
providePasswordStrength,
|
|
96
|
+
} from '@angular-helpers/security';
|
|
97
|
+
|
|
98
|
+
// Use only the services you need
|
|
99
|
+
bootstrapApplication(AppComponent, {
|
|
100
|
+
providers: [
|
|
101
|
+
provideSecureStorage({ storage: 'session', pbkdf2Iterations: 600_000 }),
|
|
102
|
+
provideInputSanitizer({ allowedTags: ['b', 'i', 'em', 'strong'] }),
|
|
103
|
+
providePasswordStrength(),
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
48
108
|
### **Service Injection**
|
|
49
109
|
|
|
50
110
|
```typescript
|
|
@@ -163,6 +223,154 @@ export class FormValidationComponent {
|
|
|
163
223
|
}
|
|
164
224
|
```
|
|
165
225
|
|
|
226
|
+
### **WebCryptoService**
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
import { WebCryptoService } from '@angular-helpers/security';
|
|
230
|
+
|
|
231
|
+
export class SecureStorageComponent {
|
|
232
|
+
private cryptoService = inject(WebCryptoService);
|
|
233
|
+
|
|
234
|
+
async hashPassword(password: string): Promise<string> {
|
|
235
|
+
return await this.cryptoService.hash(password, 'SHA-256');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async encryptData(
|
|
239
|
+
data: string,
|
|
240
|
+
): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array; key: CryptoKey }> {
|
|
241
|
+
const key = await this.cryptoService.generateAesKey(256);
|
|
242
|
+
const { ciphertext, iv } = await this.cryptoService.encryptAes(key, data);
|
|
243
|
+
return { ciphertext, iv, key };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async decryptData(ciphertext: ArrayBuffer, iv: Uint8Array, key: CryptoKey): Promise<string> {
|
|
247
|
+
return await this.cryptoService.decryptAes(key, ciphertext, iv);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async exportKeyForStorage(key: CryptoKey): Promise<JsonWebKey> {
|
|
251
|
+
return await this.cryptoService.exportKey(key);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async importKeyFromStorage(jwk: JsonWebKey): Promise<CryptoKey> {
|
|
255
|
+
return await this.cryptoService.importAesKey(jwk);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
generateSecureToken(length: number = 32): string {
|
|
259
|
+
const bytes = this.cryptoService.generateRandomBytes(length);
|
|
260
|
+
return Array.from(bytes)
|
|
261
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
262
|
+
.join('');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
generateUUID(): string {
|
|
266
|
+
return this.cryptoService.randomUUID();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async signAndVerify(data: string): Promise<boolean> {
|
|
270
|
+
// Generate HMAC key for SHA-256
|
|
271
|
+
const key = await this.cryptoService.generateHmacKey('HMAC-SHA-256');
|
|
272
|
+
|
|
273
|
+
// Sign the data
|
|
274
|
+
const signature = await this.cryptoService.sign(key, data);
|
|
275
|
+
|
|
276
|
+
// Verify the signature
|
|
277
|
+
return await this.cryptoService.verify(key, data, signature);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### **SecureStorageService**
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
import { SecureStorageService } from '@angular-helpers/security';
|
|
286
|
+
|
|
287
|
+
export class UserSettingsComponent {
|
|
288
|
+
private storage = inject(SecureStorageService);
|
|
289
|
+
|
|
290
|
+
async saveUserToken(token: string): Promise<void> {
|
|
291
|
+
// Ephemeral mode (default): data survives only this session
|
|
292
|
+
await this.storage.set('authToken', { token, createdAt: Date.now() });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async getUserToken(): Promise<{ token: string; createdAt: number } | null> {
|
|
296
|
+
return await this.storage.get<{ token: string; createdAt: number }>('authToken');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async initWithPassphrase(passphrase: string): Promise<void> {
|
|
300
|
+
// Passphrase mode: data survives page reloads
|
|
301
|
+
await this.storage.initWithPassphrase(passphrase);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async saveWithNamespace(userId: string, data: unknown): Promise<void> {
|
|
305
|
+
// Namespace isolation
|
|
306
|
+
await this.storage.set('profile', data, `user:${userId}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
clearUserData(userId: string): void {
|
|
310
|
+
// Clear only this user's data
|
|
311
|
+
this.storage.clear(`user:${userId}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### **InputSanitizerService**
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
import { InputSanitizerService } from '@angular-helpers/security';
|
|
320
|
+
|
|
321
|
+
export class CommentComponent {
|
|
322
|
+
private sanitizer = inject(InputSanitizerService);
|
|
323
|
+
|
|
324
|
+
sanitizeUserComment(html: string): string {
|
|
325
|
+
// Strip dangerous tags, keep safe ones (b, i, em, a, etc.)
|
|
326
|
+
return this.sanitizer.sanitizeHtml(html);
|
|
327
|
+
// Example: '<b>Hello</b><script>alert(1)</script>' → '<b>Hello</b>'
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
validateUserLink(url: string): string | null {
|
|
331
|
+
// Only allow http/https URLs
|
|
332
|
+
return this.sanitizer.sanitizeUrl(url);
|
|
333
|
+
// Example: 'javascript:alert(1)' → null
|
|
334
|
+
// Example: 'https://example.com' → 'https://example.com/'
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
escapeForDisplay(text: string): string {
|
|
338
|
+
// Safe for HTML text nodes
|
|
339
|
+
return this.sanitizer.escapeHtml(text);
|
|
340
|
+
// Example: '<b>hello</b>' → '<b>hello</b>'
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
parseUserJson(json: string): unknown | null {
|
|
344
|
+
// Safe JSON parsing without eval
|
|
345
|
+
return this.sanitizer.sanitizeJson(json);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### **PasswordStrengthService**
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
import { PasswordStrengthService } from '@angular-helpers/security';
|
|
354
|
+
|
|
355
|
+
export class RegistrationComponent {
|
|
356
|
+
private passwordStrength = inject(PasswordStrengthService);
|
|
357
|
+
|
|
358
|
+
checkPasswordStrength(password: string): void {
|
|
359
|
+
const result = this.passwordStrength.assess(password);
|
|
360
|
+
|
|
361
|
+
console.log(`Score: ${result.score}/4`); // 0-4
|
|
362
|
+
console.log(`Label: ${result.label}`); // 'very-weak' to 'very-strong'
|
|
363
|
+
console.log(`Entropy: ${result.entropy} bits`); // calculated entropy
|
|
364
|
+
console.log('Feedback:', result.feedback); // improvement suggestions
|
|
365
|
+
|
|
366
|
+
// Example results:
|
|
367
|
+
// 'password' → score: 0, label: 'very-weak', feedback: ['This is a commonly used password']
|
|
368
|
+
// 'P@ssw0rd!' → score: 2, label: 'fair', feedback: ['Avoid keyboard patterns']
|
|
369
|
+
// 'xK#9mZ$vLq2@rBnT7' → score: 4, label: 'very-strong', feedback: []
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
166
374
|
## 🔧 Advanced Configuration
|
|
167
375
|
|
|
168
376
|
### **Security Options**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { inject, DestroyRef, Injectable, makeEnvironmentProviders } from '@angular/core';
|
|
2
|
+
import { inject, DestroyRef, Injectable, PLATFORM_ID, InjectionToken, makeEnvironmentProviders } from '@angular/core';
|
|
3
|
+
import { isPlatformBrowser } from '@angular/common';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Security service for regular expressions that prevents ReDoS
|
|
@@ -411,8 +412,614 @@ class RegexSecurityBuilder {
|
|
|
411
412
|
}
|
|
412
413
|
}
|
|
413
414
|
|
|
415
|
+
class WebCryptoService {
|
|
416
|
+
platformId = inject(PLATFORM_ID);
|
|
417
|
+
isSupported() {
|
|
418
|
+
return isPlatformBrowser(this.platformId) && 'crypto' in window && 'subtle' in crypto;
|
|
419
|
+
}
|
|
420
|
+
get subtle() {
|
|
421
|
+
if (!this.isSupported()) {
|
|
422
|
+
throw new Error('Web Crypto API not supported in this environment');
|
|
423
|
+
}
|
|
424
|
+
return crypto.subtle;
|
|
425
|
+
}
|
|
426
|
+
ensureSecureContext() {
|
|
427
|
+
if (!window.isSecureContext) {
|
|
428
|
+
throw new Error('Web Crypto API requires a secure context (HTTPS)');
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
async hash(data, algorithm = 'SHA-256') {
|
|
432
|
+
this.ensureSecureContext();
|
|
433
|
+
const buffer = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
|
434
|
+
const hashBuffer = await this.subtle.digest(algorithm, buffer);
|
|
435
|
+
return this.bufferToHex(hashBuffer);
|
|
436
|
+
}
|
|
437
|
+
async generateAesKey(length = 256) {
|
|
438
|
+
this.ensureSecureContext();
|
|
439
|
+
return this.subtle.generateKey({ name: 'AES-GCM', length }, true, ['encrypt', 'decrypt']);
|
|
440
|
+
}
|
|
441
|
+
async encryptAes(key, data) {
|
|
442
|
+
this.ensureSecureContext();
|
|
443
|
+
const buffer = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
|
444
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
445
|
+
const ciphertext = await this.subtle.encrypt({ name: 'AES-GCM', iv }, key, buffer);
|
|
446
|
+
return { ciphertext, iv };
|
|
447
|
+
}
|
|
448
|
+
async decryptAes(key, ciphertext, iv) {
|
|
449
|
+
this.ensureSecureContext();
|
|
450
|
+
const decrypted = await this.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
|
|
451
|
+
return new TextDecoder().decode(decrypted);
|
|
452
|
+
}
|
|
453
|
+
async exportKey(key) {
|
|
454
|
+
this.ensureSecureContext();
|
|
455
|
+
return this.subtle.exportKey('jwk', key);
|
|
456
|
+
}
|
|
457
|
+
async importAesKey(jwk) {
|
|
458
|
+
this.ensureSecureContext();
|
|
459
|
+
return this.subtle.importKey('jwk', jwk, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
|
|
460
|
+
}
|
|
461
|
+
generateRandomBytes(length) {
|
|
462
|
+
if (!this.isSupported()) {
|
|
463
|
+
throw new Error('Web Crypto API not supported');
|
|
464
|
+
}
|
|
465
|
+
return crypto.getRandomValues(new Uint8Array(length));
|
|
466
|
+
}
|
|
467
|
+
randomUUID() {
|
|
468
|
+
if (!this.isSupported()) {
|
|
469
|
+
throw new Error('Web Crypto API not supported');
|
|
470
|
+
}
|
|
471
|
+
return crypto.randomUUID();
|
|
472
|
+
}
|
|
473
|
+
async generateHmacKey(algorithm = 'HMAC-SHA-256') {
|
|
474
|
+
this.ensureSecureContext();
|
|
475
|
+
return this.subtle.generateKey({ name: 'HMAC', hash: { name: this.hmacHashName(algorithm) } }, true, ['sign', 'verify']);
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Signs data with an HMAC key. Returns a hex-encoded signature.
|
|
479
|
+
*/
|
|
480
|
+
async sign(key, data) {
|
|
481
|
+
this.ensureSecureContext();
|
|
482
|
+
const buffer = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
|
483
|
+
const signature = await this.subtle.sign('HMAC', key, buffer);
|
|
484
|
+
return this.bufferToHex(signature);
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Verifies an HMAC signature (hex-encoded). Returns false for malformed input — never throws.
|
|
488
|
+
*/
|
|
489
|
+
async verify(key, data, signature) {
|
|
490
|
+
this.ensureSecureContext();
|
|
491
|
+
let sigBytes;
|
|
492
|
+
try {
|
|
493
|
+
sigBytes = this.hexToBytes(signature);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const buffer = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
|
500
|
+
return await this.subtle.verify('HMAC', key, sigBytes, buffer);
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async importHmacKey(jwk, algorithm = 'HMAC-SHA-256') {
|
|
507
|
+
this.ensureSecureContext();
|
|
508
|
+
return this.subtle.importKey('jwk', jwk, { name: 'HMAC', hash: { name: this.hmacHashName(algorithm) } }, true, ['sign', 'verify']);
|
|
509
|
+
}
|
|
510
|
+
bufferToHex(buffer) {
|
|
511
|
+
return Array.from(new Uint8Array(buffer))
|
|
512
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
513
|
+
.join('');
|
|
514
|
+
}
|
|
515
|
+
hexToBytes(hex) {
|
|
516
|
+
if (hex.length % 2 !== 0)
|
|
517
|
+
throw new Error('Invalid hex string');
|
|
518
|
+
const bytes = new Uint8Array(new ArrayBuffer(hex.length / 2));
|
|
519
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
520
|
+
const byte = parseInt(hex.substring(i, i + 2), 16);
|
|
521
|
+
if (isNaN(byte))
|
|
522
|
+
throw new Error('Invalid hex string');
|
|
523
|
+
bytes[i / 2] = byte;
|
|
524
|
+
}
|
|
525
|
+
return bytes;
|
|
526
|
+
}
|
|
527
|
+
hmacHashName(algorithm) {
|
|
528
|
+
return algorithm.replace('HMAC-', '');
|
|
529
|
+
}
|
|
530
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: WebCryptoService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
531
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: WebCryptoService });
|
|
532
|
+
}
|
|
533
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: WebCryptoService, decorators: [{
|
|
534
|
+
type: Injectable
|
|
535
|
+
}] });
|
|
536
|
+
|
|
537
|
+
const SECURE_STORAGE_CONFIG = new InjectionToken('SECURE_STORAGE_CONFIG');
|
|
538
|
+
const SALT_STORAGE_KEY = '__ss_salt__';
|
|
539
|
+
const DEFAULT_ITERATIONS = 600_000;
|
|
540
|
+
/**
|
|
541
|
+
* Service for transparent AES-GCM encrypted storage on top of localStorage/sessionStorage.
|
|
542
|
+
*
|
|
543
|
+
* Two key modes are supported:
|
|
544
|
+
* - **Ephemeral** (default): a CryptoKey is generated in memory per service instance.
|
|
545
|
+
* Data is unrecoverable after page reload or service re-creation.
|
|
546
|
+
* - **Passphrase-derived**: call `initWithPassphrase(passphrase)` to derive a stable key
|
|
547
|
+
* via PBKDF2. Data survives page reloads as long as the same passphrase is used.
|
|
548
|
+
*
|
|
549
|
+
* @example
|
|
550
|
+
* // Ephemeral mode
|
|
551
|
+
* await storage.set('token', { value: 'abc' });
|
|
552
|
+
* const token = await storage.get<{ value: string }>('token');
|
|
553
|
+
*
|
|
554
|
+
* @example
|
|
555
|
+
* // Passphrase mode
|
|
556
|
+
* await storage.initWithPassphrase('my-secret');
|
|
557
|
+
* await storage.set('user', { id: 1 }, 'auth');
|
|
558
|
+
* const user = await storage.get<{ id: number }>('user', 'auth');
|
|
559
|
+
*/
|
|
560
|
+
class SecureStorageService {
|
|
561
|
+
platformId = inject(PLATFORM_ID);
|
|
562
|
+
storageConfig;
|
|
563
|
+
activeKey = null;
|
|
564
|
+
constructor() {
|
|
565
|
+
const config = inject(SECURE_STORAGE_CONFIG, { optional: true }) ?? {};
|
|
566
|
+
this.storageConfig = {
|
|
567
|
+
storage: config.storage ?? 'local',
|
|
568
|
+
pbkdf2Iterations: config.pbkdf2Iterations ?? DEFAULT_ITERATIONS,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
isSupported() {
|
|
572
|
+
return (isPlatformBrowser(this.platformId) &&
|
|
573
|
+
'crypto' in window &&
|
|
574
|
+
'subtle' in crypto &&
|
|
575
|
+
'localStorage' in window);
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Initializes the service with a passphrase-derived key (PBKDF2 + AES-GCM).
|
|
579
|
+
* The salt is automatically persisted in storage on first call and reused on subsequent calls.
|
|
580
|
+
* Calling this again replaces the active key.
|
|
581
|
+
*
|
|
582
|
+
* @param passphrase Secret passphrase for key derivation.
|
|
583
|
+
* @param explicitSalt Optional base64 salt. When provided, the stored salt is ignored.
|
|
584
|
+
*/
|
|
585
|
+
async initWithPassphrase(passphrase, explicitSalt) {
|
|
586
|
+
this.assertSupported();
|
|
587
|
+
let salt;
|
|
588
|
+
if (explicitSalt) {
|
|
589
|
+
salt = this.base64ToBytes(explicitSalt);
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
const stored = this.nativeStorage.getItem(SALT_STORAGE_KEY);
|
|
593
|
+
if (stored) {
|
|
594
|
+
salt = this.base64ToBytes(stored);
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
salt = crypto.getRandomValues(new Uint8Array(new ArrayBuffer(16)));
|
|
598
|
+
this.nativeStorage.setItem(SALT_STORAGE_KEY, this.bytesToBase64(salt));
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const keyMaterial = await crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveKey']);
|
|
602
|
+
this.activeKey = await crypto.subtle.deriveKey({
|
|
603
|
+
name: 'PBKDF2',
|
|
604
|
+
salt,
|
|
605
|
+
iterations: this.storageConfig.pbkdf2Iterations,
|
|
606
|
+
hash: 'SHA-256',
|
|
607
|
+
}, keyMaterial, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Encrypts and stores a value.
|
|
611
|
+
* A fresh random IV is generated for every write.
|
|
612
|
+
*
|
|
613
|
+
* @throws {TypeError} When `value` is `undefined`.
|
|
614
|
+
* @throws {DOMException} When storage quota is exceeded.
|
|
615
|
+
*/
|
|
616
|
+
async set(key, value, namespace) {
|
|
617
|
+
this.assertSupported();
|
|
618
|
+
if (value === undefined) {
|
|
619
|
+
throw new TypeError('Cannot store undefined value in SecureStorageService');
|
|
620
|
+
}
|
|
621
|
+
const cryptoKey = await this.ensureKey();
|
|
622
|
+
const iv = crypto.getRandomValues(new Uint8Array(new ArrayBuffer(12)));
|
|
623
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(value));
|
|
624
|
+
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, plaintext);
|
|
625
|
+
const entry = {
|
|
626
|
+
iv: this.bytesToBase64(iv),
|
|
627
|
+
ct: this.bytesToBase64(ciphertext),
|
|
628
|
+
};
|
|
629
|
+
this.nativeStorage.setItem(this.buildStorageKey(key, namespace), JSON.stringify(entry));
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Decrypts and returns a stored value.
|
|
633
|
+
* Returns `null` if the key does not exist, was written without encryption,
|
|
634
|
+
* or the ciphertext is corrupted.
|
|
635
|
+
*/
|
|
636
|
+
async get(key, namespace) {
|
|
637
|
+
this.assertSupported();
|
|
638
|
+
const raw = this.nativeStorage.getItem(this.buildStorageKey(key, namespace));
|
|
639
|
+
if (!raw)
|
|
640
|
+
return null;
|
|
641
|
+
let entry;
|
|
642
|
+
try {
|
|
643
|
+
const parsed = JSON.parse(raw);
|
|
644
|
+
if (!parsed || typeof parsed !== 'object' || !('iv' in parsed) || !('ct' in parsed)) {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
entry = parsed;
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
try {
|
|
653
|
+
const cryptoKey = await this.ensureKey();
|
|
654
|
+
const iv = this.base64ToBytes(entry.iv);
|
|
655
|
+
const ciphertext = this.base64ToBytes(entry.ct);
|
|
656
|
+
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, cryptoKey, ciphertext);
|
|
657
|
+
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Removes a single entry from storage.
|
|
665
|
+
*/
|
|
666
|
+
remove(key, namespace) {
|
|
667
|
+
this.assertSupported();
|
|
668
|
+
this.nativeStorage.removeItem(this.buildStorageKey(key, namespace));
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Clears all entries belonging to a namespace.
|
|
672
|
+
* When called without arguments, clears the entire storage target.
|
|
673
|
+
*/
|
|
674
|
+
clear(namespace) {
|
|
675
|
+
this.assertSupported();
|
|
676
|
+
if (!namespace) {
|
|
677
|
+
this.nativeStorage.clear();
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const prefix = `${namespace}:`;
|
|
681
|
+
const keysToRemove = [];
|
|
682
|
+
for (let i = 0; i < this.nativeStorage.length; i++) {
|
|
683
|
+
const k = this.nativeStorage.key(i);
|
|
684
|
+
if (k?.startsWith(prefix)) {
|
|
685
|
+
keysToRemove.push(k);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
keysToRemove.forEach((k) => this.nativeStorage.removeItem(k));
|
|
689
|
+
}
|
|
690
|
+
get nativeStorage() {
|
|
691
|
+
return this.storageConfig.storage === 'session' ? sessionStorage : localStorage;
|
|
692
|
+
}
|
|
693
|
+
buildStorageKey(key, namespace) {
|
|
694
|
+
return namespace ? `${namespace}:${key}` : key;
|
|
695
|
+
}
|
|
696
|
+
async ensureKey() {
|
|
697
|
+
if (this.activeKey)
|
|
698
|
+
return this.activeKey;
|
|
699
|
+
this.activeKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [
|
|
700
|
+
'encrypt',
|
|
701
|
+
'decrypt',
|
|
702
|
+
]);
|
|
703
|
+
return this.activeKey;
|
|
704
|
+
}
|
|
705
|
+
assertSupported() {
|
|
706
|
+
if (!this.isSupported()) {
|
|
707
|
+
throw new Error('SecureStorageService is not supported in this environment (requires browser + Web Crypto API)');
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
bytesToBase64(buffer) {
|
|
711
|
+
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
|
712
|
+
let binary = '';
|
|
713
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
714
|
+
binary += String.fromCharCode(bytes[i]);
|
|
715
|
+
}
|
|
716
|
+
return btoa(binary);
|
|
717
|
+
}
|
|
718
|
+
base64ToBytes(base64) {
|
|
719
|
+
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
|
720
|
+
}
|
|
721
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SecureStorageService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
722
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SecureStorageService });
|
|
723
|
+
}
|
|
724
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SecureStorageService, decorators: [{
|
|
725
|
+
type: Injectable
|
|
726
|
+
}], ctorParameters: () => [] });
|
|
727
|
+
|
|
728
|
+
const SANITIZER_CONFIG = new InjectionToken('SANITIZER_CONFIG');
|
|
729
|
+
const DEFAULT_ALLOWED_TAGS = [
|
|
730
|
+
'b',
|
|
731
|
+
'i',
|
|
732
|
+
'em',
|
|
733
|
+
'strong',
|
|
734
|
+
'a',
|
|
735
|
+
'p',
|
|
736
|
+
'br',
|
|
737
|
+
'ul',
|
|
738
|
+
'ol',
|
|
739
|
+
'li',
|
|
740
|
+
'span',
|
|
741
|
+
];
|
|
742
|
+
const DEFAULT_ALLOWED_ATTRIBUTES = {
|
|
743
|
+
a: ['href'],
|
|
744
|
+
};
|
|
745
|
+
const SAFE_URL_SCHEMES = ['http:', 'https:'];
|
|
746
|
+
/**
|
|
747
|
+
* Service for structured input sanitization to defend against XSS, URL injection, and unsafe HTML.
|
|
748
|
+
*
|
|
749
|
+
* This service is defense-in-depth and DOES NOT replace a Content Security Policy (CSP).
|
|
750
|
+
* Always configure a proper CSP alongside using this service.
|
|
751
|
+
*
|
|
752
|
+
* @example
|
|
753
|
+
* const clean = sanitizer.sanitizeHtml('<b>Hello</b><script>alert(1)</script>');
|
|
754
|
+
* // → '<b>Hello</b>'
|
|
755
|
+
*
|
|
756
|
+
* @example
|
|
757
|
+
* const url = sanitizer.sanitizeUrl('javascript:alert(1)');
|
|
758
|
+
* // → null
|
|
759
|
+
*/
|
|
760
|
+
class InputSanitizerService {
|
|
761
|
+
platformId = inject(PLATFORM_ID);
|
|
762
|
+
allowedTags;
|
|
763
|
+
allowedAttributes;
|
|
764
|
+
constructor() {
|
|
765
|
+
const config = inject(SANITIZER_CONFIG, { optional: true }) ?? {};
|
|
766
|
+
this.allowedTags = new Set(config.allowedTags ?? DEFAULT_ALLOWED_TAGS);
|
|
767
|
+
this.allowedAttributes = config.allowedAttributes ?? DEFAULT_ALLOWED_ATTRIBUTES;
|
|
768
|
+
}
|
|
769
|
+
isSupported() {
|
|
770
|
+
return isPlatformBrowser(this.platformId) && typeof DOMParser !== 'undefined';
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Parses and sanitizes an HTML string, keeping only allowed tags and attributes.
|
|
774
|
+
* Script execution is prevented — parsing is done via DOMParser, not innerHTML assignment.
|
|
775
|
+
*
|
|
776
|
+
* @throws {Error} When called in a non-browser environment.
|
|
777
|
+
*/
|
|
778
|
+
sanitizeHtml(input) {
|
|
779
|
+
if (!this.isSupported()) {
|
|
780
|
+
throw new Error('sanitizeHtml requires a browser environment (DOMParser unavailable)');
|
|
781
|
+
}
|
|
782
|
+
if (!input)
|
|
783
|
+
return '';
|
|
784
|
+
const parser = new DOMParser();
|
|
785
|
+
const doc = parser.parseFromString(input, 'text/html');
|
|
786
|
+
this.processNode(doc.body);
|
|
787
|
+
return doc.body.innerHTML;
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Validates and normalizes a URL string.
|
|
791
|
+
* Returns the normalized URL only for `http:` and `https:` schemes.
|
|
792
|
+
* Returns `null` for `javascript:`, `data:`, `vbscript:`, relative URLs, or malformed input.
|
|
793
|
+
*/
|
|
794
|
+
sanitizeUrl(input) {
|
|
795
|
+
if (!input)
|
|
796
|
+
return null;
|
|
797
|
+
try {
|
|
798
|
+
const url = new URL(input);
|
|
799
|
+
return SAFE_URL_SCHEMES.includes(url.protocol) ? url.toString() : null;
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Escapes HTML special characters for safe text interpolation.
|
|
807
|
+
* Use this when inserting user content into HTML text nodes or attributes.
|
|
808
|
+
*/
|
|
809
|
+
escapeHtml(input) {
|
|
810
|
+
if (!input)
|
|
811
|
+
return '';
|
|
812
|
+
return input
|
|
813
|
+
.replace(/&/g, '&')
|
|
814
|
+
.replace(/</g, '<')
|
|
815
|
+
.replace(/>/g, '>')
|
|
816
|
+
.replace(/"/g, '"')
|
|
817
|
+
.replace(/'/g, ''');
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Safely parses a JSON string. Returns the parsed value on success, `null` on any error.
|
|
821
|
+
* Does NOT use `eval` or `Function` — uses JSON.parse only.
|
|
822
|
+
*/
|
|
823
|
+
sanitizeJson(input) {
|
|
824
|
+
if (!input)
|
|
825
|
+
return null;
|
|
826
|
+
try {
|
|
827
|
+
return JSON.parse(input);
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
processNode(node) {
|
|
834
|
+
const children = Array.from(node.childNodes);
|
|
835
|
+
for (const child of children) {
|
|
836
|
+
if (child.nodeType !== Node.ELEMENT_NODE)
|
|
837
|
+
continue;
|
|
838
|
+
const element = child;
|
|
839
|
+
const tagName = element.tagName.toLowerCase();
|
|
840
|
+
if (!this.allowedTags.has(tagName)) {
|
|
841
|
+
const text = element.textContent ?? '';
|
|
842
|
+
node.replaceChild(document.createTextNode(text), element);
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
this.sanitizeAttributes(element, tagName);
|
|
846
|
+
this.processNode(element);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
sanitizeAttributes(element, tagName) {
|
|
850
|
+
const attrsToRemove = [];
|
|
851
|
+
const allowed = this.allowedAttributes[tagName] ?? [];
|
|
852
|
+
for (let i = 0; i < element.attributes.length; i++) {
|
|
853
|
+
const attr = element.attributes[i];
|
|
854
|
+
if (attr.name.startsWith('on')) {
|
|
855
|
+
attrsToRemove.push(attr.name);
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (!allowed.includes(attr.name)) {
|
|
859
|
+
attrsToRemove.push(attr.name);
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
if (attr.name === 'href' && this.sanitizeUrl(attr.value) === null) {
|
|
863
|
+
attrsToRemove.push(attr.name);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
attrsToRemove.forEach((name) => element.removeAttribute(name));
|
|
867
|
+
}
|
|
868
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: InputSanitizerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
869
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: InputSanitizerService });
|
|
870
|
+
}
|
|
871
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: InputSanitizerService, decorators: [{
|
|
872
|
+
type: Injectable
|
|
873
|
+
}], ctorParameters: () => [] });
|
|
874
|
+
|
|
875
|
+
const COMMON_PASSWORDS = new Set([
|
|
876
|
+
'password',
|
|
877
|
+
'123456',
|
|
878
|
+
'qwerty',
|
|
879
|
+
'letmein',
|
|
880
|
+
'admin',
|
|
881
|
+
'welcome',
|
|
882
|
+
'111111',
|
|
883
|
+
'abc123',
|
|
884
|
+
'monkey',
|
|
885
|
+
'master',
|
|
886
|
+
'login',
|
|
887
|
+
'pass',
|
|
888
|
+
]);
|
|
889
|
+
const ALPHA_SEQUENCES = 'abcdefghijklmnopqrstuvwxyz';
|
|
890
|
+
const DIGIT_SEQUENCES = '0123456789';
|
|
891
|
+
const KEYBOARD_ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
|
|
892
|
+
const SCORE_LABELS = {
|
|
893
|
+
0: 'very-weak',
|
|
894
|
+
1: 'weak',
|
|
895
|
+
2: 'fair',
|
|
896
|
+
3: 'strong',
|
|
897
|
+
4: 'very-strong',
|
|
898
|
+
};
|
|
899
|
+
/**
|
|
900
|
+
* Service for entropy-based password strength evaluation.
|
|
901
|
+
* All methods are synchronous and side-effect free — safely wrappable in Angular `computed()`.
|
|
902
|
+
*
|
|
903
|
+
* Score thresholds (bits of entropy):
|
|
904
|
+
* - 0 (very-weak): < 28 bits
|
|
905
|
+
* - 1 (weak): 28–35 bits
|
|
906
|
+
* - 2 (fair): 36–49 bits
|
|
907
|
+
* - 3 (strong): 50–69 bits
|
|
908
|
+
* - 4 (very-strong): ≥ 70 bits
|
|
909
|
+
*
|
|
910
|
+
* @example
|
|
911
|
+
* const result = passwordStrength.assess('P@ssw0rd!');
|
|
912
|
+
* console.log(result.score); // 2
|
|
913
|
+
* console.log(result.label); // 'fair'
|
|
914
|
+
* console.log(result.entropy); // ~42.5
|
|
915
|
+
*/
|
|
916
|
+
class PasswordStrengthService {
|
|
917
|
+
/**
|
|
918
|
+
* Evaluates the strength of a password.
|
|
919
|
+
* Never throws — returns score 0 for empty or null-like input.
|
|
920
|
+
*/
|
|
921
|
+
assess(password) {
|
|
922
|
+
if (!password) {
|
|
923
|
+
return {
|
|
924
|
+
score: 0,
|
|
925
|
+
label: 'very-weak',
|
|
926
|
+
entropy: 0,
|
|
927
|
+
feedback: ['Password cannot be empty'],
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
const feedback = [];
|
|
931
|
+
const chars = [...password];
|
|
932
|
+
const length = chars.length;
|
|
933
|
+
const hasLower = /[a-z]/.test(password);
|
|
934
|
+
const hasUpper = /[A-Z]/.test(password);
|
|
935
|
+
const hasDigit = /[0-9]/.test(password);
|
|
936
|
+
const hasSymbol = /[!@#$%^&*()\-_=+[\]{}|;:'",.<>/?\\`~]/.test(password);
|
|
937
|
+
const hasExtended = chars.some((c) => c.codePointAt(0) > 127);
|
|
938
|
+
let poolSize = 0;
|
|
939
|
+
if (hasLower)
|
|
940
|
+
poolSize += 26;
|
|
941
|
+
if (hasUpper)
|
|
942
|
+
poolSize += 26;
|
|
943
|
+
if (hasDigit)
|
|
944
|
+
poolSize += 10;
|
|
945
|
+
if (hasSymbol)
|
|
946
|
+
poolSize += 32;
|
|
947
|
+
if (hasExtended)
|
|
948
|
+
poolSize += 64;
|
|
949
|
+
if (poolSize === 0)
|
|
950
|
+
poolSize = 26;
|
|
951
|
+
let entropy = length * Math.log2(poolSize);
|
|
952
|
+
const hasAlphaSeq = this.containsSequence(password.toLowerCase(), ALPHA_SEQUENCES, 3);
|
|
953
|
+
const hasDigitSeq = this.containsSequence(password, DIGIT_SEQUENCES, 3);
|
|
954
|
+
const hasRepeat = /(.)\1{2,}/.test(password);
|
|
955
|
+
const hasKeyboard = KEYBOARD_ROWS.some((row) => this.containsSequence(password.toLowerCase(), row, 4));
|
|
956
|
+
if (hasAlphaSeq || hasDigitSeq) {
|
|
957
|
+
entropy *= 0.8;
|
|
958
|
+
feedback.push('Avoid predictable sequences (abc, 123)');
|
|
959
|
+
}
|
|
960
|
+
if (hasRepeat) {
|
|
961
|
+
entropy *= 0.9;
|
|
962
|
+
feedback.push('Avoid repeated characters');
|
|
963
|
+
}
|
|
964
|
+
if (hasKeyboard) {
|
|
965
|
+
entropy *= 0.85;
|
|
966
|
+
feedback.push('Avoid keyboard patterns (qwerty, asdf)');
|
|
967
|
+
}
|
|
968
|
+
if (length < 8) {
|
|
969
|
+
feedback.push('Use at least 8 characters');
|
|
970
|
+
}
|
|
971
|
+
if (!hasUpper)
|
|
972
|
+
feedback.push('Add uppercase letters');
|
|
973
|
+
if (!hasDigit)
|
|
974
|
+
feedback.push('Add numbers');
|
|
975
|
+
if (!hasSymbol)
|
|
976
|
+
feedback.push('Add special characters');
|
|
977
|
+
const isCommon = COMMON_PASSWORDS.has(password.toLowerCase());
|
|
978
|
+
let score = this.entropyToScore(entropy);
|
|
979
|
+
if (isCommon) {
|
|
980
|
+
score = Math.min(score, 1);
|
|
981
|
+
if (!feedback.includes('Use at least 8 characters')) {
|
|
982
|
+
feedback.push('This is a commonly used password');
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return {
|
|
986
|
+
score,
|
|
987
|
+
label: SCORE_LABELS[score],
|
|
988
|
+
entropy: Math.round(entropy * 100) / 100,
|
|
989
|
+
feedback,
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
entropyToScore(entropy) {
|
|
993
|
+
if (entropy < 28)
|
|
994
|
+
return 0;
|
|
995
|
+
if (entropy < 36)
|
|
996
|
+
return 1;
|
|
997
|
+
if (entropy < 50)
|
|
998
|
+
return 2;
|
|
999
|
+
if (entropy < 70)
|
|
1000
|
+
return 3;
|
|
1001
|
+
return 4;
|
|
1002
|
+
}
|
|
1003
|
+
containsSequence(input, sequence, minLength) {
|
|
1004
|
+
for (let i = 0; i <= sequence.length - minLength; i++) {
|
|
1005
|
+
if (input.includes(sequence.substring(i, i + minLength)))
|
|
1006
|
+
return true;
|
|
1007
|
+
}
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: PasswordStrengthService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1011
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: PasswordStrengthService });
|
|
1012
|
+
}
|
|
1013
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: PasswordStrengthService, decorators: [{
|
|
1014
|
+
type: Injectable
|
|
1015
|
+
}] });
|
|
1016
|
+
|
|
414
1017
|
const defaultSecurityConfig = {
|
|
415
1018
|
enableRegexSecurity: true,
|
|
1019
|
+
enableWebCrypto: true,
|
|
1020
|
+
enableSecureStorage: false,
|
|
1021
|
+
enableInputSanitizer: false,
|
|
1022
|
+
enablePasswordStrength: false,
|
|
416
1023
|
defaultTimeout: 5000,
|
|
417
1024
|
safeMode: false,
|
|
418
1025
|
};
|
|
@@ -422,14 +1029,44 @@ function provideSecurity(config = {}) {
|
|
|
422
1029
|
if (mergedConfig.enableRegexSecurity) {
|
|
423
1030
|
providers.push(RegexSecurityService);
|
|
424
1031
|
}
|
|
1032
|
+
if (mergedConfig.enableWebCrypto) {
|
|
1033
|
+
providers.push(WebCryptoService);
|
|
1034
|
+
}
|
|
1035
|
+
if (mergedConfig.enableSecureStorage) {
|
|
1036
|
+
providers.push(SecureStorageService);
|
|
1037
|
+
}
|
|
1038
|
+
if (mergedConfig.enableInputSanitizer) {
|
|
1039
|
+
providers.push(InputSanitizerService);
|
|
1040
|
+
}
|
|
1041
|
+
if (mergedConfig.enablePasswordStrength) {
|
|
1042
|
+
providers.push(PasswordStrengthService);
|
|
1043
|
+
}
|
|
425
1044
|
return makeEnvironmentProviders(providers);
|
|
426
1045
|
}
|
|
427
1046
|
function provideRegexSecurity() {
|
|
428
1047
|
return makeEnvironmentProviders([RegexSecurityService]);
|
|
429
1048
|
}
|
|
1049
|
+
function provideWebCrypto() {
|
|
1050
|
+
return makeEnvironmentProviders([WebCryptoService]);
|
|
1051
|
+
}
|
|
1052
|
+
function provideSecureStorage(config) {
|
|
1053
|
+
return makeEnvironmentProviders([
|
|
1054
|
+
SecureStorageService,
|
|
1055
|
+
...(config ? [{ provide: SECURE_STORAGE_CONFIG, useValue: config }] : []),
|
|
1056
|
+
]);
|
|
1057
|
+
}
|
|
1058
|
+
function provideInputSanitizer(config) {
|
|
1059
|
+
return makeEnvironmentProviders([
|
|
1060
|
+
InputSanitizerService,
|
|
1061
|
+
...(config ? [{ provide: SANITIZER_CONFIG, useValue: config }] : []),
|
|
1062
|
+
]);
|
|
1063
|
+
}
|
|
1064
|
+
function providePasswordStrength() {
|
|
1065
|
+
return makeEnvironmentProviders([PasswordStrengthService]);
|
|
1066
|
+
}
|
|
430
1067
|
|
|
431
1068
|
/**
|
|
432
1069
|
* Generated bundle index. Do not edit.
|
|
433
1070
|
*/
|
|
434
1071
|
|
|
435
|
-
export { RegexSecurityBuilder, RegexSecurityService, defaultSecurityConfig, provideRegexSecurity, provideSecurity };
|
|
1072
|
+
export { InputSanitizerService, PasswordStrengthService, RegexSecurityBuilder, RegexSecurityService, SANITIZER_CONFIG, SECURE_STORAGE_CONFIG, SecureStorageService, WebCryptoService, defaultSecurityConfig, provideInputSanitizer, providePasswordStrength, provideRegexSecurity, provideSecureStorage, provideSecurity, provideWebCrypto };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@angular-helpers/security",
|
|
3
|
-
"version": "21.0
|
|
3
|
+
"version": "21.1.0",
|
|
4
4
|
"description": "Angular security helpers for preventing ReDoS and other security vulnerabilities",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"angular",
|
|
@@ -9,18 +9,25 @@
|
|
|
9
9
|
"redos",
|
|
10
10
|
"prevention",
|
|
11
11
|
"web-worker",
|
|
12
|
-
"builder-pattern"
|
|
12
|
+
"builder-pattern",
|
|
13
|
+
"encryption",
|
|
14
|
+
"web-crypto",
|
|
15
|
+
"hmac",
|
|
16
|
+
"xss",
|
|
17
|
+
"sanitization",
|
|
18
|
+
"password-strength",
|
|
19
|
+
"secure-storage"
|
|
13
20
|
],
|
|
14
21
|
"author": "Angular Helpers Team",
|
|
15
22
|
"license": "MIT",
|
|
16
23
|
"repository": {
|
|
17
24
|
"type": "git",
|
|
18
|
-
"url": "https://github.com/angular-helpers
|
|
25
|
+
"url": "https://github.com/Gaspar1992/angular-helpers"
|
|
19
26
|
},
|
|
20
27
|
"bugs": {
|
|
21
|
-
"url": "https://github.com/angular-helpers/
|
|
28
|
+
"url": "https://github.com/Gaspar1992/angular-helpers/issues"
|
|
22
29
|
},
|
|
23
|
-
"homepage": "https://github.
|
|
30
|
+
"homepage": "https://gaspar1992.github.io/angular-helpers/docs/security",
|
|
24
31
|
"publishConfig": {
|
|
25
32
|
"access": "public"
|
|
26
33
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { EnvironmentProviders } from '@angular/core';
|
|
2
|
+
import { InjectionToken, EnvironmentProviders } from '@angular/core';
|
|
3
3
|
|
|
4
4
|
interface RegexSecurityConfig {
|
|
5
5
|
timeout?: number;
|
|
@@ -151,14 +151,222 @@ declare class RegexSecurityBuilder {
|
|
|
151
151
|
execute(text: string, service: RegexSecurityService): Promise<RegexTestResult>;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
type HashAlgorithm = 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512';
|
|
155
|
+
type HmacAlgorithm = 'HMAC-SHA-256' | 'HMAC-SHA-384' | 'HMAC-SHA-512';
|
|
156
|
+
type AesKeyLength = 128 | 192 | 256;
|
|
157
|
+
interface AesEncryptResult {
|
|
158
|
+
ciphertext: ArrayBuffer;
|
|
159
|
+
iv: Uint8Array;
|
|
160
|
+
}
|
|
161
|
+
declare class WebCryptoService {
|
|
162
|
+
private readonly platformId;
|
|
163
|
+
isSupported(): boolean;
|
|
164
|
+
private get subtle();
|
|
165
|
+
private ensureSecureContext;
|
|
166
|
+
hash(data: string | ArrayBuffer, algorithm?: HashAlgorithm): Promise<string>;
|
|
167
|
+
generateAesKey(length?: AesKeyLength): Promise<CryptoKey>;
|
|
168
|
+
encryptAes(key: CryptoKey, data: string | ArrayBuffer): Promise<AesEncryptResult>;
|
|
169
|
+
decryptAes(key: CryptoKey, ciphertext: ArrayBuffer, iv: Uint8Array<ArrayBuffer>): Promise<string>;
|
|
170
|
+
exportKey(key: CryptoKey): Promise<JsonWebKey>;
|
|
171
|
+
importAesKey(jwk: JsonWebKey): Promise<CryptoKey>;
|
|
172
|
+
generateRandomBytes(length: number): Uint8Array;
|
|
173
|
+
randomUUID(): string;
|
|
174
|
+
generateHmacKey(algorithm?: HmacAlgorithm): Promise<CryptoKey>;
|
|
175
|
+
/**
|
|
176
|
+
* Signs data with an HMAC key. Returns a hex-encoded signature.
|
|
177
|
+
*/
|
|
178
|
+
sign(key: CryptoKey, data: string | ArrayBuffer): Promise<string>;
|
|
179
|
+
/**
|
|
180
|
+
* Verifies an HMAC signature (hex-encoded). Returns false for malformed input — never throws.
|
|
181
|
+
*/
|
|
182
|
+
verify(key: CryptoKey, data: string | ArrayBuffer, signature: string): Promise<boolean>;
|
|
183
|
+
importHmacKey(jwk: JsonWebKey, algorithm?: HmacAlgorithm): Promise<CryptoKey>;
|
|
184
|
+
private bufferToHex;
|
|
185
|
+
private hexToBytes;
|
|
186
|
+
private hmacHashName;
|
|
187
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<WebCryptoService, never>;
|
|
188
|
+
static ɵprov: i0.ɵɵInjectableDeclaration<WebCryptoService>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
type StorageTarget = 'local' | 'session';
|
|
192
|
+
interface SecureStorageConfig {
|
|
193
|
+
storage?: StorageTarget;
|
|
194
|
+
pbkdf2Iterations?: number;
|
|
195
|
+
}
|
|
196
|
+
declare const SECURE_STORAGE_CONFIG: InjectionToken<SecureStorageConfig>;
|
|
197
|
+
/**
|
|
198
|
+
* Service for transparent AES-GCM encrypted storage on top of localStorage/sessionStorage.
|
|
199
|
+
*
|
|
200
|
+
* Two key modes are supported:
|
|
201
|
+
* - **Ephemeral** (default): a CryptoKey is generated in memory per service instance.
|
|
202
|
+
* Data is unrecoverable after page reload or service re-creation.
|
|
203
|
+
* - **Passphrase-derived**: call `initWithPassphrase(passphrase)` to derive a stable key
|
|
204
|
+
* via PBKDF2. Data survives page reloads as long as the same passphrase is used.
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* // Ephemeral mode
|
|
208
|
+
* await storage.set('token', { value: 'abc' });
|
|
209
|
+
* const token = await storage.get<{ value: string }>('token');
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* // Passphrase mode
|
|
213
|
+
* await storage.initWithPassphrase('my-secret');
|
|
214
|
+
* await storage.set('user', { id: 1 }, 'auth');
|
|
215
|
+
* const user = await storage.get<{ id: number }>('user', 'auth');
|
|
216
|
+
*/
|
|
217
|
+
declare class SecureStorageService {
|
|
218
|
+
private readonly platformId;
|
|
219
|
+
private readonly storageConfig;
|
|
220
|
+
private activeKey;
|
|
221
|
+
constructor();
|
|
222
|
+
isSupported(): boolean;
|
|
223
|
+
/**
|
|
224
|
+
* Initializes the service with a passphrase-derived key (PBKDF2 + AES-GCM).
|
|
225
|
+
* The salt is automatically persisted in storage on first call and reused on subsequent calls.
|
|
226
|
+
* Calling this again replaces the active key.
|
|
227
|
+
*
|
|
228
|
+
* @param passphrase Secret passphrase for key derivation.
|
|
229
|
+
* @param explicitSalt Optional base64 salt. When provided, the stored salt is ignored.
|
|
230
|
+
*/
|
|
231
|
+
initWithPassphrase(passphrase: string, explicitSalt?: string): Promise<void>;
|
|
232
|
+
/**
|
|
233
|
+
* Encrypts and stores a value.
|
|
234
|
+
* A fresh random IV is generated for every write.
|
|
235
|
+
*
|
|
236
|
+
* @throws {TypeError} When `value` is `undefined`.
|
|
237
|
+
* @throws {DOMException} When storage quota is exceeded.
|
|
238
|
+
*/
|
|
239
|
+
set<T>(key: string, value: T, namespace?: string): Promise<void>;
|
|
240
|
+
/**
|
|
241
|
+
* Decrypts and returns a stored value.
|
|
242
|
+
* Returns `null` if the key does not exist, was written without encryption,
|
|
243
|
+
* or the ciphertext is corrupted.
|
|
244
|
+
*/
|
|
245
|
+
get<T>(key: string, namespace?: string): Promise<T | null>;
|
|
246
|
+
/**
|
|
247
|
+
* Removes a single entry from storage.
|
|
248
|
+
*/
|
|
249
|
+
remove(key: string, namespace?: string): void;
|
|
250
|
+
/**
|
|
251
|
+
* Clears all entries belonging to a namespace.
|
|
252
|
+
* When called without arguments, clears the entire storage target.
|
|
253
|
+
*/
|
|
254
|
+
clear(namespace?: string): void;
|
|
255
|
+
private get nativeStorage();
|
|
256
|
+
private buildStorageKey;
|
|
257
|
+
private ensureKey;
|
|
258
|
+
private assertSupported;
|
|
259
|
+
private bytesToBase64;
|
|
260
|
+
private base64ToBytes;
|
|
261
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<SecureStorageService, never>;
|
|
262
|
+
static ɵprov: i0.ɵɵInjectableDeclaration<SecureStorageService>;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
interface SanitizerConfig {
|
|
266
|
+
allowedTags?: string[];
|
|
267
|
+
allowedAttributes?: Record<string, string[]>;
|
|
268
|
+
}
|
|
269
|
+
declare const SANITIZER_CONFIG: InjectionToken<SanitizerConfig>;
|
|
270
|
+
/**
|
|
271
|
+
* Service for structured input sanitization to defend against XSS, URL injection, and unsafe HTML.
|
|
272
|
+
*
|
|
273
|
+
* This service is defense-in-depth and DOES NOT replace a Content Security Policy (CSP).
|
|
274
|
+
* Always configure a proper CSP alongside using this service.
|
|
275
|
+
*
|
|
276
|
+
* @example
|
|
277
|
+
* const clean = sanitizer.sanitizeHtml('<b>Hello</b><script>alert(1)</script>');
|
|
278
|
+
* // → '<b>Hello</b>'
|
|
279
|
+
*
|
|
280
|
+
* @example
|
|
281
|
+
* const url = sanitizer.sanitizeUrl('javascript:alert(1)');
|
|
282
|
+
* // → null
|
|
283
|
+
*/
|
|
284
|
+
declare class InputSanitizerService {
|
|
285
|
+
private readonly platformId;
|
|
286
|
+
private readonly allowedTags;
|
|
287
|
+
private readonly allowedAttributes;
|
|
288
|
+
constructor();
|
|
289
|
+
isSupported(): boolean;
|
|
290
|
+
/**
|
|
291
|
+
* Parses and sanitizes an HTML string, keeping only allowed tags and attributes.
|
|
292
|
+
* Script execution is prevented — parsing is done via DOMParser, not innerHTML assignment.
|
|
293
|
+
*
|
|
294
|
+
* @throws {Error} When called in a non-browser environment.
|
|
295
|
+
*/
|
|
296
|
+
sanitizeHtml(input: string): string;
|
|
297
|
+
/**
|
|
298
|
+
* Validates and normalizes a URL string.
|
|
299
|
+
* Returns the normalized URL only for `http:` and `https:` schemes.
|
|
300
|
+
* Returns `null` for `javascript:`, `data:`, `vbscript:`, relative URLs, or malformed input.
|
|
301
|
+
*/
|
|
302
|
+
sanitizeUrl(input: string): string | null;
|
|
303
|
+
/**
|
|
304
|
+
* Escapes HTML special characters for safe text interpolation.
|
|
305
|
+
* Use this when inserting user content into HTML text nodes or attributes.
|
|
306
|
+
*/
|
|
307
|
+
escapeHtml(input: string): string;
|
|
308
|
+
/**
|
|
309
|
+
* Safely parses a JSON string. Returns the parsed value on success, `null` on any error.
|
|
310
|
+
* Does NOT use `eval` or `Function` — uses JSON.parse only.
|
|
311
|
+
*/
|
|
312
|
+
sanitizeJson(input: string): unknown | null;
|
|
313
|
+
private processNode;
|
|
314
|
+
private sanitizeAttributes;
|
|
315
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<InputSanitizerService, never>;
|
|
316
|
+
static ɵprov: i0.ɵɵInjectableDeclaration<InputSanitizerService>;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
interface PasswordStrengthResult {
|
|
320
|
+
score: 0 | 1 | 2 | 3 | 4;
|
|
321
|
+
label: 'very-weak' | 'weak' | 'fair' | 'strong' | 'very-strong';
|
|
322
|
+
entropy: number;
|
|
323
|
+
feedback: string[];
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Service for entropy-based password strength evaluation.
|
|
327
|
+
* All methods are synchronous and side-effect free — safely wrappable in Angular `computed()`.
|
|
328
|
+
*
|
|
329
|
+
* Score thresholds (bits of entropy):
|
|
330
|
+
* - 0 (very-weak): < 28 bits
|
|
331
|
+
* - 1 (weak): 28–35 bits
|
|
332
|
+
* - 2 (fair): 36–49 bits
|
|
333
|
+
* - 3 (strong): 50–69 bits
|
|
334
|
+
* - 4 (very-strong): ≥ 70 bits
|
|
335
|
+
*
|
|
336
|
+
* @example
|
|
337
|
+
* const result = passwordStrength.assess('P@ssw0rd!');
|
|
338
|
+
* console.log(result.score); // 2
|
|
339
|
+
* console.log(result.label); // 'fair'
|
|
340
|
+
* console.log(result.entropy); // ~42.5
|
|
341
|
+
*/
|
|
342
|
+
declare class PasswordStrengthService {
|
|
343
|
+
/**
|
|
344
|
+
* Evaluates the strength of a password.
|
|
345
|
+
* Never throws — returns score 0 for empty or null-like input.
|
|
346
|
+
*/
|
|
347
|
+
assess(password: string): PasswordStrengthResult;
|
|
348
|
+
private entropyToScore;
|
|
349
|
+
private containsSequence;
|
|
350
|
+
static ɵfac: i0.ɵɵFactoryDeclaration<PasswordStrengthService, never>;
|
|
351
|
+
static ɵprov: i0.ɵɵInjectableDeclaration<PasswordStrengthService>;
|
|
352
|
+
}
|
|
353
|
+
|
|
154
354
|
interface SecurityConfig {
|
|
155
355
|
enableRegexSecurity?: boolean;
|
|
356
|
+
enableWebCrypto?: boolean;
|
|
357
|
+
enableSecureStorage?: boolean;
|
|
358
|
+
enableInputSanitizer?: boolean;
|
|
359
|
+
enablePasswordStrength?: boolean;
|
|
156
360
|
defaultTimeout?: number;
|
|
157
361
|
safeMode?: boolean;
|
|
158
362
|
}
|
|
159
363
|
declare const defaultSecurityConfig: SecurityConfig;
|
|
160
364
|
declare function provideSecurity(config?: SecurityConfig): EnvironmentProviders;
|
|
161
365
|
declare function provideRegexSecurity(): EnvironmentProviders;
|
|
366
|
+
declare function provideWebCrypto(): EnvironmentProviders;
|
|
367
|
+
declare function provideSecureStorage(config?: SecureStorageConfig): EnvironmentProviders;
|
|
368
|
+
declare function provideInputSanitizer(config?: SanitizerConfig): EnvironmentProviders;
|
|
369
|
+
declare function providePasswordStrength(): EnvironmentProviders;
|
|
162
370
|
|
|
163
|
-
export { RegexSecurityBuilder, RegexSecurityService, defaultSecurityConfig, provideRegexSecurity, provideSecurity };
|
|
164
|
-
export type { RegexBuilderOptions, RegexSecurityConfig, RegexSecurityResult, RegexTestResult, SecurityConfig };
|
|
371
|
+
export { InputSanitizerService, PasswordStrengthService, RegexSecurityBuilder, RegexSecurityService, SANITIZER_CONFIG, SECURE_STORAGE_CONFIG, SecureStorageService, WebCryptoService, defaultSecurityConfig, provideInputSanitizer, providePasswordStrength, provideRegexSecurity, provideSecureStorage, provideSecurity, provideWebCrypto };
|
|
372
|
+
export type { AesEncryptResult, AesKeyLength, HashAlgorithm, HmacAlgorithm, PasswordStrengthResult, RegexBuilderOptions, RegexSecurityConfig, RegexSecurityResult, RegexTestResult, SanitizerConfig, SecureStorageConfig, SecurityConfig, StorageTarget };
|