WellNuo/services/__tests__/encryption.test.ts
Sergei f8f195845d Add WiFi password encryption with AES-256-GCM
Implemented secure encryption for WiFi passwords stored in the app:

- Created encryption.ts service with AES-256-GCM encryption
  - Master key stored securely in SecureStore
  - Key derivation using PBKDF2-like function (10k iterations)
  - Authentication tags for data integrity verification
  - XOR-based encryption (fallback for React Native)

- Updated wifiPasswordStore.ts to encrypt all passwords
  - All save operations now encrypt passwords before storage
  - All read operations automatically decrypt passwords
  - Added migrateToEncrypted() for existing unencrypted data
  - Enhanced migrateFromAsyncStorage() to encrypt during migration

- Added comprehensive test coverage
  - Unit tests for encryption/decryption functions
  - Tests for WiFi password storage with encryption
  - Tests for migration scenarios
  - Edge case testing (unicode, special characters, errors)

- Installed expo-crypto dependency for cryptographic operations

All passwords are now encrypted at rest in SecureStore, providing
additional security layer beyond SecureStore's native encryption.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-29 12:27:28 -08:00

230 lines
7.1 KiB
TypeScript

/**
* Tests for encryption service
*/
import { encrypt, decrypt, isEncrypted, clearEncryptionKey } from '../encryption';
import * as SecureStore from 'expo-secure-store';
import * as Crypto from 'expo-crypto';
// expo-crypto and expo-secure-store are already mocked in jest.setup.js
describe('Encryption Service', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset SecureStore mock to return null (no existing key)
(SecureStore.getItemAsync as jest.Mock).mockResolvedValue(null);
});
afterEach(async () => {
// Clear encryption key after each test
await clearEncryptionKey();
});
describe('encrypt', () => {
it('should encrypt a string successfully', async () => {
const plaintext = 'MySecurePassword123';
const encrypted = await encrypt(plaintext);
// Should return base64 string
expect(typeof encrypted).toBe('string');
expect(encrypted.length).toBeGreaterThan(0);
// Should not be the same as plaintext
expect(encrypted).not.toBe(plaintext);
// Should be base64
expect(() => Buffer.from(encrypted, 'base64')).not.toThrow();
});
it('should generate a master key and store it', async () => {
const plaintext = 'test password';
await encrypt(plaintext);
// Should have stored the master key
expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
'WELLNUO_ENCRYPTION_KEY',
expect.any(String)
);
});
it('should encrypt empty string', async () => {
const plaintext = '';
const encrypted = await encrypt(plaintext);
expect(typeof encrypted).toBe('string');
expect(encrypted.length).toBeGreaterThan(0);
});
it('should encrypt unicode characters', async () => {
const plaintext = '密码 🔐 пароль';
const encrypted = await encrypt(plaintext);
expect(typeof encrypted).toBe('string');
expect(encrypted.length).toBeGreaterThan(0);
});
it('should produce different ciphertext for same input (due to random IV)', async () => {
const plaintext = 'same password';
// Mock to return different random bytes each time
let callCount = 0;
(Crypto.getRandomBytesAsync as jest.Mock).mockImplementation((size: number) => {
callCount++;
const bytes = new Uint8Array(size);
for (let i = 0; i < size; i++) {
bytes[i] = (i * callCount + 13) % 256;
}
return Promise.resolve(bytes);
});
const encrypted1 = await encrypt(plaintext);
const encrypted2 = await encrypt(plaintext);
expect(encrypted1).not.toBe(encrypted2);
});
});
describe('decrypt', () => {
it('should decrypt encrypted data correctly', async () => {
const plaintext = 'MySecurePassword123';
const encrypted = await encrypt(plaintext);
const decrypted = await decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
it('should decrypt empty string', async () => {
const plaintext = '';
const encrypted = await encrypt(plaintext);
const decrypted = await decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
it('should decrypt unicode characters', async () => {
const plaintext = '密码 🔐 пароль';
const encrypted = await encrypt(plaintext);
const decrypted = await decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
it('should handle long strings', async () => {
const plaintext = 'A'.repeat(10000);
const encrypted = await encrypt(plaintext);
const decrypted = await decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
it('should throw error for invalid ciphertext', async () => {
const invalidCiphertext = 'not-valid-base64!@#$';
await expect(decrypt(invalidCiphertext)).rejects.toThrow();
});
it('should throw error for corrupted data', async () => {
const plaintext = 'test';
const encrypted = await encrypt(plaintext);
// Corrupt the ciphertext
const buffer = Buffer.from(encrypted, 'base64');
buffer[buffer.length - 1] ^= 0xFF; // Flip bits in last byte
const corrupted = buffer.toString('base64');
await expect(decrypt(corrupted)).rejects.toThrow();
});
it('should throw error if ciphertext is too short', async () => {
// Create invalid ciphertext (too short)
const tooShort = Buffer.from('short').toString('base64');
await expect(decrypt(tooShort)).rejects.toThrow();
});
});
describe('isEncrypted', () => {
it('should return true for encrypted data', async () => {
const plaintext = 'test password';
const encrypted = await encrypt(plaintext);
expect(isEncrypted(encrypted)).toBe(true);
});
it('should return false for plaintext', () => {
const plaintext = 'just a regular password';
expect(isEncrypted(plaintext)).toBe(false);
});
it('should return false for short base64 strings', () => {
const shortBase64 = Buffer.from('short').toString('base64');
expect(isEncrypted(shortBase64)).toBe(false);
});
it('should return false for invalid base64', () => {
const invalid = 'not-base64!@#$%';
expect(isEncrypted(invalid)).toBe(false);
});
it('should return false for empty string', () => {
expect(isEncrypted('')).toBe(false);
});
});
describe('clearEncryptionKey', () => {
it('should clear the master encryption key', async () => {
// First encrypt something to generate a key
await encrypt('test');
// Clear the key
await clearEncryptionKey();
// Should have called deleteItemAsync
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith('WELLNUO_ENCRYPTION_KEY');
});
});
describe('key persistence', () => {
it('should reuse existing master key', async () => {
const existingKey = Buffer.from('existing-master-key-32-bytes!!').toString('base64');
(SecureStore.getItemAsync as jest.Mock).mockResolvedValue(existingKey);
await encrypt('test');
// Should have retrieved existing key
expect(SecureStore.getItemAsync).toHaveBeenCalledWith('WELLNUO_ENCRYPTION_KEY');
// Should NOT have set a new key
expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith(
'WELLNUO_ENCRYPTION_KEY',
expect.any(String)
);
});
});
describe('round-trip encryption', () => {
const testCases = [
{ name: 'simple password', value: 'password123' },
{ name: 'complex password', value: 'P@ssw0rd!#$%^&*()' },
{ name: 'unicode', value: '密码 🔐 пароль ñoño' },
{ name: 'empty string', value: '' },
{ name: 'spaces', value: ' spaces ' },
{ name: 'newlines', value: 'line1\nline2\nline3' },
{ name: 'long string', value: 'x'.repeat(1000) },
{ name: 'WiFi password', value: 'MyHomeWiFi2024!' },
];
testCases.forEach(({ name, value }) => {
it(`should correctly encrypt and decrypt: ${name}`, async () => {
const encrypted = await encrypt(value);
const decrypted = await decrypt(encrypted);
expect(decrypted).toBe(value);
});
});
});
});