diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index c444fbf..7809f0b 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -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 \ diff --git a/energyplugin/etm/scheduler/optimizerclient.cpp b/energyplugin/etm/scheduler/optimizerclient.cpp index 8d6421a..0aab4d0 100644 --- a/energyplugin/etm/scheduler/optimizerclient.cpp +++ b/energyplugin/etm/scheduler/optimizerclient.cpp @@ -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 #include @@ -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; } diff --git a/energyplugin/etm/scheduler/optimizerclient.h b/energyplugin/etm/scheduler/optimizerclient.h index 5f6d8c7..bd30294 100644 --- a/energyplugin/etm/scheduler/optimizerclient.h +++ b/energyplugin/etm/scheduler/optimizerclient.h @@ -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; diff --git a/energyplugin/etm/scheduler/optimizersecret.cpp b/energyplugin/etm/scheduler/optimizersecret.cpp new file mode 100644 index 0000000..da11dd3 --- /dev/null +++ b/energyplugin/etm/scheduler/optimizersecret.cpp @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync + +#include "optimizersecret.h" + +#include +#include + +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; +} + +} diff --git a/energyplugin/etm/scheduler/optimizersecret.h b/energyplugin/etm/scheduler/optimizersecret.h new file mode 100644 index 0000000..918bf53 --- /dev/null +++ b/energyplugin/etm/scheduler/optimizersecret.h @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include + +/*! + * \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); + +} diff --git a/energyplugin/etm/scheduler/optimizertrace.cpp b/energyplugin/etm/scheduler/optimizertrace.cpp index a58de1b..19c63a6 100644 --- a/energyplugin/etm/scheduler/optimizertrace.cpp +++ b/energyplugin/etm/scheduler/optimizertrace.cpp @@ -14,6 +14,7 @@ #include #include "energysettings.h" +#include "optimizersecret.h" Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy) @@ -122,25 +123,6 @@ QVariantList executees(const Slot &slot, const QHash &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'; diff --git a/energyplugin/etm/scheduler/optimizertrace.h b/energyplugin/etm/scheduler/optimizertrace.h index 4ee59c5..2803cc5 100644 --- a/energyplugin/etm/scheduler/optimizertrace.h +++ b/energyplugin/etm/scheduler/optimizertrace.h @@ -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 &appliquees, const QSet &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é. diff --git a/tests/auto/optimizerclient/optimizerclient.pro b/tests/auto/optimizerclient/optimizerclient.pro index 0598f27..71c5a05 100644 --- a/tests/auto/optimizerclient/optimizerclient.pro +++ b/tests/auto/optimizerclient/optimizerclient.pro @@ -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 diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp index a7865fa..c4e0b1f 100644 --- a/tests/auto/socketscheduler/testsocketscheduler.cpp +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -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 &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"