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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

- **SEC-008: Mask API keys in debug output and clear from memory in destructor** (#76)
- Added `__debugInfo()` method to `ApiEmbeddingFunction` — masks API key as `***xxxx` (last 4 chars visible) in `var_dump()` output
- Added `__destruct()` method to `ApiEmbeddingFunction` — calls `sodium_memzero()` on the API key string buffer when `ext-sodium` is available
- Constructor `$apiKey` parameter changed from `string` to `?string = null` — falls back to `OPENAI_API_KEY` or `DASHSCOPE_API_KEY` environment variables
- Added `private __clone()` to prevent cloned-instance string buffer corruption
- Updated `SECURITY.md` — marked "API keys in memory" as fixed
- Added 4 test files: `test_embedding_apikey_mask.phpt` (source analysis), `test_embedding_apikey_env.phpt` (env var fallback), `test_embedding_apikey_destruct.phpt` (destructor), `test_embedding_apikey_runtime.phpt` (runtime validation)

- **SEC-012: Enforce explicit SSL certificate verification in embedding API requests** (#80)
- Added `CURLOPT_SSL_VERIFYPEER => true` and `CURLOPT_SSL_VERIFYHOST => 2` to all embedding HTTP requests
- Using `curl_setopt_array()` to ensure SSL options are always applied together
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@ All C++ FFI functions accept opaque handle types. When these are null (e.g., aft

1. **Local filesystem security**: The temp directory fix (SEC-002) assumes the attacker already has local user access. It prevents privilege escalation via symlink attacks.
2. **TLS is not explicitly pinned**: The download stream context sets `verify_peer` and `verify_peer_name` to true, relying on the system's CA bundle. No certificate pinning is implemented.
3. **API keys in memory**: Embedding function API keys are stored in PHP string properties and are not explicitly zeroed after use. This is a known limitation (see SEC-008).
3. **API keys in memory**: Embedding function API keys are stored in PHP string properties. Starting from v0.4.12+, `__debugInfo()` masks the key in var_dump output and `__destruct()` calls `sodium_memzero()` when the sodium extension is available (see SEC-008).
39 changes: 36 additions & 3 deletions src/embeddings/EmbeddingInterfaces.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,23 +73,56 @@ public function embedBatch(array $inputs): array;
*/
abstract class ApiEmbeddingFunction
{
protected string $apiKey;
protected string $apiKey = '';
protected string $baseUrl;
protected int $timeout;
protected ?string $proxy = null;

public function __construct(
string $apiKey,
?string $apiKey = null,
?string $baseUrl = null,
int $timeout = 30,
?string $proxy = null
) {
$this->apiKey = $apiKey;
$this->apiKey = $apiKey ?? (getenv('OPENAI_API_KEY') ?: null) ?? (getenv('DASHSCOPE_API_KEY') ?: null) ?? '';
$this->baseUrl = $baseUrl ?? $this->getDefaultBaseUrl();
$this->timeout = $timeout;
$this->proxy = $proxy;
}

/**
* Control var_dump() output — mask the API key to prevent accidental exposure.
*
* @return array Debug info with masked API key
*/
public function __debugInfo(): array
{
return [
'apiKey' => '***' . substr($this->apiKey, -4),
'baseUrl' => $this->baseUrl,
'timeout' => $this->timeout,
'proxy' => $this->proxy,
];
}

/**
* Destructor — clear API key from memory if sodium extension is available.
*/
public function __destruct()
{
if (function_exists('sodium_memzero')) {
sodium_memzero($this->apiKey);
}
}

/**
* Prevent cloning — cloned instances would share the same string buffer
* for $apiKey, and sodium_memzero() in __destruct would corrupt the clone's key.
*/
private function __clone(): void
{
}

abstract protected function getDefaultBaseUrl(): string;

/**
Expand Down
4 changes: 2 additions & 2 deletions src/embeddings/OpenAIDenseEmbedding.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,15 @@ class OpenAIDenseEmbedding extends ApiEmbeddingFunction implements DenseEmbeddin
/**
* Constructor.
*
* @param string $apiKey OpenAI API key
* @param string|null $apiKey OpenAI API key (default: OPENAI_API_KEY env var)
* @param string $model Model name (default: text-embedding-3-small)
* @param int|null $dimensions Number of dimensions (optional, only for v3 models)
* @param string|null $baseUrl Custom base URL (default: https://api.openai.com/v1)
* @param int $timeout Request timeout in seconds (default: 30)
* @param string|null $proxy HTTP proxy URL (optional)
*/
public function __construct(
string $apiKey,
?string $apiKey = null,
string $model = self::MODEL_SMALL,
?int $dimensions = null,
?string $baseUrl = null,
Expand Down
4 changes: 2 additions & 2 deletions src/embeddings/QwenDenseEmbedding.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ class QwenDenseEmbedding extends ApiEmbeddingFunction implements DenseEmbeddingF
/**
* Constructor.
*
* @param string $apiKey DashScope API key
* @param string|null $apiKey DashScope API key (default: DASHSCOPE_API_KEY env var)
* @param string $model Model name (default: text-embedding-v4)
* @param string|null $baseUrl Custom base URL (default: https://dashscope.aliyuncs.com/api/v1)
* @param int $timeout Request timeout in seconds (default: 30)
* @param string|null $proxy HTTP proxy URL (optional)
*/
public function __construct(
string $apiKey,
?string $apiKey = null,
string $model = self::MODEL_V4,
?string $baseUrl = null,
int $timeout = 30,
Expand Down
59 changes: 59 additions & 0 deletions tests/test_embedding_apikey_destruct.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
--TEST--
SEC-008: API key destructor — __destruct with sodium_memzero
--FILE--
<?php
$source = file_get_contents(__DIR__ . '/../src/embeddings/EmbeddingInterfaces.php');

// Test 1: __destruct method exists
if (str_contains($source, 'function __destruct()')) {
echo "PASS: __destruct method declared in EmbeddingInterfaces.php\n";
} else {
echo "FAIL: __destruct method not found\n";
exit(1);
}

// Test 2: __destruct uses sodium_memzero
if (str_contains($source, 'sodium_memzero($this->apiKey)')) {
echo "PASS: __destruct calls sodium_memzero on apiKey\n";
} else {
echo "FAIL: sodium_memzero call not found in __destruct\n";
exit(1);
}

// Test 3: function_exists check for sodium_memzero
if (str_contains($source, 'function_exists(\'sodium_memzero\')')) {
echo "PASS: __destruct checks if sodium_memzero exists before calling\n";
} else {
echo "FAIL: function_exists check for sodium_memzero not found\n";
exit(1);
}

// Test 4: Verify the destructor is in the ApiEmbeddingFunction class
$apiClassStart = strpos($source, 'abstract class ApiEmbeddingFunction');
$destructPos = strpos($source, 'function __destruct()');
if ($destructPos !== false && $apiClassStart !== false && $destructPos > $apiClassStart) {
echo "PASS: __destruct method is inside ApiEmbeddingFunction class\n";
} else {
echo "FAIL: __destruct not found inside ApiEmbeddingFunction class\n";
exit(1);
}

// Test 5: Verify __debugInfo is also inside ApiEmbeddingFunction
$debugInfoPos = strpos($source, 'function __debugInfo()');
if ($debugInfoPos !== false && $debugInfoPos > $apiClassStart) {
echo "PASS: __debugInfo method is inside ApiEmbeddingFunction class\n";
} else {
echo "FAIL: __debugInfo not found inside ApiEmbeddingFunction class\n";
exit(1);
}

echo "\nAll destructor source checks passed!\n";
?>
--EXPECT--
PASS: __destruct method declared in EmbeddingInterfaces.php
PASS: __destruct calls sodium_memzero on apiKey
PASS: __destruct checks if sodium_memzero exists before calling
PASS: __destruct method is inside ApiEmbeddingFunction class
PASS: __debugInfo method is inside ApiEmbeddingFunction class

All destructor source checks passed!
67 changes: 67 additions & 0 deletions tests/test_embedding_apikey_env.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
--TEST--
SEC-008: API key loading from environment variables — source analysis
--FILE--
<?php
$source = file_get_contents(__DIR__ . '/../src/embeddings/EmbeddingInterfaces.php');

// Test 1: Constructor uses env var fallback
if (str_contains($source, "getenv('OPENAI_API_KEY')") && str_contains($source, "getenv('DASHSCOPE_API_KEY')")) {
echo "PASS: Constructor uses OPENAI_API_KEY and DASHSCOPE_API_KEY env vars as fallback\n";
} else {
echo "FAIL: Env var fallback not found in constructor\n";
exit(1);
}

// Test 2: apiKey parameter is nullable
if (str_contains($source, '?string $apiKey = null')) {
echo "PASS: apiKey constructor parameter is nullable with default null\n";
} else {
echo "FAIL: apiKey parameter not nullable\n";
exit(1);
}

// Test 3: Null coalescing chain for env vars
$constructStart = strpos($source, 'public function __construct(');
$constructEnd = strpos($source, 'abstract protected function getDefaultBaseUrl()');
if ($constructStart !== false && $constructEnd !== false) {
$constructBody = substr($source, $constructStart, $constructEnd - $constructStart);
$hasCoalescing = preg_match('/\$this->apiKey\s*=\s*\$apiKey\s*\?\?/', $constructBody);
if ($hasCoalescing) {
echo "PASS: Constructor uses null coalescing for apiKey with env var fallback\n";
} else {
echo "FAIL: Null coalescing pattern not found in constructor\n";
exit(1);
}
} else {
echo "FAIL: Could not locate constructor\n";
exit(1);
}

// Test 4: OpenAIDenseEmbedding constructor also nullable
$openaiSource = file_get_contents(__DIR__ . '/../src/embeddings/OpenAIDenseEmbedding.php');
if (str_contains($openaiSource, '?string $apiKey = null')) {
echo "PASS: OpenAIDenseEmbedding constructor has nullable apiKey\n";
} else {
echo "FAIL: OpenAIDenseEmbedding constructor apiKey not nullable\n";
exit(1);
}

// Test 5: QwenDenseEmbedding constructor also nullable
$qwenSource = file_get_contents(__DIR__ . '/../src/embeddings/QwenDenseEmbedding.php');
if (str_contains($qwenSource, '?string $apiKey = null')) {
echo "PASS: QwenDenseEmbedding constructor has nullable apiKey\n";
} else {
echo "FAIL: QwenDenseEmbedding constructor apiKey not nullable\n";
exit(1);
}

echo "\nAll API key env var source checks passed!\n";
?>
--EXPECT--
PASS: Constructor uses OPENAI_API_KEY and DASHSCOPE_API_KEY env vars as fallback
PASS: apiKey constructor parameter is nullable with default null
PASS: Constructor uses null coalescing for apiKey with env var fallback
PASS: OpenAIDenseEmbedding constructor has nullable apiKey
PASS: QwenDenseEmbedding constructor has nullable apiKey

All API key env var source checks passed!
72 changes: 72 additions & 0 deletions tests/test_embedding_apikey_mask.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
--TEST--
SEC-008: API key masking — __debugInfo() source analysis
--FILE--
<?php
$source = file_get_contents(__DIR__ . '/../src/embeddings/EmbeddingInterfaces.php');

// __debugInfo method exists
if (!str_contains($source, 'function __debugInfo()')) { echo "FAIL: __debugInfo method not found\n"; exit(1); }
echo "PASS: __debugInfo method declared in EmbeddingInterfaces.php\n";

// API key masking pattern
if (!str_contains($source, "'***' . substr(\$this->apiKey, -4)")) { echo "FAIL: API key mask pattern not found\n"; exit(1); }
echo "PASS: API key mask pattern found (*** + last 4 chars)\n";

// apiKey key in return array
if (!str_contains($source, "'apiKey'")) { echo "FAIL: apiKey key not in __debugInfo return array\n"; exit(1); }
echo "PASS: apiKey key present in __debugInfo return array\n";

// Raw apiKey NOT exposed directly
$debugInfoPos = strpos($source, 'function __debugInfo()');
$braceCount = 0;
$debugBody = '';
for ($i = $debugInfoPos; $i < strlen($source); $i++) {
$debugBody .= $source[$i];
if ($source[$i] === '{') $braceCount++;
if ($source[$i] === '}') $braceCount--;
if ($braceCount === 0 && $i > $debugInfoPos) break;
}
if (str_contains($debugBody, "'apiKey' => \$this->apiKey")) { echo "FAIL: Raw apiKey exposed in __debugInfo\n"; exit(1); }
echo "PASS: Raw apiKey not exposed directly in __debugInfo\n";

// __clone method prevents cloned-instance buffer corruption
if (!str_contains($source, 'function __clone()')) { echo "FAIL: __clone method not found\n"; exit(1); }
echo "PASS: __clone method declared to prevent cloning\n";

// Destructor with sodium_memzero
if (!str_contains($source, 'sodium_memzero($this->apiKey)')) { echo "FAIL: sodium_memzero call not found\n"; exit(1); }
echo "PASS: Destructor calls sodium_memzero on apiKey\n";
if (!str_contains($source, "function_exists('sodium_memzero')")) { echo "FAIL: function_exists guard not found\n"; exit(1); }
echo "PASS: Destructor checks sodium_memzero exists before calling\n";

// Nullable apiKey with env var fallback
if (!str_contains($source, '?string $apiKey = null')) { echo "FAIL: Nullable apiKey not found\n"; exit(1); }
if (!str_contains($source, "getenv('OPENAI_API_KEY')")) { echo "FAIL: OPENAI_API_KEY env var fallback not found\n"; exit(1); }
if (!str_contains($source, "getenv('DASHSCOPE_API_KEY')")) { echo "FAIL: DASHSCOPE_API_KEY env var fallback not found\n"; exit(1); }
echo "PASS: Constructor has nullable apiKey with env var fallback\n";

// Verify OpenAIDenseEmbedding constructor is also nullable
$openaiSource = file_get_contents(__DIR__ . '/../src/embeddings/OpenAIDenseEmbedding.php');
if (!str_contains($openaiSource, '?string $apiKey = null')) { echo "FAIL: OpenAIDenseEmbedding apiKey not nullable\n"; exit(1); }
echo "PASS: OpenAIDenseEmbedding constructor has nullable apiKey\n";

// Verify QwenDenseEmbedding constructor is also nullable
$qwenSource = file_get_contents(__DIR__ . '/../src/embeddings/QwenDenseEmbedding.php');
if (!str_contains($qwenSource, '?string $apiKey = null')) { echo "FAIL: QwenDenseEmbedding apiKey not nullable\n"; exit(1); }
echo "PASS: QwenDenseEmbedding constructor has nullable apiKey\n";

echo "\nAll API key security source checks passed!\n";
?>
--EXPECT--
PASS: __debugInfo method declared in EmbeddingInterfaces.php
PASS: API key mask pattern found (*** + last 4 chars)
PASS: apiKey key present in __debugInfo return array
PASS: Raw apiKey not exposed directly in __debugInfo
PASS: __clone method declared to prevent cloning
PASS: Destructor calls sodium_memzero on apiKey
PASS: Destructor checks sodium_memzero exists before calling
PASS: Constructor has nullable apiKey with env var fallback
PASS: OpenAIDenseEmbedding constructor has nullable apiKey
PASS: QwenDenseEmbedding constructor has nullable apiKey

All API key security source checks passed!
Loading
Loading