fix(optimiseur): un token recopié par l'optimiseur ne sort par aucun journal (brief 4 T7, correctif A)

Constat de T6 (question 2) : la trace masquait un token recopié dans reason ou planId, mais
les lignes [Optimiseur] (planId, texte, détail des refus, nom du handshake) et
GetOptimizerStatus.name le portaient en clair.

- OptimizerSecret : UNE fonction de masquage (texte brut et échappé JSON, objet JSON à toute
  profondeur, clés comprises ; ligne de trace sérialisée), partagée par le client et les traces.
- OptimizerClient masque chaque message reçu dès son analyse, avec le token envoyé sur cette
  connexion : rien en aval (journal, traces, app) ne voit le secret ; dit une fois.
- Test testAnEchoedTokenLeaksNowhere : secret avec " et \ recopié partout ; aucune ligne de
  journal (tous niveaux), aucune trace, aucune réponse RPC. 4 contre-épreuves mordent.

decisions sans endpoint : IDENTIQUE (23 792 lignes), 0 rejeu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-17 13:10:15 +02:00
parent 10683744d2
commit 693e01579f
9 changed files with 274 additions and 34 deletions

View File

@ -19,6 +19,7 @@ HEADERS += \
$$PWD/scheduler/rulebasedscheduler.h \
$$PWD/scheduler/optimizerprotocol.h \
$$PWD/scheduler/optimizerclient.h \
$$PWD/scheduler/optimizersecret.h \
$$PWD/scheduler/optimizerplan.h \
$$PWD/scheduler/socketscheduler.h \
$$PWD/scheduler/optimizerstatus.h \
@ -43,6 +44,7 @@ SOURCES += \
$$PWD/scheduler/rulebasedscheduler.cpp \
$$PWD/scheduler/optimizerprotocol.cpp \
$$PWD/scheduler/optimizerclient.cpp \
$$PWD/scheduler/optimizersecret.cpp \
$$PWD/scheduler/optimizerplan.cpp \
$$PWD/scheduler/socketscheduler.cpp \
$$PWD/scheduler/optimizerstatus.cpp \

View File

@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "optimizerclient.h"
#include "optimizersecret.h"
#include <QFile>
#include <QJsonDocument>
@ -302,6 +303,7 @@ void OptimizerClient::onConnected()
// Relu à CHAQUE connexion (décision Q2) : un token remplacé vaut dès la tentative suivante.
const QByteArray token = readToken();
m_tokenSent = !token.isEmpty();
m_tokenConnexion = QString::fromUtf8(token);
if (m_tokenSent)
params.insert(QStringLiteral("token"), QString::fromUtf8(token));
@ -392,7 +394,16 @@ void OptimizerClient::handleLine(const QByteArray &line)
countProtocolError(QStringLiteral("invalid-json"), QStringLiteral("%1 octets").arg(line.size()));
return;
}
const QJsonObject message = doc.object();
// Correctif A (brief 4 T7) : un optimiseur peut RECOPIER le token qu'il a reçu. Masqué ICI,
// avant toute lecture : nom, version, méthode, plan, motifs — rien en aval ne voit le secret,
// ni le journal, ni les traces, ni l'app.
int echos = 0;
const QJsonObject message = OptimizerSecret::masquerJson(doc.object(), m_tokenConnexion, &echos);
if (echos > 0 && !m_echoTokenSignale) {
m_echoTokenSignale = true;
qCWarning(dcNymeaEnergy()) << "[Optimiseur] l'optimiseur recopie le token dans ses réponses — masqué"
" à la réception (journal, traces, app) ; signalé une fois";
}
const QJsonValue idValue = message.value(QStringLiteral("id"));
if (idValue.isUndefined() || idValue.isNull()) {
@ -553,6 +564,7 @@ void OptimizerClient::dropSocket()
tcp->abort();
socket->deleteLater();
m_buffer.clear();
m_tokenConnexion.clear();
m_skippingLine = false;
}

View File

@ -118,6 +118,11 @@ private:
* **Le token** est relu dans \c tokenPath à CHAQUE tentative de connexion : le remplacer prend
* effet à la suivante, sans redémarrage. Il ne figure dans aucune ligne de journal, ni en clair
* ni par sa longueur ; le message de handshake qui le porte n'est jamais journalisé.
* **Un optimiseur qui le recopie** (\c reason, \c planId, \c name…) ne le fait pas sortir : chaque
* message reçu est masqué dès son analyse (\c OptimizerSecret::masquerJson(), avec le token
* envoyé sur CETTE connexion), avant toute journalisation et avant tout signal — journal, traces
* et charges utiles de l'app ne voient que \c "[token masqué]". Le fait est dit une fois par
* client, jamais la valeur.
*
* \invariant Aucune méthode n'attend : toute E/S est asynchrone. \c requestPlan() rend la main
* immédiatement ; la réponse arrive par signal, au plus tôt au tour de boucle suivant.
@ -291,6 +296,8 @@ private:
quint64 m_handshakeId = 0;
quint64 m_pendingId = 0;
bool m_tokenSent = false; // le handshake en cours porte un token (pour le diagnostic)
QString m_tokenConnexion; // le token envoyé sur CETTE connexion : ce qu'un écho recopierait
bool m_echoTokenSignale = false;
qint64 m_handshakeDeadline = -1; // -1 = pas d'échéance
qint64 m_requestDeadline = -1;

View File

@ -0,0 +1,83 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "optimizersecret.h"
#include <QJsonArray>
#include <QJsonDocument>
namespace OptimizerSecret {
namespace {
// La forme que QJsonDocument donne au token DANS une chaîne : ["…"] sans ses bornes.
QByteArray formeEchappee(const QString &token)
{
const QByteArray tableau = QJsonDocument(QJsonArray{token}).toJson(QJsonDocument::Compact);
return tableau.mid(2, tableau.size() - 4);
}
QJsonValue masquerValeur(const QJsonValue &v, const QString &token, int *masques)
{
switch (v.type()) {
case QJsonValue::String:
return masquerTexte(v.toString(), token, masques);
case QJsonValue::Array: {
QJsonArray out;
for (const QJsonValue &x : v.toArray())
out.append(masquerValeur(x, token, masques));
return out;
}
case QJsonValue::Object:
return masquerJson(v.toObject(), token, masques);
default:
return v;
}
}
} // namespace
QString masquerTexte(const QString &texte, const QString &token, int *masques)
{
if (token.isEmpty())
return texte;
QString out = texte;
// L'échappée d'abord : elle CONTIENT la brute quand le token n'a ni " ni \ (elles sont alors
// égales), et sinon un optimiseur qui a recopié la forme sérialisée l'a fait sous celle-là.
for (const QString &forme : {QString::fromUtf8(formeEchappee(token)), token}) {
const int n = out.count(forme);
if (n == 0)
continue;
out.replace(forme, QString::fromUtf8(Masque)); // UTF-8 : « masqué »
if (masques)
*masques += n;
}
return out;
}
QJsonObject masquerJson(const QJsonObject &message, const QString &token, int *masques)
{
if (token.isEmpty())
return message;
QJsonObject out;
for (auto it = message.constBegin(); it != message.constEnd(); ++it)
out.insert(masquerTexte(it.key(), token, masques), masquerValeur(it.value(), token, masques));
return out;
}
QByteArray masquerLigneJson(const QByteArray &ligne, const QByteArray &token, int *masques)
{
if (token.isEmpty())
return ligne;
const QByteArray echappe = formeEchappee(QString::fromUtf8(token));
if (echappe.isEmpty() || !ligne.contains(echappe))
return ligne;
QByteArray out = ligne;
const int n = out.count(echappe);
out.replace(echappe, Masque);
if (masques)
*masques += n;
return out;
}
}

View File

@ -0,0 +1,56 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QByteArray>
#include <QJsonObject>
#include <QString>
/*!
* \file optimizersecret.h
* \brief Masquage du token d'optimiseur (brief 4 T7, correctif A) — LA fonction partagée par le
* journal, les traces et tout ce qui part vers l'app.
*
* Le token ne voyage que dans le handshake (§3). Un optimiseur qui le RECOPIE dans ce qu'il
* renvoie (\c reason, \c planId, un code, un paramètre, son \c name…) ferait sortir le secret par
* chacun des chemins qui relaient son texte : journal \c [Optimiseur], traces, \c GetOptimizerStatus.
* Plutôt que de masquer à chacun de ces chemins — et d'oublier le prochain —, le client masque
* chaque message REÇU dès son analyse (\c OptimizerClient), avant tout usage : rien en aval ne voit
* jamais le secret. L'écrivain des traces masque en plus la ligne sérialisée (défense en
* profondeur), avec la même fonction.
*
* \invariant Aucune fonction d'ici ne journalise, ni le token, ni sa longueur.
*/
namespace OptimizerSecret {
//! Ce qui remplace le token, partout.
constexpr const char *Masque = "[token masqué]";
/*!
* \brief Remplace dans un TEXTE chaque occurrence du token, sous sa forme brute et sous sa forme
* échappée JSON (\c " et \c \\ précédés d'un \c \\).
* \param texte Texte à masquer. \param token Le secret ; vide = rien à faire.
* \param[in,out] masques Incrémenté du nombre de remplacements (peut être \c nullptr).
* \return Le texte masqué.
*/
QString masquerTexte(const QString &texte, const QString &token, int *masques = nullptr);
/*!
* \brief Masque un message JSON reçu : chaque chaîne ET chaque clé d'objet, à toute profondeur.
* \param message Objet analysé. \param token Le secret ; vide = rien à faire.
* \param[in,out] masques Incrémenté du nombre de remplacements (peut être \c nullptr).
* \return L'objet masqué. Les nombres, booléens et la structure sont inchangés.
*/
QJsonObject masquerJson(const QJsonObject &message, const QString &token, int *masques = nullptr);
/*!
* \brief Masque une ligne JSON DÉJÀ SÉRIALISÉE (\c QJsonDocument, compacte) : le token y est cherché
* sous sa seule forme échappée — c'est ainsi, et seulement ainsi, qu'il y figurerait. Chercher la
* forme brute pourrait mordre sur la syntaxe de la ligne et la rendre illisible.
* \param ligne Ligne JSON. \param token Le secret, brut ; vide = rien à faire.
* \param[in,out] masques Incrémenté du nombre de remplacements (peut être \c nullptr).
* \return La ligne masquée.
*/
QByteArray masquerLigneJson(const QByteArray &ligne, const QByteArray &token, int *masques = nullptr);
}

View File

@ -14,6 +14,7 @@
#include <nymeasettings.h>
#include "energysettings.h"
#include "optimizersecret.h"
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
@ -122,25 +123,6 @@ QVariantList executees(const Slot &slot, const QHash<QString, LoadAction> &appli
return l;
}
QByteArray masquerToken(const QByteArray &ligne, const QByteArray &token, int *masques)
{
if (masques)
*masques = 0;
if (token.isEmpty())
return ligne;
// La forme que QJsonDocument donnerait au token dans une chaîne : ["…"] sans ses bornes.
const QByteArray tableau = QJsonDocument(QJsonArray{QString::fromUtf8(token)}).toJson(QJsonDocument::Compact);
const QByteArray echappe = tableau.mid(2, tableau.size() - 4);
if (echappe.isEmpty() || !ligne.contains(echappe))
return ligne;
QByteArray out = ligne;
const int n = out.count(echappe);
out.replace(echappe, QByteArrayLiteral("[token masqué]"));
if (masques)
*masques = n;
return out;
}
Reglage Reglage::lire()
{
EnergySettings s;
@ -196,7 +178,9 @@ void OptimizerTraceWriter::ecrire(const QVariantMap &ligne, const QDate &jour)
} decompte{m_enAttente.get()};
int masques = 0;
const QByteArray l = OptimizerTrace::masquerToken(
// Seconde garde : le client masque déjà chaque message reçu (OptimizerSecret, correctif A) ;
// celle-ci couvre un token REMPLACÉ depuis la connexion, que l'écrivain relit du fichier.
const QByteArray l = OptimizerSecret::masquerLigneJson(
QJsonDocument(QJsonObject::fromVariantMap(ligne)).toJson(QJsonDocument::Compact),
token(), &masques)
+ '\n';

View File

@ -32,8 +32,9 @@
* \c t (instant du cycle, heure locale avec décalage), \c request (les paramètres \c GetPlan
* envoyés, ou ceux qui l'auraient été), \c response (ce qui a répondu à CETTE requête),
* \c executed (actions appliquées au cycle), \c strategy (\c optimizer | \c rules | \c none),
* \c latencyMs (optimiseur seulement). Jamais le token : il ne voyage que dans le handshake,
* et l'écrivain masque en plus toute occurrence que la réponse brute d'un optimiseur porterait.
* \c latencyMs (optimiseur seulement). Jamais le token : il ne voyage que dans le handshake ;
* le client masque toute réponse qui le recopierait (\c OptimizerSecret), et l'écrivain masque
* encore la ligne sérialisée, avec la même fonction.
*
* \invariant Les traces restent sur le site (invariant 10 du moteur) : aucun envoi, export manuel.
* \invariant Rien ici ne commande : une trace activée ou non, en échec ou non, ne change aucune
@ -99,17 +100,6 @@ QVariantMap reponseRegles(const Plan &plan, const QDateTime &now);
QVariantList executees(const Slot &slot, const QHash<QString, LoadAction> &appliquees,
const QSet<QString> &delestees);
/*!
* \brief Remplace, dans une ligne déjà sérialisée, chaque occurrence du token par
* \c "[token masqué]".
* \param ligne Ligne JSON. \param token Le secret, brut ; vide = rien à faire.
* \param[out] masques Nombre de remplacements (peut être \c nullptr).
* \return La ligne masquée.
* \note Le token est cherché sous sa forme ÉCHAPPÉE JSON (\c " et \c \\ ) : c'est ainsi qu'il
* apparaîtrait dans la ligne.
*/
QByteArray masquerToken(const QByteArray &ligne, const QByteArray &token, int *masques = nullptr);
/*! \brief Réglage des traces (\c Optimizer/trace* dans \c energy.conf). */
struct Reglage {
bool active = false; //!< \c Optimizer/trace — défaut : désactivé.

View File

@ -14,8 +14,10 @@ TARGET = etm-powersync-optimizerclient
INCLUDEPATH += $$PWD/../../../energyplugin
HEADERS += $$PWD/../../../energyplugin/etm/scheduler/optimizerclient.h \
$$PWD/../../../energyplugin/etm/scheduler/optimizersecret.h \
$$PWD/../common/etm-powersync-fakeoptimizer.h
SOURCES += $$PWD/../../../energyplugin/etm/scheduler/optimizerclient.cpp \
$$PWD/../../../energyplugin/etm/scheduler/optimizersecret.cpp \
testoptimizerclient.cpp
target.path = $$[QT_INSTALL_PREFIX]/bin

View File

@ -134,6 +134,8 @@ private slots:
void testTokenIsInNoTrace();
void testTraceLineSize();
void testTracerPairsTheResponseByRequestId();
// Brief 4 T7 — correctif A : un token recopié par l'optimiseur ne sort nulle part
void testAnEchoedTokenLeaksNowhere();
private:
//! Banc commun ; \p avecOptimiseur faux = rien n'est configuré.
@ -1944,5 +1946,107 @@ void TestSocketScheduler::testTracerPairsTheResponseByRequestId()
QCOMPARE(l.first().value("latencyMs").toInt(), 12);
}
void TestSocketScheduler::testAnEchoedTokenLeaksNowhere()
{
// Correctif A (brief 4 T7). Un optimiseur qui RECOPIE le token — avec les deux caractères que
// JSON échappe — dans tout ce qu'il renvoie et que le moteur relaie : son nom et sa version au
// handshake, l'identifiant et la stratégie du plan, le texte d'une action, un code hors
// catalogue, une clé et une valeur de paramètre de motif, le texte d'une abstention, un loadId
// inconnu, une notification. Le secret ne doit apparaître dans AUCUNE ligne de journal (tous
// niveaux, nymea compris, hors la trace brute des données ENTRANTES de nymea — voir
// testTokenIsWriteOnlyAndNeverLeaks), aucune trace, aucune réponse ni notification RPC.
monter();
const QString secret = QStringLiteral("T7-echo\"du\\token-9876543210");
const QByteArray echappe = QJsonDocument(QJsonArray{secret}).toJson(QJsonDocument::Compact).mid(2).chopped(2);
const QStringList fragments{secret, QString::fromUtf8(echappe), QStringLiteral("9876543210")};
QCOMPARE(rpc("SetOptimizerToken", {{"token", secret}}).value("energyError").toString(),
QString("EnergyErrorNoError"));
const QString d = activerTraces();
QSignalSpy paquets(m_mockTcpServer, &MockTcpServer::outgoingData);
QLoggingCategory::setFilterRules(QStringLiteral("*.debug=true"));
Journal j;
const QJsonObject handshakeAvant = m_handshake;
m_handshake = handshakeResult();
m_handshake.insert("name", "écho " + secret);
m_handshake.insert("protocolVersion", "1.1-" + secret);
const int handshakes = m_handshakes.count();
m_serveur->abortClient();
QVERIFY(waitFor([this, handshakes] { return m_handshakes.count() > handshakes; }, 8000));
QCOMPARE(m_handshakes.last().value("token").toString(), secret); // le secret a bien transité
SocketScheduler *s = m_arb->socketScheduler();
QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }));
m_repondre = [this, secret](const QVariantMap &req) {
QVariantMap p = plan(req, {action("ecs", {{"powerW", 1000}, {"reason", "écho " + secret},
{"reasonCode", "SURPLUS_SETPOINT"},
{"reasonParams", QVariantMap{{"budgetW", secret}, {secret, 5},
{"setpointW", 1000}}}}),
action(m_var, {{"powerW", 500}, {"reasonCode", secret}})});
p.insert("planId", secret);
p.insert("strategy", secret);
p.insert("diagnostics", QVariantMap{{"status", "optimal"}, {"solver", secret}});
return p;
};
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
cycle(t0, -3000);
cycle(t0.addSecs(60), -3000);
QVERIFY(s->optimiseurAuDernierCycle());
QCOMPARE(rpc("GetOptimizerStatus").value("name").toString(), QString("écho [token masqué]"));
rpc("GetPlan");
m_arb->loadTelemetry();
injectAndWait("NymeaEnergy.GetLoadTelemetry");
m_repondre = [secret](const QVariantMap &) { return QVariantMap{{"abstain", true}, {"reason", "écho " + secret}}; };
cycle(t0.addSecs(120), -3000);
m_repondre = [this, secret](const QVariantMap &req) {
return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}}),
action(secret, {{"powerW", 1}})});
};
cycle(t0.addSecs(180), -3000);
m_serveur->sendJson(QJsonObject{{"jsonrpc", "2.0"}, {"method", secret}, {"params", QJsonObject{{secret, secret}}}});
settle(200);
cycle(t0.addSecs(240), -3000);
rpc("GetOptimizerStatus");
rpc("GetPlan");
// Le texte de l'optimiseur a bien coulé au journal — MASQUÉ.
QVERIFY2(j.texte().contains("plan [token masqué] reçu"), qUtf8Printable(j.texte().right(3000)));
QVERIFY(j.texte().contains("« écho [token masqué] »"));
QVERIFY(j.texte().contains("abstention : écho [token masqué]"));
QVERIFY(j.texte().contains("charge [token masqué] inconnue"));
QCOMPARE(j.compte("recopie le token"), 1);
// Aucune ligne du journal hors la trace brute ENTRANTE de nymea (la pose du token).
for (const QString &f : fragments) {
QStringList fuites;
for (const QString &x : j.lignes(f))
if (!x.startsWith(QLatin1String("JsonRpcTraffic: ")))
fuites << x.left(300);
QVERIFY2(fuites.isEmpty(), qUtf8Printable(fuites.join('\n')));
}
// Aucune réponse ni notification RPC (5 réponses au moins : 2 statuts, 2 plans, la télémétrie).
QVERIFY2(paquets.count() >= 5, qPrintable(QString::number(paquets.count())));
for (const QList<QVariant> &p : paquets)
for (const QString &f : fragments)
QVERIFY2(!p.last().toByteArray().contains(f.toUtf8()), p.last().toByteArray().left(300).constData());
// Aucune trace.
QVERIFY(traces(d, 5).count() >= 5);
QByteArray tout;
for (const QString &nom : fichiersTrace(d)) {
QFile f(QDir(d).filePath(nom));
QVERIFY(f.open(QIODevice::ReadOnly));
tout += f.readAll();
}
QVERIFY(tout.contains("[token masqué]"));
for (const QString &f : fragments)
QVERIFY2(!tout.contains(f.toUtf8()), qPrintable(f));
m_handshake = handshakeAvant;
QFile::remove(OptimizerClient::defaultTokenPath());
QLoggingCategory::setFilterRules(QStringLiteral(
"*.debug=false\nApplication.debug=true\nSimulation.debug=true\nExperiences.debug=true\n"
"NymeaEnergy.debug=true\nEnergyMocks.debug=true\nDBus.warning=false"));
}
QTEST_MAIN(TestSocketScheduler)
#include "testsocketscheduler.moc"