fix(optimiseur): token d'au moins 16 caractères (correctifs brief 4, point 4)
Question 4 de T7 : le masquage à la réception remplace TOUTE occurrence du token dans ce que l'optimiseur renvoie ; un token court, présent par hasard dans un identifiant de charge, corromprait le plan reçu (unknown-load). - SetOptimizerToken (writeToken) refuse un token non vide de moins de 16 caractères (points de code UTF-8) : EnergyErrorInvalidParameter, fault `token-refused` comme les autres refus ; rien n'est modifié ; le motif est journalisé, jamais la valeur. Vide = retrait, inchangé. - Le client relit le fichier au début de chaque tentative ; plus court que 16 caractères, il ne se connecte PAS (ni avec ce token, ni sans token : les mesures du site ne partent pas vers un optimiseur que rien n'authentifie). lastErrorCode `token-too-short`, dit une fois par série, nouvelle tentative au backoff jusqu'au remplacement du fichier. INTERFACE.md (SetOptimizerToken, lastErrorCode) et protocole §3 (E-T2-8, forme du token). Tests : 15 refusé, 16 accepté, 15 « é » refusés (caractères, pas octets), vide = retrait, fichier court sans connexion puis token conforme envoyé ; tokens des suites portés à 16 au moins. Contre-épreuves M4a (refus retiré) et M4b (fichier court envoyé) : mordent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e3b916e249
commit
5768c36953
@ -136,7 +136,11 @@ Réponse attendue :
|
||||
- `planning` : `true` si l'optimiseur produit des plans multi-créneaux ; `false` s'il
|
||||
ne répond qu'au présent (plan à un créneau). Informationnel.
|
||||
- `token` : si configuré côté plugin et absent/incorrect côté serveur → connexion fermée.
|
||||
Recommandé pour tout endpoint `tcp://`.
|
||||
Recommandé pour tout endpoint `tcp://`. **Forme** (E-T2-8) : chaîne de **16 caractères au
|
||||
moins** et de 4 096 octets au plus, imprimable, sans blanc ni caractère de contrôle (UTF-8
|
||||
admis). Le plugin refuse d'en poser un plus court, et ne se connecte pas avec un fichier de
|
||||
token plus court (ni sans token) : il masque toute occurrence du token dans ce que l'optimiseur
|
||||
renvoie, et un token court corromprait un identifiant qui le contiendrait.
|
||||
- `capabilities` (1.1, optionnel) : affiché en mode installateur. **S'il est présent**, le
|
||||
plugin rejette toute action dont le `kind` n'y est pas annoncé.
|
||||
- Pas de `locale` : la locale nymea est par connexion app (`AGENTS.md` invariant 12) ; la
|
||||
|
||||
@ -172,6 +172,8 @@ bool OptimizerClient::writeToken(const QString &path, const QByteArray &token, Q
|
||||
// survivrait pas à l'aller-retour, et le handshake échouerait sans raison visible.
|
||||
if (token.size() > kMaxTokenBytes)
|
||||
return fail(QStringLiteral("token refusé : plus de %1 octets").arg(kMaxTokenBytes));
|
||||
if (tokenLength(token) < kMinTokenChars)
|
||||
return fail(QStringLiteral("token refusé : moins de %1 caractères").arg(kMinTokenChars));
|
||||
for (const char c : token) {
|
||||
if (static_cast<unsigned char>(c) <= 0x20 || c == 0x7f)
|
||||
return fail(QStringLiteral("token refusé : blanc ou caractère de contrôle"));
|
||||
@ -198,6 +200,11 @@ bool OptimizerClient::writeToken(const QString &path, const QByteArray &token, Q
|
||||
return true;
|
||||
}
|
||||
|
||||
int OptimizerClient::tokenLength(const QByteArray &token)
|
||||
{
|
||||
return int(QString::fromUtf8(token).toUcs4().size());
|
||||
}
|
||||
|
||||
void OptimizerClient::start()
|
||||
{
|
||||
if (m_state != Stopped)
|
||||
@ -267,6 +274,20 @@ void OptimizerClient::connectNow()
|
||||
m_tokenSent = false;
|
||||
m_handshakeDeadline = m_clock->nowMs() + kHandshakeTimeoutMs;
|
||||
setState(Connecting);
|
||||
|
||||
// Relu à CHAQUE tentative (décision Q2) : un token remplacé vaut dès la suivante. Un fichier
|
||||
// trop court (posé à la main : la RPC le refuse) n'est pas envoyé, et l'on ne se connecte PAS
|
||||
// sans lui : l'installateur a voulu une connexion authentifiée, et les mesures du site ne
|
||||
// partent pas vers un optimiseur que rien n'authentifie. Dit une fois par série (failConnection),
|
||||
// jamais la valeur ni sa longueur.
|
||||
m_tokenTentative = readToken();
|
||||
if (!m_tokenTentative.isEmpty() && tokenLength(m_tokenTentative) < kMinTokenChars) {
|
||||
m_tokenTentative.clear();
|
||||
failConnection(QStringLiteral("token-too-short"),
|
||||
QStringLiteral("fichier de token de moins de %1 caractères — aucune connexion "
|
||||
"tant qu'il n'est pas remplacé (SetOptimizerToken)").arg(kMinTokenChars));
|
||||
return;
|
||||
}
|
||||
rearm();
|
||||
|
||||
if (m_endpoint.kind == OptimizerEndpoint::Unix) {
|
||||
@ -300,8 +321,9 @@ void OptimizerClient::onConnected()
|
||||
{QStringLiteral("protocolVersion"), QLatin1String(kProtocolVersion)},
|
||||
{QStringLiteral("engine"), QStringLiteral("etm-powersync-energy-plugin-etm/") + m_engineVersion},
|
||||
};
|
||||
// Relu à CHAQUE connexion (décision Q2) : un token remplacé vaut dès la tentative suivante.
|
||||
const QByteArray token = readToken();
|
||||
// Lu au début de cette tentative (connectNow) : un seul accès au fichier par tentative.
|
||||
const QByteArray token = m_tokenTentative;
|
||||
m_tokenTentative.clear();
|
||||
m_tokenSent = !token.isEmpty();
|
||||
m_tokenConnexion = QString::fromUtf8(token);
|
||||
if (m_tokenSent)
|
||||
@ -553,6 +575,7 @@ void OptimizerClient::failConnection(const QString &code, const QString &detail)
|
||||
|
||||
void OptimizerClient::dropSocket()
|
||||
{
|
||||
m_tokenTentative.clear();
|
||||
QIODevice *socket = m_socket;
|
||||
if (!socket)
|
||||
return;
|
||||
|
||||
@ -155,6 +155,19 @@ public:
|
||||
/*! \brief Nom du fichier de token, sous \c NymeaSettings::settingsPath() (décision Q2). */
|
||||
static constexpr const char *kTokenFileName = "energy-optimizer.token";
|
||||
|
||||
/*!
|
||||
* \brief Longueur minimale d'un token non vide, en caractères.
|
||||
*
|
||||
* Le masquage à la réception (\c OptimizerSecret) remplace TOUTE occurrence du token dans ce
|
||||
* que l'optimiseur renvoie : un token court, présent par hasard dans un identifiant de charge
|
||||
* ou un code, corromprait le plan reçu (\c unknown-load → repli). 16 caractères rendent la
|
||||
* rencontre fortuite négligeable.
|
||||
*/
|
||||
static constexpr int kMinTokenChars = 16;
|
||||
|
||||
/*! \brief \return Nombre de caractères (points de code) de \p token, lu en UTF-8. */
|
||||
static int tokenLength(const QByteArray &token);
|
||||
|
||||
/*!
|
||||
* \brief Construit un client ARRÊTÉ : rien n'est ouvert avant \c start().
|
||||
* \param endpoint Adresse de l'optimiseur ; invalide → \c start() refuse.
|
||||
@ -181,7 +194,9 @@ public:
|
||||
* aux droits plus larges est ainsi resserré à 0600.
|
||||
*
|
||||
* \param path Fichier cible (\c defaultTokenPath() en production). Jamais \c energy.conf.
|
||||
* \param token Secret ; vide = retrait du fichier (plus de token configuré).
|
||||
* \param token Secret ; vide = retrait du fichier (plus de token configuré). Refusé s'il
|
||||
* compte moins de \c kMinTokenChars caractères, plus de 4 096 octets, un blanc ou un
|
||||
* caractère de contrôle — et rien n'est alors modifié.
|
||||
* \param[out] error Motif d'échec, en français, sans le secret.
|
||||
* \return Vrai si le fichier est posé (ou retiré) avec succès.
|
||||
* \note Pas de RPC dans ce lot : c'est la fonction qu'elle appellera.
|
||||
@ -297,6 +312,7 @@ private:
|
||||
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
|
||||
QByteArray m_tokenTentative; // relu au début de la tentative, envoyé à son handshake
|
||||
bool m_echoTokenSignale = false;
|
||||
|
||||
qint64 m_handshakeDeadline = -1; // -1 = pas d'échéance
|
||||
|
||||
@ -109,6 +109,7 @@ private slots:
|
||||
void testBadLinesKeepConnection();
|
||||
void testWriteToken();
|
||||
void testTokenNeverInAnyLog();
|
||||
void testAShortTokenFileOpensNoConnection();
|
||||
|
||||
private:
|
||||
// Démarre le client et mène le handshake jusqu'à Ready.
|
||||
@ -350,7 +351,7 @@ void TestOptimizerClient::testInvalidHandshakeIsRefused()
|
||||
void TestOptimizerClient::testWrongTokenThenFixedToken()
|
||||
{
|
||||
const QString tokenPath = m_dir.filePath("energy-optimizer.token");
|
||||
QVERIFY(OptimizerClient::writeToken(tokenPath, "jeton-faux-7f3a"));
|
||||
QVERIFY(OptimizerClient::writeToken(tokenPath, "jeton-faux-7f3a9"));
|
||||
|
||||
FakeOptimizer server(FakeOptimizer::Tcp, m_dir.path());
|
||||
QVERIFY(server.listen());
|
||||
@ -361,7 +362,7 @@ void TestOptimizerClient::testWrongTokenThenFixedToken()
|
||||
client.start();
|
||||
QJsonObject hs;
|
||||
QVERIFY(server.waitMessage(&hs));
|
||||
QCOMPARE(hs.value("params").toObject().value("token").toString(), QStringLiteral("jeton-faux-7f3a"));
|
||||
QCOMPARE(hs.value("params").toObject().value("token").toString(), QStringLiteral("jeton-faux-7f3a9"));
|
||||
server.abortClient();
|
||||
QVERIFY(waitFor([&] { return client.state() == OptimizerClient::Waiting; }));
|
||||
QCOMPARE(client.lastErrorCode(), QStringLiteral("closed-during-handshake"));
|
||||
@ -668,22 +669,22 @@ void TestOptimizerClient::testWriteToken()
|
||||
|
||||
// Nouveau fichier : 0600 malgré un umask permissif.
|
||||
QString error;
|
||||
QVERIFY2(OptimizerClient::writeToken(path, "secret-un", &error), qPrintable(error));
|
||||
QVERIFY2(OptimizerClient::writeToken(path, "secret-un-16-car", &error), qPrintable(error));
|
||||
QCOMPARE(::stat(QFile::encodeName(path).constData(), &st), 0);
|
||||
QCOMPARE(int(st.st_mode & 0777), 0600);
|
||||
QFile f(path);
|
||||
QVERIFY(f.open(QIODevice::ReadOnly));
|
||||
QCOMPARE(f.readAll(), QByteArray("secret-un"));
|
||||
QCOMPARE(f.readAll(), QByteArray("secret-un-16-car"));
|
||||
f.close();
|
||||
|
||||
// Fichier préexistant en 0644 (posé à la main) : remplacé ET resserré.
|
||||
QVERIFY(QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner
|
||||
| QFileDevice::ReadGroup | QFileDevice::ReadOther));
|
||||
QVERIFY(OptimizerClient::writeToken(path, "secret-deux"));
|
||||
QVERIFY(OptimizerClient::writeToken(path, "secret-deux-1234"));
|
||||
QCOMPARE(::stat(QFile::encodeName(path).constData(), &st), 0);
|
||||
QCOMPARE(int(st.st_mode & 0777), 0600);
|
||||
QVERIFY(f.open(QIODevice::ReadOnly));
|
||||
QCOMPARE(f.readAll(), QByteArray("secret-deux"));
|
||||
QCOMPARE(f.readAll(), QByteArray("secret-deux-1234"));
|
||||
f.close();
|
||||
|
||||
// Refus : blanc ou contrôle (le trimmed() de la relecture ne les rendrait pas), trop long.
|
||||
@ -691,13 +692,25 @@ void TestOptimizerClient::testWriteToken()
|
||||
QVERIFY(!error.contains("avec"));
|
||||
QVERIFY(!OptimizerClient::writeToken(path, " bord"));
|
||||
QVERIFY(!OptimizerClient::writeToken(path, QByteArray(4097, 'a')));
|
||||
// Trop court : moins de 16 caractères (le masquage remplace toute occurrence du token). Comptés
|
||||
// en caractères : 15 « é » font 30 octets et restent refusés.
|
||||
QVERIFY(!OptimizerClient::writeToken(path, "quinze-car-1234", &error));
|
||||
QVERIFY2(error.contains("moins de 16 caractères"), qUtf8Printable(error));
|
||||
QVERIFY(!error.contains("quinze"));
|
||||
QVERIFY(!OptimizerClient::writeToken(path, QString(15, QChar(0xE9)).toUtf8()));
|
||||
QVERIFY(f.open(QIODevice::ReadOnly));
|
||||
QCOMPARE(f.readAll(), QByteArray("secret-deux")); // intact
|
||||
QCOMPARE(f.readAll(), QByteArray("secret-deux-1234")); // intact
|
||||
f.close();
|
||||
|
||||
// Seul ce fichier est écrit à côté : aucun temporaire laissé, rien d'autre (energy.conf).
|
||||
QCOMPARE(QDir(m_dir.filePath("tok")).entryList(QDir::Files | QDir::Hidden), QStringList{"energy-optimizer.token"});
|
||||
|
||||
// 16 caractères : accepté, exactement à la borne.
|
||||
QVERIFY2(OptimizerClient::writeToken(path, "seize-car-123456", &error), qPrintable(error));
|
||||
QVERIFY(f.open(QIODevice::ReadOnly));
|
||||
QCOMPARE(f.readAll(), QByteArray("seize-car-123456"));
|
||||
f.close();
|
||||
|
||||
// Vide : retrait (plus de token configuré) ; retirer un absent n'est pas une erreur.
|
||||
QVERIFY(OptimizerClient::writeToken(path, QByteArray()));
|
||||
QVERIFY(!QFile::exists(path));
|
||||
@ -768,5 +781,54 @@ void TestOptimizerClient::testTokenNeverInAnyLog()
|
||||
QVERIFY2(!line.contains(secret) && !line.contains("Zq81"), qPrintable("token dans le journal : " + line));
|
||||
}
|
||||
|
||||
void TestOptimizerClient::testAShortTokenFileOpensNoConnection()
|
||||
{
|
||||
// Un fichier de token de moins de 16 caractères (posé à la main : la RPC le refuse) : le client
|
||||
// ne se connecte PAS — ni avec ce token, ni sans token. Dit une fois, sans la valeur. Relu à
|
||||
// chaque tentative : un token conforme posé ensuite sert à la suivante.
|
||||
const QString tokenPath = m_dir.filePath("energy-optimizer.token");
|
||||
const QByteArray court = "court-4f2a-9c1e"; // 15 caractères
|
||||
{
|
||||
QFile f(tokenPath);
|
||||
QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Truncate));
|
||||
QVERIFY(f.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner));
|
||||
QCOMPARE(f.write(court + '\n'), qint64(court.size() + 1));
|
||||
}
|
||||
FakeOptimizer server(FakeOptimizer::Unix, m_dir.path());
|
||||
QVERIFY(server.listen());
|
||||
SimClock clock;
|
||||
OptimizerClient client(OptimizerEndpoint::parse(server.endpoint()), tokenPath, "9.9", &clock);
|
||||
int attempts = 0;
|
||||
connect(&client, &OptimizerClient::stateChanged, this, [&](OptimizerClient::State s) {
|
||||
if (s == OptimizerClient::Connecting)
|
||||
++attempts;
|
||||
});
|
||||
|
||||
client.start();
|
||||
settle();
|
||||
QCOMPARE(client.state(), OptimizerClient::Waiting);
|
||||
QCOMPARE(client.lastErrorCode(), QStringLiteral("token-too-short"));
|
||||
QCOMPARE(server.connections, 0);
|
||||
// Les tentatives suivantes relisent le fichier, échouent pareil, et ne le redisent pas.
|
||||
clock.advance(1000);
|
||||
settle();
|
||||
clock.advance(2000);
|
||||
settle();
|
||||
QCOMPARE(attempts, 3);
|
||||
QCOMPARE(server.connections, 0);
|
||||
QCOMPARE(countLog("(token-too-short) :"), 1);
|
||||
QCOMPARE(countLog("moins de 16 caractères"), 1);
|
||||
for (const QString &line : std::as_const(s_log))
|
||||
QVERIFY2(!line.contains(QString::fromUtf8(court)), qUtf8Printable(line));
|
||||
|
||||
// Remplacé par un token conforme : la tentative suivante se connecte et l'envoie.
|
||||
QVERIFY(OptimizerClient::writeToken(tokenPath, "seize-car-123456"));
|
||||
clock.advance(4000);
|
||||
QJsonObject hs;
|
||||
QVERIFY(server.waitMessage(&hs));
|
||||
QCOMPARE(hs.value("params").toObject().value("token").toString(), QStringLiteral("seize-car-123456"));
|
||||
QCOMPARE(server.connections, 1);
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(TestOptimizerClient)
|
||||
#include "testoptimizerclient.moc"
|
||||
|
||||
@ -1320,11 +1320,22 @@ void TestSocketScheduler::testTokenIsWriteOnlyAndNeverLeaks()
|
||||
// Refus : pas une chaîne, un blanc — rien ne change, le secret reste.
|
||||
r = injectAndWait("NymeaEnergy.SetOptimizerToken", {{"token", 31415926}}).toMap().value("params").toMap();
|
||||
QCOMPARE(r.value("fault").toString(), QString("token-refused"));
|
||||
r = rpc("SetOptimizerToken", {{"token", "avec blanc"}});
|
||||
r = rpc("SetOptimizerToken", {{"token", "avec un blanc dedans"}});
|
||||
QCOMPARE(r.value("fault").toString(), QString("token-refused"));
|
||||
// Trop court (15 caractères) : refusé de même, par code, sans rien changer.
|
||||
r = rpc("SetOptimizerToken", {{"token", "quinze-car-1234"}});
|
||||
QCOMPARE(r.value("energyError").toString(), QString("EnergyErrorInvalidParameter"));
|
||||
QCOMPARE(r.value("fault").toString(), QString("token-refused"));
|
||||
QVERIFY(f.open(QIODevice::ReadOnly));
|
||||
QCOMPARE(QString::fromUtf8(f.readAll()).trimmed(), secret);
|
||||
f.close();
|
||||
QVERIFY(j.texte().contains("moins de 16 caractères"));
|
||||
// 16 caractères : accepté.
|
||||
QCOMPARE(rpc("SetOptimizerToken", {{"token", "seize-car-123456"}}).value("energyError").toString(),
|
||||
QString("EnergyErrorNoError"));
|
||||
QVERIFY(f.open(QIODevice::ReadOnly));
|
||||
QCOMPARE(QString::fromUtf8(f.readAll()).trimmed(), QString("seize-car-123456"));
|
||||
f.close();
|
||||
|
||||
// Retrait : fichier supprimé, tokenConfigured faux.
|
||||
QCOMPARE(rpc("SetOptimizerToken", {{"token", ""}}).value("energyError").toString(), QString("EnergyErrorNoError"));
|
||||
@ -1349,6 +1360,8 @@ void TestSocketScheduler::testTokenIsWriteOnlyAndNeverLeaks()
|
||||
};
|
||||
QVERIFY2(horsTrafic(secret).isEmpty(), qUtf8Printable(horsTrafic(secret).join('\n')));
|
||||
QVERIFY2(horsTrafic("31415926").isEmpty(), qUtf8Printable(horsTrafic("31415926").join('\n')));
|
||||
for (const QString &x : {QStringLiteral("quinze-car-1234"), QStringLiteral("seize-car-123456")})
|
||||
QVERIFY2(horsTrafic(x).isEmpty(), qUtf8Printable(horsTrafic(x).join('\n')));
|
||||
QVERIFY2(!j.lignes(secret).isEmpty(),
|
||||
"la trace JsonRpcTraffic de nymea ne recopie plus les données entrantes : retirer la mise "
|
||||
"en garde d'INTERFACE.md (SetOptimizerToken)");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user