diff --git a/README.md b/README.md index b8efb4c..7c5e257 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,31 @@ KEK_PATH=storage/keys/kek.key > For development, use the relative path above. In production, set `KEK_PATH` to the absolute path of your KEK file (ideally outside the web root), and ensure file permissions are `0600`. +#### Key Rotation + +SecPal provides Artisan commands for key lifecycle management: + +```bash +# Generate new tenant with envelope keys +php artisan keys:generate-tenant + +# Rotate KEK and re-wrap all tenant keys (creates backup) +php artisan keys:rotate-kek + +# Rotate DEK for specific tenant (re-encrypts all data) +php artisan keys:rotate-dek {tenant_id} + +# Rebuild blind indexes for specific tenant +php artisan idx:rebuild {tenant_id} +``` + +**Best Practices:** + +- Rotate KEK annually or after suspected compromise +- Rotate tenant DEKs when offboarding users with access +- Keep KEK backups (created by `keys:rotate-kek`) in secure offline storage +- Test rotation procedures in staging before production + ### 6. Set up development tools ```bash @@ -289,6 +314,51 @@ See [LICENSE](LICENSE) for full details. ## Security +### Encryption Architecture + +SecPal implements **multi-tenant envelope encryption** with the following security properties: + +**Key Hierarchy:** + +- **KEK (Key Encryption Key)**: Master key stored in `storage/keys/kek.key` (mode 0600) +- **Per-Tenant DEK**: Data Encryption Key for encrypting PII fields (email, phone, notes) +- **Per-Tenant idx_key**: Index key for generating blind indexes (searchable without decryption) + +**Encrypted Fields:** + +- `email_enc`, `phone_enc`, `note_enc` - Encrypted with tenant DEK using XChaCha20-Poly1305 +- Stored as JSON: `{"ciphertext": "base64", "nonce": "base64"}` + +**Blind Indexes:** + +- `email_idx`, `phone_idx` - HMAC-SHA256 of normalized values using idx_key +- Enable equality search without decryption +- Tenant-isolated (same email in different tenants produces different indexes) + +### Security Considerations + +**✅ What SecPal Protects Against:** + +- Database compromise (all PII encrypted at rest) +- Cross-tenant data access (tenant-specific keys + middleware isolation) +- Unauthorized API access (Sanctum PAT authentication + Spatie RBAC) + +**⚠️ Known Limitations:** + +- **Full-Text Search Leakage**: The `note_tsv` field contains plaintext tokens for FTS. If FTS on notes is required, accept this trade-off or implement separate FTS infrastructure. +- **Blind Index Frequency Analysis**: Repeated values (e.g., common email domains) can be detected through blind index frequency patterns. +- **Application-Level Access**: Authenticated users with proper permissions can decrypt data (by design). + +**🔒 Operational Security:** + +- Never commit KEK file (already in `.gitignore`) +- Store production KEK outside web root with 0600 permissions +- Use key rotation commands regularly (see "Key Rotation" section above) +- Monitor `storage/logs` for any accidental PII leakage (tests enforce this) +- Backup KEK securely before rotation (kept by `keys:rotate-kek`) + +### Reporting Vulnerabilities + See [SECURITY.md](SECURITY.md) for information about reporting security vulnerabilities. ## Code of Conduct diff --git a/tests/Feature/SecurityHardeningTest.php b/tests/Feature/SecurityHardeningTest.php new file mode 100644 index 0000000..10eb650 --- /dev/null +++ b/tests/Feature/SecurityHardeningTest.php @@ -0,0 +1,211 @@ +tenant = TenantKey::create($keys); +}); + +afterEach(function (): void { + cleanupTestKekFile(); + TenantKey::setKekPath(null); +}); + +describe('No Plaintext in Database', function () { + test('encrypted fields do not contain plaintext in database', function (): void { + $testEmail = 'sensitive@example.com'; + $testPhone = '+49 123 456789'; + $testNote = 'Very sensitive information'; + + // Create person with sensitive data + $person = new Person; + $person->tenant_id = $this->tenant->id; + $person->email_plain = $testEmail; + $person->phone_plain = $testPhone; + $person->note_enc = $testNote; + $person->save(); + + // Query raw database row + $rawRow = DB::table('person') + ->where('id', $person->id) + ->first(); + + // Check email_enc does not contain plaintext + expect($rawRow->email_enc)->not->toContain($testEmail); + expect($rawRow->email_enc)->not->toContain('sensitive'); + expect($rawRow->email_enc)->toContain('ciphertext'); // Should be JSON format + expect($rawRow->email_enc)->toContain('nonce'); + + // Check phone_enc does not contain plaintext + expect($rawRow->phone_enc)->not->toContain($testPhone); + expect($rawRow->phone_enc)->not->toContain('123'); + expect($rawRow->phone_enc)->toContain('ciphertext'); + expect($rawRow->phone_enc)->toContain('nonce'); + + // Check note_enc does not contain plaintext + expect($rawRow->note_enc)->not->toContain($testNote); + expect($rawRow->note_enc)->not->toContain('sensitive'); + expect($rawRow->note_enc)->toContain('ciphertext'); + expect($rawRow->note_enc)->toContain('nonce'); + }); + + test('blind indexes do not contain plaintext', function (): void { + $testEmail = 'testuser@example.com'; + $testPhone = '+49 987 654321'; + + $person = new Person; + $person->tenant_id = $this->tenant->id; + $person->email_plain = $testEmail; + $person->phone_plain = $testPhone; + $person->save(); + + $rawRow = DB::table('person') + ->where('id', $person->id) + ->first(); + + // Blind indexes should be base64-encoded hashes, not plaintext + expect($rawRow->email_idx)->not->toContain($testEmail); + expect($rawRow->email_idx)->not->toContain('testuser'); + expect($rawRow->email_idx)->not->toContain('@'); + expect($rawRow->email_idx)->not->toContain('example.com'); + + expect($rawRow->phone_idx)->not->toContain($testPhone); + expect($rawRow->phone_idx)->not->toContain('987'); + expect($rawRow->phone_idx)->not->toContain('654321'); + + // Should be base64 (only alphanumeric + / + =) + expect($rawRow->email_idx)->toMatch('/^[A-Za-z0-9+\/=]+$/'); + expect($rawRow->phone_idx)->toMatch('/^[A-Za-z0-9+\/=]+$/'); + }); + + test('tenant keys are wrapped and not in plaintext', function (): void { + $rawRow = DB::table('tenant_keys') + ->where('id', $this->tenant->id) + ->first(); + + // Wrapped keys should be base64-encoded ciphertext + expect($rawRow->dek_wrapped)->toMatch('/^[A-Za-z0-9+\/=]+$/'); + expect($rawRow->idx_wrapped)->toMatch('/^[A-Za-z0-9+\/=]+$/'); + + // Nonces should be base64 + expect($rawRow->dek_nonce)->toMatch('/^[A-Za-z0-9+\/=]+$/'); + expect($rawRow->idx_nonce)->toMatch('/^[A-Za-z0-9+\/=]+$/'); + + // Should not be trivially short (must be actual encrypted data) + expect(strlen($rawRow->dek_wrapped))->toBeGreaterThan(40); + expect(strlen($rawRow->idx_wrapped))->toBeGreaterThan(40); + }); +}); + +describe('No Plaintext in Logs', function () { + test('person creation does not log plaintext PII', function (): void { + $testEmail = 'secret-email@example.com'; + $testPhone = '+49 555 1234567'; + $testNote = 'Confidential information'; + + // Capture log output + $logMessages = []; + Log::listen(function ($log) use (&$logMessages) { + $logMessages[] = $log->message; + }); + + // Create person + $person = new Person; + $person->tenant_id = $this->tenant->id; + $person->email_plain = $testEmail; + $person->phone_plain = $testPhone; + $person->note_enc = $testNote; + $person->save(); + + // Check logs do not contain plaintext + $allLogs = implode(' ', $logMessages); + expect($allLogs)->not->toContain($testEmail); + expect($allLogs)->not->toContain('secret-email'); + expect($allLogs)->not->toContain($testPhone); + expect($allLogs)->not->toContain('555'); + expect($allLogs)->not->toContain($testNote); + expect($allLogs)->not->toContain('Confidential'); + }); + + test('person update does not log plaintext PII', function (): void { + $originalEmail = 'original@example.com'; + $newEmail = 'updated-secret@example.com'; + + $person = new Person; + $person->tenant_id = $this->tenant->id; + $person->email_plain = $originalEmail; + $person->save(); + + // Capture log output for update + $logMessages = []; + Log::listen(function ($log) use (&$logMessages) { + $logMessages[] = $log->message; + }); + + $person->email_plain = $newEmail; + $person->save(); + + // Check logs do not contain plaintext + $allLogs = implode(' ', $logMessages); + expect($allLogs)->not->toContain($newEmail); + expect($allLogs)->not->toContain('updated-secret'); + }); + + test('exception messages do not expose plaintext', function (): void { + $sensitiveEmail = 'confidential@example.com'; + + try { + // Attempt to create invalid person (missing tenant_id should fail) + $person = new Person; + $person->email_plain = $sensitiveEmail; + $person->save(); // Should throw exception + } catch (\Exception $e) { + // Exception message should not contain the sensitive email + expect($e->getMessage())->not->toContain($sensitiveEmail); + expect($e->getMessage())->not->toContain('confidential'); + } + }); +}); + +describe('API Response Security', function () { + test('JSON responses do not expose encrypted fields or indexes', function (): void { + $person = new Person; + $person->tenant_id = $this->tenant->id; + $person->email_plain = 'test@example.com'; + $person->phone_plain = '+49 123 456789'; + $person->note_enc = 'Secret note'; + $person->save(); + + $jsonArray = $person->toArray(); + + // Encrypted fields should be hidden + expect($jsonArray)->not->toHaveKey('email_enc'); + expect($jsonArray)->not->toHaveKey('phone_enc'); + expect($jsonArray)->not->toHaveKey('note_enc'); + + // Blind indexes should be hidden + expect($jsonArray)->not->toHaveKey('email_idx'); + expect($jsonArray)->not->toHaveKey('phone_idx'); + + // Only safe fields should be present + expect($jsonArray)->toHaveKeys(['id', 'tenant_id', 'created_at', 'updated_at']); + }); +});