Skip to content

Commit f4b4a96

Browse files
ds
0 parents  commit f4b4a96

15 files changed

+583
-0
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
SEARCH_PEPPER=your-long-random-string-here

.idea/.gitignore

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/laravel-encrypted-search-index.iml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/modules.xml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/php.xml

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# Ginkelsoft Laravel Encrypted Search Index
2+
3+
## Overview
4+
5+
Modern applications that handle sensitive user data—such as healthcare, financial, or membership systems—must ensure that all personally identifiable information (PII) is properly encrypted at rest. However, standard encryption creates a practical challenge: **once data is encrypted, it can no longer be searched efficiently.**
6+
7+
Laravel's built-in `Crypt` system offers strong encryption (AES-256-CBC) but provides no mechanism for searching encrypted values. Some systems attempt to address this by storing partial plaintext or using blind indexes, which can leak statistical patterns and increase the risk of correlation attacks.
8+
9+
The **Laravel Encrypted Search Index** package provides a clean, secure, and scalable alternative. It allows encrypted model fields to be **searched using deterministic hashed tokens**, without ever exposing plaintext data.
10+
11+
---
12+
13+
## Problem Statement
14+
15+
### The traditional trade-off
16+
17+
When data is fully encrypted, you lose the ability to perform meaningful queries. Developers must choose between:
18+
19+
1. **Strong security (no search):** Encrypt every value with a random IV; searches become impossible.
20+
2. **Weak security (searchable):** Store hashed or partially-encrypted values that can be compared, leaking patterns.
21+
22+
This package removes that trade-off by introducing a **detached searchable index** that maps encrypted records to deterministic tokens.
23+
24+
---\n
25+
26+
## Key Features
27+
28+
* **Searchable encryption**: Enables exact and prefix-based searches over encrypted data.
29+
* **Detached search index**: Tokens are stored separately from the main data, reducing exposure risk.
30+
* **Deterministic hashing with peppering**: Each token is derived from normalized text combined with a secret pepper, preventing reverse-engineering.
31+
* **No blind indexes in primary tables**: Encrypted fields remain opaque—only hashed references are stored elsewhere.
32+
* **High scalability**: Indexes can handle millions of records efficiently using native database indexes.
33+
* **Laravel-native integration**: Fully compatible with Eloquent models, query scopes, and events.
34+
35+
---
36+
37+
## How It Works
38+
39+
Each model can declare specific fields as searchable. When the model is saved, a background process normalizes the field value, generates one or more hashed tokens, and stores them in a separate database table named `encrypted_search_index`.
40+
41+
When you search, the package hashes your input using the same process and retrieves matching model IDs from the index.
42+
43+
### 1. Token Generation
44+
45+
For each configured field:
46+
47+
* **Exact match token:** A SHA-256 hash of the normalized value plus a secret pepper.
48+
* **Prefix tokens:** Multiple SHA-256 hashes representing progressive prefixes of the normalized text (e.g., `w`, `wi`, `wie`).
49+
50+
### 2. Token Storage
51+
52+
All tokens are stored in `encrypted_search_index` with the following structure:
53+
54+
| model_type | model_id | field | type | token |
55+
| ----------------- | -------- | ---------- | ------ | ------ |
56+
| App\Models\Client | 42 | last_names | exact | [hash] |
57+
| App\Models\Client | 42 | last_names | prefix | [hash] |
58+
59+
### 3. Querying
60+
61+
The package provides two Eloquent scopes:
62+
63+
```php
64+
Client::encryptedExact('last_names', 'Vermeer')->get();
65+
Client::encryptedPrefix('first_names', 'Wie')->get();
66+
```
67+
68+
These queries use database-level indexes for efficient lookups even on large datasets.
69+
70+
---
71+
72+
## Security Model
73+
74+
| Threat | Mitigation |
75+
| ----------------------- | --------------------------------------------------------------------------- |
76+
| Database dump or breach | Tokens cannot be reversed to plaintext (salted and peppered SHA-256). |
77+
| Statistical analysis | Tokens are fully detached; frequency analysis yields no useful correlation. |
78+
| Insider access | No sensitive data in the index table; encrypted fields remain opaque. |
79+
| Leaked `APP_KEY` | Does not affect token security; the pepper is stored separately in `.env`. |
80+
81+
The system follows a **defense-in-depth** approach: encrypted data remains fully protected, while token search provides limited, controlled visibility for queries.
82+
83+
---
84+
85+
## Installation
86+
87+
```bash
88+
composer require ginkelsoft/laravel-encrypted-search-index
89+
php artisan vendor:publish --tag=config
90+
php artisan migrate
91+
```
92+
93+
Update your `.env` file with a unique pepper:
94+
95+
```
96+
SEARCH_PEPPER=your-random-secret-string
97+
```
98+
99+
---
100+
101+
## Configuration
102+
103+
`config/encrypted-search.php`
104+
105+
```php
106+
return [
107+
'search_pepper' => env('SEARCH_PEPPER', ''),
108+
'max_prefix_depth' => 6,
109+
];
110+
```
111+
112+
---
113+
114+
## Usage
115+
116+
### Model Setup
117+
118+
```php
119+
use Illuminate\Database\Eloquent\Model;
120+
use Ginkelsoft\EncryptedSearch\Traits\HasEncryptedSearchIndex;
121+
122+
class Client extends Model
123+
{
124+
use HasEncryptedSearchIndex;
125+
126+
protected array $encryptedSearch = [
127+
'first_names' => ['exact' => true, 'prefix' => true],
128+
'last_names' => ['exact' => true, 'prefix' => true],
129+
'bsn' => ['exact' => true],
130+
];
131+
}
132+
```
133+
134+
When a `Client` record is saved, its searchable tokens are automatically created or updated in the `encrypted_search_index` table.
135+
136+
### Searching
137+
138+
```php
139+
// Exact match search
140+
$clients = Client::encryptedExact('last_names', 'Vermeer')->get();
141+
142+
// Prefix match search
143+
$clients = Client::encryptedPrefix('first_names', 'Wie')->get();
144+
```
145+
146+
### Rebuilding the Index
147+
148+
You can rebuild the entire search index using an Artisan command:
149+
150+
```bash
151+
php artisan encryption:index-rebuild "App\\Models\\Client"
152+
```
153+
154+
This will reprocess all searchable fields for the specified model.
155+
156+
---
157+
158+
## Scalability and Performance
159+
160+
* **Optimized database lookups**: The `encrypted_search_index` table uses compound indexes for fast token-based lookups.
161+
* **Chunked rebuilds**: The `index-rebuild` command supports chunked processing to handle large datasets efficiently.
162+
* **Asynchronous rebuilds**: Can be safely run in queues or background jobs.
163+
164+
Unlike in-memory search systems, this index-based approach scales linearly with the size of your dataset and can efficiently handle millions of records.
165+
166+
---
167+
168+
## Compliance
169+
170+
This approach aligns with major privacy and compliance frameworks:
171+
172+
* GDPR: Minimal data exposure; encrypted and hashed data separation.
173+
* HIPAA: Ensures ePHI remains protected even in breach scenarios.
174+
* ISO 27001: Supports layered security controls for data confidentiality.
175+
176+
---
177+
178+
## License
179+
180+
MIT License
181+
(c) 2025 Ginkelsoft

composer.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "ginkelsoft/laravel-encrypted-search-index",
3+
"description": "Searchable indexes for encrypted model fields in Laravel.",
4+
"type": "library",
5+
"license": "MIT",
6+
"autoload": {
7+
"psr-4": {
8+
"Ginkelsoft\\EncryptedSearch\\": "src/"
9+
}
10+
},
11+
"extra": {
12+
"laravel": {
13+
"providers": [
14+
"Ginkelsoft\\EncryptedSearch\\EncryptedSearchServiceProvider"
15+
]
16+
}
17+
},
18+
"require": {
19+
"php": ">=8.2",
20+
"illuminate/support": "^11.0|^12.0",
21+
"illuminate/database": "^11.0|^12.0"
22+
},
23+
"require-dev": {
24+
"orchestra/testbench": "^9.0",
25+
"phpunit/phpunit": "^10.5|^11.0"
26+
}
27+
}

config/encrypted-search.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
return [
4+
/*
5+
|--------------------------------------------------------------------------
6+
| Search Pepper
7+
|--------------------------------------------------------------------------
8+
| Een geheime “pepper” die wordt mee-gehasht met genormaliseerde tokens.
9+
| Dit voorkomt dat een gelekte index triviaal terug te rekenen is.
10+
| Zet dit in .env als SEARCH_PEPPER="random-string".
11+
*/
12+
'search_pepper' => env('SEARCH_PEPPER', ''),
13+
14+
/*
15+
|--------------------------------------------------------------------------
16+
| Prefix token lengte
17+
|--------------------------------------------------------------------------
18+
| Maximaal aantal prefix-niveaus voor prefix-zoekopdrachten.
19+
| Bijv. "wietse" -> ["w","wi","wie"]
20+
*/
21+
'max_prefix_depth' => 6,
22+
];
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration {
8+
public function up(): void
9+
{
10+
Schema::create('encrypted_search_index', function (Blueprint $table) {
11+
$table->id();
12+
$table->string('model_type'); // FQCN
13+
$table->unsignedBigInteger('model_id');
14+
$table->string('field'); // bv. 'last_names'
15+
$table->string('type', 16); // 'exact' | 'prefix'
16+
$table->string('token', 80); // sha256 hex (64) of korter
17+
$table->timestamps();
18+
19+
$table->index(['model_type', 'field', 'type', 'token'], 'esi_lookup');
20+
$table->index(['model_type', 'model_id'], 'esi_row');
21+
});
22+
}
23+
24+
public function down(): void
25+
{
26+
Schema::dropIfExists('encrypted_search_index');
27+
}
28+
};

src/Console/RebuildIndex.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace Ginkelsoft\EncryptedSearch\Console;
4+
5+
use Illuminate\Console\Command;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Ginkelsoft\EncryptedSearch\Models\SearchIndex;
8+
9+
class RebuildIndex extends Command
10+
{
11+
protected $signature = 'encryption:index-rebuild {model : FQCN of the Eloquent model} {--chunk=100}';
12+
protected $description = 'Rebuild encrypted search index for a given model.';
13+
14+
public function handle(): int
15+
{
16+
/** @var class-string<Model> $class */
17+
$class = $this->argument('model');
18+
19+
if (! class_exists($class)) {
20+
$this->error("Model class not found: {$class}");
21+
return self::FAILURE;
22+
}
23+
24+
$chunk = (int) $this->option('chunk');
25+
26+
// Drop alle tokens voor dit model
27+
SearchIndex::where('model_type', $class)->delete();
28+
29+
/** @var \Illuminate\Database\Eloquent\Builder $q */
30+
$q = $class::query();
31+
32+
$count = 0;
33+
$q->chunk($chunk, function ($rows) use (&$count, $class) {
34+
foreach ($rows as $model) {
35+
if (method_exists($class, 'updateSearchIndex')) {
36+
$class::updateSearchIndex($model);
37+
}
38+
$count++;
39+
}
40+
$this->output->write('.');
41+
});
42+
43+
$this->newLine();
44+
$this->info("Rebuilt index for {$count} records of {$class}.");
45+
46+
return self::SUCCESS;
47+
}
48+
}

0 commit comments

Comments
 (0)