Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion config/encrypted-search.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
|--------------------------------------------------------------------------
|
| The maximum number of prefix levels to generate for prefix-based search.
| For example, the term wietse would generate:
| For example, the term "wietse" would generate:
| ["w", "wi", "wie", "wiet", "wiets", "wietse"]
|
| Increasing this value improves search precision for short terms, but
Expand All @@ -45,6 +45,29 @@
*/
'max_prefix_depth' => 6,

/*
|--------------------------------------------------------------------------
| Minimum Prefix Length
|--------------------------------------------------------------------------
|
| The minimum number of characters required for prefix-based searches.
| This prevents overly broad matches from very short search terms.
|
| For example, with min_prefix_length = 3:
| - Searching for "Wi" (2 chars) will return no results
| - Searching for "Wil" (3 chars) will work normally
|
| This helps prevent performance issues and reduces false positives
| when users search for very short terms like "a" or "de".
|
| Recommended values:
| - 2: Allow two-character searches (more flexible, more false positives)
| - 3: Require three characters (good balance)
| - 4: Require four characters (very precise, less flexible)
|
*/
'min_prefix_length' => env('ENCRYPTED_SEARCH_MIN_PREFIX', 3),

/*
|--------------------------------------------------------------------------
| Automatic Indexing of Encrypted Casts
Expand Down
15 changes: 12 additions & 3 deletions src/Support/Tokens.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,21 +69,27 @@ public static function exact(string $normalized, string $pepper): string
* These prefix hashes can be used to implement fast "starts-with"
* queries while maintaining cryptographic privacy.
*
* Example: "alex" with maxDepth=3 yields tokens for "a", "al", "ale".
* Only prefixes at or above the minimum length (from config) are generated.
* This prevents overly broad matches from very short search terms.
*
* Example: "alex" with maxDepth=4, minLength=2 yields tokens for "al", "ale", "alex".
* (skips "a" because it's below minimum length)
*
* @param string $normalized
* The normalized (lowercase, diacritic-free) string.
* @param int $maxDepth
* The maximum number of prefix characters to hash.
* @param string $pepper
* A secret application-level random string from configuration.
* @param int $minLength
* The minimum prefix length to generate (default: 1 for backwards compatibility).
*
* @return string[]
* An array of hex-encoded SHA-256 prefix tokens.
*
* @throws \RuntimeException if pepper is empty
*/
public static function prefixes(string $normalized, int $maxDepth, string $pepper): array
public static function prefixes(string $normalized, int $maxDepth, string $pepper, int $minLength = 1): array
{
if (empty($pepper)) {
throw new \RuntimeException(
Expand All @@ -96,7 +102,10 @@ public static function prefixes(string $normalized, int $maxDepth, string $peppe
$len = mb_strlen($normalized, 'UTF-8');
$depth = min($maxDepth, $len);

for ($i = 1; $i <= $depth; $i++) {
// Start from minimum length instead of 1
$start = max(1, $minLength);

for ($i = $start; $i <= $depth; $i++) {
$prefix = mb_substr($normalized, 0, $i, 'UTF-8');
$out[] = hash('sha256', $prefix . $pepper);
}
Expand Down
17 changes: 15 additions & 2 deletions src/Traits/HasEncryptedSearchIndex.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ public function updateSearchIndex(): void

$pepper = (string) config('encrypted-search.search_pepper', '');
$max = (int) config('encrypted-search.max_prefix_depth', 6);
$min = (int) config('encrypted-search.min_prefix_length', 1);
$useElastic = config('encrypted-search.elasticsearch.enabled', false);

$rows = [];
Expand Down Expand Up @@ -108,7 +109,7 @@ public function updateSearchIndex(): void

// Generate prefix-based tokens
if (!empty($modes['prefix'])) {
foreach (Tokens::prefixes($normalized, $max, $pepper) as $token) {
foreach (Tokens::prefixes($normalized, $max, $pepper, $min) as $token) {
$rows[] = [
'model_type' => static::class,
'model_id' => $this->getKey(),
Expand Down Expand Up @@ -275,18 +276,30 @@ public function scopeEncryptedExact(Builder $query, string $field, string $term)
public function scopeEncryptedPrefix(Builder $query, string $field, string $term): Builder
{
$pepper = (string) config('encrypted-search.search_pepper', '');
$minLength = (int) config('encrypted-search.min_prefix_length', 1);
$normalized = Normalizer::normalize($term);

if (!$normalized) {
return $query->whereRaw('1=0');
}

// Check if search term meets minimum length requirement
if (mb_strlen($normalized, 'UTF-8') < $minLength) {
return $query->whereRaw('1=0');
}

$tokens = Tokens::prefixes(
$normalized,
(int) config('encrypted-search.max_prefix_depth', 6),
$pepper
$pepper,
$minLength
);

// If no tokens generated (term too short), return no results
if (empty($tokens)) {
return $query->whereRaw('1=0');
}

// Check if Elasticsearch is enabled
if (config('encrypted-search.elasticsearch.enabled', false)) {
$modelIds = $this->searchElasticsearch($field, $tokens, 'prefix');
Expand Down
3 changes: 3 additions & 0 deletions tests/Feature/EncryptedSearchIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ protected function setUp(): void
// Disable Elasticsearch during tests (we test DB index)
config()->set('encrypted-search.elasticsearch.enabled', false);

// Set minimum prefix length to 1 for backwards compatibility in basic tests
config()->set('encrypted-search.min_prefix_length', 1);

// Ensure Eloquent events are active (boot model & dispatcher)
\Illuminate\Database\Eloquent\Model::unsetEventDispatcher();
\Illuminate\Database\Eloquent\Model::setEventDispatcher(app('events'));
Expand Down
1 change: 1 addition & 0 deletions tests/Feature/HasEncryptedSearchIndexEdgeCasesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ protected function setUp(): void

config()->set('encrypted-search.elasticsearch.enabled', false);
config()->set('encrypted-search.search_pepper', 'test-pepper-secret');
config()->set('encrypted-search.min_prefix_length', 1);

\Illuminate\Database\Eloquent\Model::unsetEventDispatcher();
\Illuminate\Database\Eloquent\Model::setEventDispatcher(app('events'));
Expand Down
Loading
Loading