Skip to content
Open
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
13 changes: 13 additions & 0 deletions admin/setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,19 @@
* Actions
*/

// Once the first fiscal fingerprint exists, the taxpayer identity that owns
// the chain is immutable. Certificate rotation remains possible.
if ($action == 'update' && !empty($user->admin)) {
$postedTaxId = GETPOST('VERIFACTU_HOLDER_NIF', 'alphanohtml');
$currentTaxId = $conf->global->VERIFACTU_HOLDER_NIF ?? '';
if (normalizeVerifactuTaxIdentifier($postedTaxId) !== normalizeVerifactuTaxIdentifier($currentTaxId)
&& hasVerifactuFiscalRecords((int) $conf->entity)) {
setEventMessages($langs->trans('VERIFACTU_TAX_IDENTITY_LOCKED'), null, 'errors');
$action = '';
$error++;
}
}

// For retrocompatibility Dolibarr < 15.0
if (versioncompare(explode('.', DOL_VERSION), array(15)) < 0 && $action == 'update' && !empty($user->admin)) {
$formSetup->saveConfFromPost();
Expand Down
1 change: 1 addition & 0 deletions langs/en_US/verifactu.lang
Original file line number Diff line number Diff line change
Expand Up @@ -718,3 +718,4 @@ verifactu_OPERACION_Exenta = Exempt Operation
verifactu_OPERACION_ExentaTooltip = Exemption type (E1-E6) when operation is VAT/IGIC/IPSI exempt.
verifactu_INCIDENCIA = Incident
verifactu_INCIDENCIATooltip = Indicates technical incident (no electricity, no internet, system failure). Defaults to "N".
VERIFACTU_TAX_IDENTITY_LOCKED=The taxpayer NIF cannot be changed because this entity already contains chained VeriFactu fiscal records.
1 change: 1 addition & 0 deletions langs/es_ES/verifactu.lang
Original file line number Diff line number Diff line change
Expand Up @@ -718,3 +718,4 @@ verifactu_OPERACION_Exenta = Operación Exenta
verifactu_OPERACION_ExentaTooltip = Tipo de exención (E1-E6) cuando la operación está exenta de IVA/IGIC/IPSI.
verifactu_INCIDENCIA = Incidencia
verifactu_INCIDENCIATooltip = Indica si hubo incidencia técnica (sin electricidad, sin internet, fallo del sistema). Por defecto "N".
VERIFACTU_TAX_IDENTITY_LOCKED=No se puede modificar el NIF del obligado tributario porque esta entidad ya contiene registros fiscales encadenados de Verifactu.
45 changes: 33 additions & 12 deletions lib/functions/functions.certificates.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,33 +54,37 @@ function prepareLocalCertificate(
}

$isWindows = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
$opensslBin = $isWindows ? "\"$winOpensslPath\"" : 'openssl';
$opensslBin = $isWindows ? escapeshellarg($winOpensslPath) : 'openssl';

$certOut = $outputPath . '_cert.pem';
$keyOut = $outputPath . '_key.pem';

// Extract certificate without key
$cmdCert = "$opensslBin pkcs12 -in \"$certificateFile\" -clcerts -nokeys -out \"$certOut\" -password pass:$certificatePassword";
$cmdCert = $opensslBin . ' pkcs12 -in ' . escapeshellarg($certificateFile)
. ' -clcerts -nokeys -out ' . escapeshellarg($certOut)
. ' -password ' . escapeshellarg('pass:' . $certificatePassword);
exec($cmdCert, $outputCert, $codeCert);

if ($codeCert !== 0 || !file_exists($certOut)) {
die("Error extracting certificate.");
throw new RuntimeException("Error extracting certificate.");
}

// Extract private key (with or without passphrase)
$passOut = $privateKeyPassword ?? $certificatePassword;
$cmdKey = "$opensslBin pkcs12 -in \"$certificateFile\" -nocerts " .
($encryptKey ? '' : '-nodes') .
" -out \"$keyOut\" -password pass:$certificatePassword" .
($encryptKey ? " -passout pass:$passOut" : '');
$cmdKey = $opensslBin . ' pkcs12 -in ' . escapeshellarg($certificateFile)
. ' -nocerts ' . ($encryptKey ? '' : '-nodes ')
. '-out ' . escapeshellarg($keyOut)
. ' -password ' . escapeshellarg('pass:' . $certificatePassword)
. ($encryptKey ? ' -passout ' . escapeshellarg('pass:' . $passOut) : '');
exec($cmdKey, $outputKey, $codeKey);

if ($codeKey !== 0 || !file_exists($keyOut)) {
die("Error extracting private key.");
throw new RuntimeException("Error extracting private key.");
}

// Combine into a single .pem
file_put_contents($bundleFile, file_get_contents($certOut) . "\n" . file_get_contents($keyOut));
@chmod($bundleFile, 0600);

// Delete temporary files
unlink($certOut);
Expand Down Expand Up @@ -153,7 +157,7 @@ function extractPrivateKeyWithOpenSSL(
): array {

$isWindows = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
$opensslBin = $isWindows ? "\"$winOpensslPath\"" : 'openssl';
$opensslBin = $isWindows ? escapeshellarg($winOpensslPath) : 'openssl';

// Verify binary exists
if ($isWindows && !file_exists($winOpensslPath)) {
Expand All @@ -168,7 +172,9 @@ function extractPrivateKeyWithOpenSSL(

try {
// Command to extract private key without encryption
$cmd = "$opensslBin pkcs12 -in \"$certificateFile\" -nocerts -nodes -out \"$tempKey\" -password pass:$certificatePassword 2>&1";
$cmd = $opensslBin . ' pkcs12 -in ' . escapeshellarg($certificateFile)
. ' -nocerts -nodes -out ' . escapeshellarg($tempKey)
. ' -password ' . escapeshellarg('pass:' . $certificatePassword) . ' 2>&1';

// Execute command
exec($cmd, $output, $returnCode);
Expand Down Expand Up @@ -252,7 +258,7 @@ function extractPublicCertificateWithOpenSSL(
): array {

$isWindows = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
$opensslBin = $isWindows ? "\"$winOpensslPath\"" : 'openssl';
$opensslBin = $isWindows ? escapeshellarg($winOpensslPath) : 'openssl';

if ($isWindows && !file_exists($winOpensslPath)) {
return [
Expand All @@ -264,7 +270,9 @@ function extractPublicCertificateWithOpenSSL(
$tempCert = tempnam(sys_get_temp_dir(), 'verifactu_cert_') . '.pem';

try {
$cmd = "$opensslBin pkcs12 -in \"$certificateFile\" -clcerts -nokeys -out \"$tempCert\" -password pass:$certificatePassword 2>&1";
$cmd = $opensslBin . ' pkcs12 -in ' . escapeshellarg($certificateFile)
. ' -clcerts -nokeys -out ' . escapeshellarg($tempCert)
. ' -password ' . escapeshellarg('pass:' . $certificatePassword) . ' 2>&1';

exec($cmd, $output, $returnCode);

Expand Down Expand Up @@ -665,6 +673,19 @@ function validateCertificateAndKey($certPath, $certPassphrase = '')
return false;
}

// Reject certificates that are not currently valid. A matching private key
// is not sufficient when the X.509 validity window has expired or has not begun.
$certInfo = openssl_x509_parse($cert);
$now = time();
$validFrom = isset($certInfo['validFrom_time_t']) ? (int) $certInfo['validFrom_time_t'] : 0;
$validTo = isset($certInfo['validTo_time_t']) ? (int) $certInfo['validTo_time_t'] : 0;
if (($validFrom > 0 && $now < $validFrom) || ($validTo > 0 && $now > $validTo)) {
openssl_x509_free($cert);
dol_syslog("VERIFACTU: Certificate is outside its X.509 validity period", LOG_ERR);
$GLOBALS['verifactu_cert_error'] = "Certificate is outside its X.509 validity period";
return false;
}

// Extract private key
$privateKey = openssl_pkey_get_private($certContent, $certPassphrase);
if ($privateKey === false) {
Expand Down
41 changes: 41 additions & 0 deletions lib/functions/functions.configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,47 @@ function calculateVerifactuIntegrityChecksums($moduleDirectory)
return hash('sha256', json_encode($files));
}

/**
* Normalize a taxpayer identifier before comparing immutable configuration.
*
* @param string $taxId Taxpayer identifier
* @return string Normalized identifier
*/
function normalizeVerifactuTaxIdentifier($taxId)
{
return strtoupper(preg_replace('/[^A-Z0-9]/i', '', trim((string) $taxId)));
}

/**
* Check whether an entity already has invoices in its VeriFactu fiscal chain.
*
* @param int|null $entity Current entity by default
* @return bool True when at least one fingerprint was generated
*/
function hasVerifactuFiscalRecords($entity = null)
{
global $conf, $db;

$entity = ($entity === null ? (int) $conf->entity : (int) $entity);
$sql = "SELECT 1 AS found";
$sql .= " FROM " . MAIN_DB_PREFIX . "facture AS f";
$sql .= " INNER JOIN " . MAIN_DB_PREFIX . "facture_extrafields AS fe ON fe.fk_object = f.rowid";
$sql .= " WHERE f.entity = " . $entity;
$sql .= " AND fe.verifactu_huella IS NOT NULL AND fe.verifactu_huella <> ''";
$sql .= $db->plimit(1);
$resql = $db->query($sql);
if (!$resql) {
// Fail closed: fiscal identity changes must not be allowed when chain state
// cannot be verified.
dol_syslog(__FUNCTION__ . ': unable to inspect the fiscal chain: ' . $db->lasterror(), LOG_ERR);
return true;
}

$hasRecords = (bool) $db->fetch_object($resql);
$db->free($resql);
return $hasRecords;
}

/**
* Gets the billing system configuration for AEAT
*
Expand Down
75 changes: 75 additions & 0 deletions tests/IntegrityHardeningTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

if (!defined('MAIN_DB_PREFIX')) {
define('MAIN_DB_PREFIX', 'llx_');
}
if (!defined('LOG_ERR')) {
define('LOG_ERR', 3);
}
if (!function_exists('dol_syslog')) {
function dol_syslog($message, $level = 0)
{
}
}

require_once __DIR__ . '/../lib/functions/functions.configuration.php';

class FakeIntegrityDatabase
{
private $rows;
private $position = 0;

public function __construct(array $rows)
{
$this->rows = $rows;
}

public function query($sql)
{
$this->position = 0;
return true;
}

public function fetch_object($result)
{
if (!isset($this->rows[$this->position])) {
return false;
}
return (object) $this->rows[$this->position++];
}

public function free($result)
{
}

public function lasterror()
{
return '';
}

public function plimit($limit)
{
return ' LIMIT ' . (int) $limit;
}
}

$conf = (object) array('entity' => 1);

if (normalizeVerifactuTaxIdentifier(' b-12345678 ') !== 'B12345678') {
fwrite(STDERR, "Taxpayer identifiers must be normalized consistently\n");
exit(1);
}

$db = new FakeIntegrityDatabase(array(array('found' => 1)));
if (!hasVerifactuFiscalRecords(1)) {
fwrite(STDERR, "An existing fiscal fingerprint must lock the taxpayer identity\n");
exit(1);
}

$db = new FakeIntegrityDatabase(array());
if (hasVerifactuFiscalRecords(1)) {
fwrite(STDERR, "An entity without fiscal fingerprints must remain configurable\n");
exit(1);
}

echo "Integrity hardening tests passed\n";