Files
firmware/test/test_lockdown_redaction/encrypted_storage_stub.cpp
T
James Rich 4fe12cf367 Add a native test suite for lockdown redaction
Lockdown's core promise is that an unauthenticated local connection sees
nothing sensitive. Nothing asserted it. The gates in
PhoneAPI::getFromRadio() are #ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL, and
no native env defined that, so every one of them compiled out of the test
build. Across 84 suites the only lockdown coverage was two cases in
test_stream_api, both on classifyLocalAdminPacket.

Adds [env:coverage-lockdown], which turns the lockdown flags on, and
test_lockdown_redaction, which drives a want_config handshake and checks what
actually reaches the client:

- security config: no private key, no public key, no admin keys
- bluetooth: fixed_pin zeroed
- network: wifi_psk withheld
- device metadata: whole struct wiped
- LoRa: reduced to the public whitelist - region, preset, channel_num and
  hop_limit survive; tx_power, override_frequency and the gain flag do not
- MQTT module config: broker username and password withheld
- channels: PSK and name emptied, and the handshake still reaches
  config_complete rather than stalling
- node DB: other nodes withheld (the DB is populated first, or "no nodes were
  sent" would be true of an empty one and prove nothing)
- inbound: an unauthorized client cannot inject mesh traffic but can still
  deliver lockdown_auth - refusing that too would make a locked node
  permanently unreachable
- with lockdown inactive, nothing is redacted at all: a capable build that was
  never provisioned must behave like stock firmware

Every case asserts both directions. A redaction test that only checks the
redacted side passes just as happily against a build that returns nothing to
anybody, which is not the property we want. Verified by mutation: stubbing
getAdminAuthorized() to always return true fails all eight redaction cases and
leaves the other two passing.

State is reset in setUp/tearDown rather than at the end of each test, because
Unity longjmps out of a failing assertion - inline cleanup would be skipped and
one red test would poison every test after it.

No nRF52 crypto is involved. The real EncryptedStorage hard-errors off
ARCH_NRF52, so the env drops it and links a stub in the suite directory that
keeps the one semantic the tests need: isLockdownActive() means "a DEK file
exists", which a test drives by creating /prefs/.dek.

Wired into CI through SPECIAL_ENVS in bin/test-shards.py, which gives the env
its own shard. The suite also lands in the general pool under plain
[env:coverage], where the flags are absent - it compiles to a single ignored
case there rather than breaking that shard. Both paths verified.
2026-09-15 06:45:27 -05:00

142 lines
2.6 KiB
C++

/*
* Native stand-in for EncryptedStorage.
*
* The real implementation is nRF52-only by design - it hard-errors off ARCH_NRF52 because it
* needs CC310 - so [env:coverage-lockdown] excludes it from the build and links this instead.
* The redaction tests do not exercise crypto: what they need is for isLockdownActive() and
* isUnlocked() to be steerable, so the compile-time gates in PhoneAPI::getFromRadio() are
* reachable. Everything else is a no-op that fails safe.
*
* isLockdownActive() keeps the real semantics - "a DEK file exists" - so tests drive it by
* creating and removing /prefs/.dek, exactly as the firmware decides it.
*/
#include "configuration.h"
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
#include "FSCommon.h"
#include "security/EncryptedStorage.h"
#include <cstring>
namespace EncryptedStorage
{
static const char *kDekFilename = "/prefs/.dek";
static bool s_unlocked = true; // storage unlocked; per-connection auth is the gate under test
static const char *s_lockReason = "ok";
void initLocked() {}
bool isProvisioned()
{
#ifdef FSCom
return FSCom.exists(kDekFilename);
#else
return false;
#endif
}
bool isLockdownActive()
{
return isProvisioned();
}
bool isUnlocked()
{
return s_unlocked;
}
bool provisionPassphrase(const uint8_t *, size_t, uint8_t, uint32_t, uint32_t)
{
s_unlocked = true;
return true;
}
bool unlockWithPassphrase(const uint8_t *, size_t, uint8_t, uint32_t, uint32_t)
{
s_unlocked = true;
return true;
}
void lockNow()
{
s_unlocked = false;
}
void secureWipeKeys()
{
s_unlocked = false;
}
void removeLockdownArtifacts()
{
#ifdef FSCom
FSCom.remove(kDekFilename);
#endif
}
const char *getLockReason()
{
return s_lockReason;
}
uint8_t getBootsRemaining()
{
return TOKEN_DEFAULT_BOOTS;
}
uint32_t getValidUntilEpoch()
{
return 0;
}
uint32_t getBackoffSecondsRemaining()
{
return 0;
}
void setSession(uint32_t) {}
bool isSessionExpired()
{
return false;
}
uint8_t consumeSessionBoot()
{
return 0;
}
// File helpers: the tests use plaintext prefs, so report "not encrypted" and pass writes
// through unchanged rather than pretending to encrypt.
bool isEncrypted(const char *)
{
return false;
}
bool readAndDecrypt(const char *, uint8_t *, size_t, size_t &outLen)
{
outLen = 0;
return false;
}
bool encryptAndWrite(const char *, const uint8_t *, size_t, bool)
{
return false;
}
bool migrateFile(const char *)
{
return true;
}
bool migrateFileToPlaintext(const char *)
{
return true;
}
} // namespace EncryptedStorage
#endif // MESHTASTIC_ENCRYPTED_STORAGE