Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a392fa3
always encrypt data
ginkelsoft-development Oct 13, 2025
36f38cb
always encrypt data
ginkelsoft-development Oct 13, 2025
bb9a8f0
fix: Elasticsearch query scopes now properly use ES backend
ginkelsoft-development Oct 13, 2025
3406612
remove CLAUDE.md from repository
ginkelsoft-development Oct 13, 2025
e4dd2ab
Merge pull request #13 from ginkelsoft-development/fix/elasticsearch-…
ginkelsoft-development Oct 13, 2025
1e2b88c
fix: only generate search tokens for fields with encrypted cast
ginkelsoft-development Oct 13, 2025
cef2546
Merge branch 'develop' into fix/only-index-encrypted-fields
ginkelsoft-development Oct 13, 2025
56b0175
Merge pull request #14 from ginkelsoft-development/fix/only-index-enc…
ginkelsoft-development Oct 13, 2025
5bc6cdc
fix: implement actual document deletion in Elasticsearch
ginkelsoft-development Oct 13, 2025
9b8c020
Merge pull request #15 from ginkelsoft-development/fix/elasticsearch-…
ginkelsoft-development Oct 13, 2025
de907bc
fix: validate SEARCH_PEPPER configuration before token generation
ginkelsoft-development Oct 13, 2025
128c23d
Merge pull request #16 from ginkelsoft-development/fix/validate-peppe…
ginkelsoft-development Oct 13, 2025
f62e1d7
fix: require intl extension for consistent normalization
ginkelsoft-development Oct 13, 2025
6f5cc39
improve: add clarifying comments to database index update process
ginkelsoft-development Oct 13, 2025
f295a10
fix: add proper error handling with exceptions to Elasticsearch service
ginkelsoft-development Oct 13, 2025
5cac167
refactor: remove unused SearchDriver contract interface
ginkelsoft-development Oct 13, 2025
8f7e733
fix: add configuration validation to service provider boot
ginkelsoft-development Oct 13, 2025
515d6ba
add optional debug logging for search index operations
ginkelsoft-development Oct 13, 2025
46522ea
expand test coverage with comprehensive unit and edge case tests
ginkelsoft-development Oct 13, 2025
5b39f28
Merge pull request #17 from ginkelsoft-development/fix/require-intl-e…
ginkelsoft-development Oct 13, 2025
3afd96e
Merge pull request #18 from ginkelsoft-development/fix/optimize-datab…
ginkelsoft-development Oct 13, 2025
d0a0b12
Merge pull request #19 from ginkelsoft-development/fix/elasticsearch-…
ginkelsoft-development Oct 13, 2025
4f264fa
Merge pull request #20 from ginkelsoft-development/refactor/remove-un…
ginkelsoft-development Oct 13, 2025
b9a348a
Merge pull request #21 from ginkelsoft-development/feature/add-debug-…
ginkelsoft-development Oct 13, 2025
7d763e8
Merge pull request #22 from ginkelsoft-development/fix/add-config-val…
ginkelsoft-development Oct 13, 2025
19b5f5a
Merge pull request #23 from ginkelsoft-development/test/expand-test-c…
ginkelsoft-development Oct 13, 2025
1f0d882
add configurable minimum prefix length for search queries
ginkelsoft-development Oct 13, 2025
09e8e92
Merge pull request #24 from ginkelsoft-development/feature/minimum-pr…
ginkelsoft-development Oct 13, 2025
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
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
],
"require": {
"php": "^8.1 || ^8.2 || ^8.3 || ^8.4",
"illuminate/support": "^9.0 || ^10.0 || ^11.0 || ^12.0"
"illuminate/support": "^9.0 || ^10.0 || ^11.0 || ^12.0",
"ext-intl": "*"
},
"require-dev": {
"phpunit/phpunit": "^9.5.10 || ^10.0 || ^11.0",
Expand Down
39 changes: 38 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 Expand Up @@ -82,4 +105,18 @@
'host' => env('ELASTICSEARCH_HOST', 'http://elasticsearch:9200'),
'index' => env('ELASTICSEARCH_INDEX', 'encrypted_search'),
],

/*
|--------------------------------------------------------------------------
| Debug Logging
|--------------------------------------------------------------------------
|
| Enable debug logging for encrypted search operations. When enabled,
| the package will log token generation, index updates, and deletions
| to help with debugging and monitoring.
|
| Warning: This can generate a lot of log entries in high-traffic applications.
|
*/
'debug' => env('ENCRYPTED_SEARCH_DEBUG', false),
];
12 changes: 0 additions & 12 deletions src/Contracts/SearchDriver.php

This file was deleted.

34 changes: 34 additions & 0 deletions src/EncryptedSearchServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ public function register(): void
*/
public function boot(): void
{
// Validate configuration
$this->validateConfiguration();

// Publish configuration
$this->publishes([
__DIR__ . '/../config/encrypted-search.php' => config_path('encrypted-search.php'),
Expand All @@ -75,4 +78,35 @@ public function boot(): void
// Listen for all Eloquent model events and route them through the observer
Event::listen('eloquent.*: *', SearchIndexObserver::class);
}

/**
* Validate package configuration at boot time.
*
* @return void
*
* @throws \InvalidArgumentException if configuration is invalid
*/
protected function validateConfiguration(): void
{
// Validate Elasticsearch configuration if enabled
if (config('encrypted-search.elasticsearch.enabled', false)) {
$host = config('encrypted-search.elasticsearch.host');

if (empty($host)) {
throw new \InvalidArgumentException(
'Elasticsearch is enabled but ELASTICSEARCH_HOST is not configured. ' .
'Set it in your .env file or disable Elasticsearch mode.'
);
}

$index = config('encrypted-search.elasticsearch.index');

if (empty($index)) {
throw new \InvalidArgumentException(
'Elasticsearch is enabled but ELASTICSEARCH_INDEX is not configured. ' .
'Set it in your .env file.'
);
}
}
}
}
47 changes: 41 additions & 6 deletions src/Services/ElasticsearchService.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,29 +49,41 @@ public function __construct(?string $host = null)
* @param string $index The Elasticsearch index name.
* @param string $id The unique document ID.
* @param array<string, mixed> $body The document body to be stored.
* @return bool True if successful, false otherwise.
* @return void
*
* @throws \RuntimeException if the request fails
*/
public function indexDocument(string $index, string $id, array $body): bool
public function indexDocument(string $index, string $id, array $body): void
{
$url = "{$this->host}/{$index}/_doc/{$id}";
$response = Http::put($url, $body);

return $response->successful();
if (!$response->successful()) {
throw new \RuntimeException(
"Failed to index document to Elasticsearch [{$url}]: " . $response->body()
);
}
}

/**
* Delete a document from Elasticsearch by its ID.
*
* @param string $index The Elasticsearch index name.
* @param string $id The document ID to delete.
* @return bool True if successful, false otherwise.
* @return void
*
* @throws \RuntimeException if the request fails
*/
public function deleteDocument(string $index, string $id): bool
public function deleteDocument(string $index, string $id): void
{
$url = "{$this->host}/{$index}/_doc/{$id}";
$response = Http::delete($url);

return $response->successful();
if (!$response->successful()) {
throw new \RuntimeException(
"Failed to delete document from Elasticsearch [{$url}]: " . $response->body()
);
}
}

/**
Expand All @@ -80,12 +92,35 @@ public function deleteDocument(string $index, string $id): bool
* @param string $index The Elasticsearch index name.
* @param array<string, mixed> $query The Elasticsearch query body.
* @return array<int, mixed> The array of matching documents (hits).
*
* @throws \RuntimeException if the request fails
*/
public function search(string $index, array $query): array
{
$url = "{$this->host}/{$index}/_search";
$response = Http::post($url, $query);

if (!$response->successful()) {
throw new \RuntimeException(
"Failed to search Elasticsearch [{$url}]: " . $response->body()
);
}

return $response->json('hits.hits', []);
}

/**
* Delete documents matching a query from an Elasticsearch index.
*
* @param string $index The Elasticsearch index name.
* @param array<string, mixed> $query The Elasticsearch query body.
* @return bool True if successful, false otherwise.
*/
public function deleteByQuery(string $index, array $query): bool
{
$url = "{$this->host}/{$index}/_delete_by_query";
$response = Http::post($url, $query);

return $response->successful();
}
}
13 changes: 7 additions & 6 deletions src/Support/Normalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@
*
* Features:
* - Lowercases all text (UTF-8 safe)
* - Optionally removes diacritics using PHP’s Normalizer (if available)
* - Removes diacritics using PHP's intl extension (required)
* - Strips all non-alphanumeric characters
*
* Requirements:
* - The intl PHP extension must be installed for consistent normalization
*/
class Normalizer
{
Expand All @@ -44,11 +47,9 @@ public static function normalize(?string $v): ?string
// Convert to lowercase (UTF-8 safe)
$s = mb_strtolower($v, 'UTF-8');

// Optionally remove diacritics if intl extension is available
if (class_exists(\Normalizer::class)) {
$s = \Normalizer::normalize($s, \Normalizer::FORM_D);
$s = preg_replace('/\p{M}/u', '', $s); // strip diacritics
}
// Remove diacritics using intl extension
$s = \Normalizer::normalize($s, \Normalizer::FORM_D);
$s = preg_replace('/\p{M}/u', '', $s); // strip diacritics

// Retain only letters and digits
$s = preg_replace('/[^a-z0-9]/u', '', $s);
Expand Down
33 changes: 30 additions & 3 deletions src/Support/Tokens.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,18 @@ class Tokens
*
* @return string
* Hex-encoded SHA-256 hash (64 characters).
*
* @throws \RuntimeException if pepper is empty
*/
public static function exact(string $normalized, string $pepper): string
{
if (empty($pepper)) {
throw new \RuntimeException(
'SEARCH_PEPPER is not configured. Set it in your .env file for security. ' .
'Generate a random string: openssl rand -base64 32'
);
}

return hash('sha256', $normalized . $pepper);
}

Expand All @@ -60,25 +69,43 @@ 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(
'SEARCH_PEPPER is not configured. Set it in your .env file for security. ' .
'Generate a random string: openssl rand -base64 32'
);
}

$out = [];
$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
Loading
Loading