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>
230 lines
7.1 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|
|
});
|