Skip to content
Draft
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ The present file will list all changes made to the project; according to the

### Changed

\GLPIKey::get() only returns a string now. It no longer returns null but can throw InvalidGlpiKeyException on errors (such as key not found, not readable, not at the expected format, not able to decrypt contents).
\GLPIKey::decrypt()
\GLPIKey::generate()

@todo complete review of all changes
### Deprecated

### Removed
Expand Down
10 changes: 9 additions & 1 deletion src/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,15 @@ public function prepareInputForUpdate($input)

$input = array_filter($input, fn($key) => !in_array($key, $values_to_filter), ARRAY_FILTER_USE_KEY);

static::setConfigurationValues('core', $input);
try {
static::setConfigurationValues('core', $input);
} catch (Exception $e) {
Session::addMessageAfterRedirect(
'Error saving configuration: ' . htmlentities($e->getMessage()),
false,
ERROR
);
}

return false;
}
Expand Down
126 changes: 52 additions & 74 deletions src/GLPIKey.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
*
* ---------------------------------------------------------------------
*/
use Glpi\Exception\InvalidGlpiKeyException;
use Glpi\Plugin\Hooks;
use Safe\Exceptions\FilesystemException;
use Safe\Exceptions\SodiumException;
Expand Down Expand Up @@ -126,25 +127,12 @@ public function keyExists()
}

/**
* Get GLPI security key used for decryptable passwords
*
* @return string|null
* Get GLPI security key used for decryptable contents
*/
public function get(): ?string
public function get(): string
{
if (!file_exists($this->keyfile)) {
trigger_error('You must create a security key, see security:change_key command.', E_USER_WARNING);
return null;
}
if (!is_readable($this->keyfile) || ($key = file_get_contents($this->keyfile)) === false) { //@phpstan-ignore theCodingMachineSafe.function
trigger_error('Unable to get security key file contents.', E_USER_WARNING);
return null;
}
if (strlen($key) !== SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
trigger_error('Invalid security key file contents.', E_USER_WARNING);
return null;
}
return $key;
$this->ensureKeyIsValid();
return file_get_contents($this->keyfile);
Comment on lines +134 to +135
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes it read the key file twice every time it needs to perform an encrypt/decrypt. Should be avoided.

Copy link
Contributor Author

@SebSept SebSept Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the downside of having 2 methods. The footprint of reading this local file et probably close to null. So I choose this approach (2 methods vs 1), it looks really better than having a single method that does both (checking key & show the message) and violate SRP principle.
Maybe we can have a local variable to keep the errors and avoid 2 calls to filegetcontents.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you are taking textbook concepts a bit too far here. The single purpose of the get method is to get the key. A part of that happens to be ensuring the key is valid, and that validation is only done here.

Indeed though, it probably doesn't make that much difference in this specific case.

Copy link
Contributor Author

@SebSept SebSept Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you are taking textbook concepts a bit too far here. The single purpose of the get method is to get the key. A part of that happens to be ensuring the key is valid, and that validation is only done here.

Indeed though, it probably doesn't make that much difference in this specific case.

(Looks like my answer was lost)

That's not too far, that's the basis. Experience learnt me that's a very important point. Writing, reading, testing, maintaining, everything is easier respecting separation of concerns.
Only very strong reasons can justify not to respect it. I don't see any reason to do it here.
And we can use ensure method elsewhere in the future.

}

/**
Expand All @@ -171,6 +159,9 @@ public function getLegacyKey(): ?string
* and update values in DB if necessary.
*
* @return bool
* @throws RuntimeException|Exception
* @todo split update and generate methods
* @todo trigger exceptions not errors
*/
public function generate(bool $update_db = true): bool
{
Expand All @@ -181,19 +172,13 @@ public function generate(bool $update_db = true): bool
(file_exists($this->keyfile) && !is_writable($this->keyfile))
|| (!file_exists($this->keyfile) && !is_writable(dirname($this->keyfile)))
) {
trigger_error(sprintf('Security key file path (%s) is not writable.', $this->keyfile), E_USER_WARNING);
return false;
throw new InvalidGlpiKeyException(sprintf('Security key file (%s) cannot be updated. Fix it\'s permission.', $this->keyfile));
}

// Fetch old key before generating the new one (but only if DB exists and there is something to migrate)
$previous_key = null;
if ($update_db && $this->keyExists()) {
$previous_key = $this->get();
if ($previous_key === null) {
// Do not continue if unable to get previous key when DB update is requested.
// Detailed warning has already been triggered by `get()` method.
return false;
}
}

$key = sodium_crypto_aead_chacha20poly1305_ietf_keygen();
Expand All @@ -203,8 +188,7 @@ public function generate(bool $update_db = true): bool
$written_bytes = false;
}
if ($written_bytes !== strlen($key)) {
trigger_error('Unable to write security key file contents.', E_USER_WARNING);
return false;
throw new InvalidGlpiKeyException("Unable to write security key file contents {$this->keyfile}.");
}

if (
Expand Down Expand Up @@ -373,85 +357,59 @@ protected function migrateConfigsInDb($sodium_key): bool
*/
public function encrypt(string $string, ?string $key = null): string
{
if ($key === null) {
$key = $this->get();
}

if ($key === null) {
// Cannot encrypt string as key reading fails, returns a empty value
// to ensure sensitive data is not propagated unencrypted.
return '';
try {
$key ??= $this->get();
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); // NONCE = Number to be used ONCE, for each message
$encrypted = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
$string,
$nonce,
$nonce,
$key
);
return base64_encode($nonce . $encrypted);
} catch (InvalidGlpiKeyException|SodiumException $e) {
throw new RuntimeException('Unable to encrypt string: ' . $e->getMessage(), 0, $e);
}

$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); // NONCE = Number to be used ONCE, for each message
$encrypted = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
$string,
$nonce,
$nonce,
$key
);
return base64_encode($nonce . $encrypted);
}

/**
* Decrypt a string.
*
* @param string|null $string String to decrypt.
* @param string|null $key Key to use, fallback to default key if null.
*
* @return string|null
*/
public function decrypt(?string $string, ?string $key = null): ?string
public function decrypt(?string $string, ?string $key = null): string
{
if (empty($string)) {
// Avoid sodium exception for blank content. Just return the null/empty value.
return $string;
}
$key ??= $this->get(); // executed before everything to ensure invalid key exception is thrown early

if ($key === null) {
$key = $this->get();
}

if ($key === null) {
// Cannot decrypt string as key reading fails, returns encrypted value.
return $string;
// Early return of '' to avoid sodium exception for blank content.
if (empty($string)) {
return '';
}

try {
$string = base64_decode($string);
} catch (UrlException $e) {
trigger_error(
'Unable to base64_decode the string. The string was probably not encrypted using GLPIKey::encrypt',
E_USER_WARNING
);
return '';
throw new RuntimeException('Unable to base64_decode the string. The string was probably not encrypted using GLPIKey::encrypt', $e->getCode(), $e);
}

$nonce = mb_substr($string, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES, '8bit');
if (mb_strlen($nonce, '8bit') !== SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES) {
trigger_error(
'Unable to extract nonce from string. It may not have been crypted with sodium functions.',
E_USER_WARNING
);
return '';
throw new InvalidGlpiKeyException('Unable to extract nonce from string. It may not have been crypted with sodium functions.');
}

$ciphertext = mb_substr($string, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES, null, '8bit');

// try catch to provide a more meaningful exception (type & message)
try {
$plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
return sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
$ciphertext,
$nonce,
$nonce,
$key
);
return $plaintext;
} catch (SodiumException $e) {
trigger_error(
'Unable to decrypt string. It may have been crypted with another key.',
E_USER_WARNING
);
return '';
throw new InvalidGlpiKeyException('Unable to decrypt string. It may have been crypted with another key'); // sodium messages are not meaningful, no need to propagate them.
}
}

Expand Down Expand Up @@ -497,4 +455,24 @@ public function decryptUsingLegacyKey(string $string, ?string $key = null): stri

return $result;
}

/**
* Ensure that key file exists, is readable and contains a valid key.
*
* @throws InvalidGlpiKeyException
*/
private function ensureKeyIsValid(): void
{
$relative_keyfile_path = './' . ltrim(str_replace(GLPI_ROOT, '', $this->keyfile), '/');
if (!file_exists($this->keyfile)) {
throw new InvalidGlpiKeyException("File $relative_keyfile_path not found. Create it with `./bin/console security:change_key` command.");
}
if (!is_readable($this->keyfile) || ($key = @file_get_contents($this->keyfile)) === false) {
throw new InvalidGlpiKeyException("Unable to read security key file contents. Fix $relative_keyfile_path permissions.");
}
if (strlen($key) !== SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
throw new InvalidGlpiKeyException("Invalid security key file contents. Regenerate $relative_keyfile_path with `./bin/console security:change_key` command.");
}
}

}
19 changes: 16 additions & 3 deletions src/GLPINetwork.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,23 @@ public static function getIcon()

public static function displayTabContentForItem(CommonGLPI $item, $tabnum = 1, $withtemplate = 0)
{
if ($item::class === Config::class) {
self::showForConfig();
try {
if ($item->getType() == 'Config') {
$glpiNetwork = new self();
$glpiNetwork->showForConfig();
Comment on lines +57 to +58
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
$glpiNetwork = new self();
$glpiNetwork->showForConfig();
self::showForConfig();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I intented to have the less possible changes, but I can change it if it hurt your eyes 😆

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's only just because it is a static method and should be called statically.

}
return true;
} catch (Exception $e) {
TemplateRenderer::getInstance()->display(
'/central/messages.html.twig',
[
'messages' => [
'errors' => [$e->getMessage()],
],
]
);
return false;
}
return true;
}

public static function showForConfig()
Expand Down
43 changes: 43 additions & 0 deletions src/Glpi/Exception/InvalidGlpiKeyException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

/**
* ---------------------------------------------------------------------
*
* GLPI - Gestionnaire Libre de Parc Informatique
*
* http://glpi-project.org
*
* @copyright 2015-2025 Teclib' and contributors.
* @licence https://www.gnu.org/licenses/gpl-3.0.html
*
* ---------------------------------------------------------------------
*
* LICENSE
*
* This file is part of GLPI.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* ---------------------------------------------------------------------
*/

namespace Glpi\Exception;

/**
* Denotes a problem while using a GLPI API key.
*
* This can be an invalid key (unexpected length), a missing key file, an unreadable key file.
* Fact that content was not decrypted can also mean the content was not created with the current key.
*/
class InvalidGlpiKeyException extends \Exception {}
4 changes: 2 additions & 2 deletions tests/GLPITestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public function setUp(): void

// Make sure the tester plugin is never deactived by a test as it would
// impact others tests that depend on it.
$this->assertTrue(Plugin::isPluginActive('tester'));
// $this->assertTrue(Plugin::isPluginActive('tester'));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// $this->assertTrue(Plugin::isPluginActive('tester'));
$this->assertTrue(Plugin::isPluginActive('tester'));

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, should not be committed. I'll fix it

}

public function tearDown(): void
Expand All @@ -108,7 +108,7 @@ public function tearDown(): void

// Make sure the tester plugin is never deactived by a test as it would
// impact others tests that depend on it.
$this->assertTrue(Plugin::isPluginActive('tester'));
// $this->assertTrue(Plugin::isPluginActive('tester'));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// $this->assertTrue(Plugin::isPluginActive('tester'));
$this->assertTrue(Plugin::isPluginActive('tester'));

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not spent too much time reviewing, it's not ready yet.


if (isset($_SESSION['MESSAGE_AFTER_REDIRECT']) && !$this->has_failed) {
unset($_SESSION['MESSAGE_AFTER_REDIRECT'][INFO]);
Expand Down
Loading
Loading