Add theme engine, animated UI, and Bus Dump / Crypto / Exfil modules

UI:
- Theme engine (5 palettes) persisted to NVS; live Theme switcher module
- Animated shell: boot splash, CRT scanlines, breathing menu cursor,
  wipe-in transitions, spinners, progress bars

New recon modules:
- Bus Dump: SPI-NOR + I2C EEPROM readout to microSD (read-only)
- Crypto Lab: entropy, magic-byte file-ID, XOR brute, AES-128 known-key
- Exfil: SD browser + base64 dump export over USB serial, manifest writer

README updated with the new module table and look-and-feel notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAhMHMRAQLQ9hSbBECKNfn
This commit is contained in:
Indiana Holmes
2026-09-11 00:17:57 +00:00
parent 79612197a1
commit d1c37b83e0
10 changed files with 615 additions and 42 deletions

126
src/modules/busdump.cpp Normal file
View File

@@ -0,0 +1,126 @@
#include "../core/module.h"
#include "../core/ui.h"
#include "../core/pins.h"
#include <SPI.h>
#include <Wire.h>
#include <SD.h>
// Bus Dump — read the contents of memory chips on YOUR boards.
// SPI : 25-series NOR flash. JEDEC ID, then 0x03 READ the whole chip
// to /dump/spi_<id>.bin on microSD.
// I2C : scan the bus, dump a 24-series EEPROM to /dump/i2c_<addr>.bin.
// Read-only; never writes the target. [space] start/stop.
class BusDump : public Module {
enum Mode { SPI_NOR, I2C_EE } mode = SPI_NOR;
bool running = false;
uint32_t addr = 0, size = 0, jedec = 0;
uint8_t i2cAddr = 0x50;
char msg[3][40] = {{0},{0},{0}};
File out;
bool sdOk = false;
static constexpr int CS = pins::PROBE[2];
public:
const char* name() const override { return "Bus Dump"; }
const char* blurb() const override { return "SPI/I2C chip readout"; }
void onEnter() override {
running = false; addr = 0; size = 0; jedec = 0;
sdOk = SD.begin();
say(sdOk ? "SD ready" : "no SD (dump->serial)");
}
void onExit() override { if (out) out.close(); running = false; SPI.end(); Wire.end(); }
bool onKey(char c) override {
if (c == 'm') { mode = mode==SPI_NOR?I2C_EE:SPI_NOR; running=false; say("mode switched"); return true; }
if (c == 'i') { probeId(); return true; }
if (c == 's') { scanI2C(); return true; }
if (c == '+') { i2cAddr = (i2cAddr+1)&0x7F; return true; }
if (c == ' ' || c == '\r') { running ? stop() : start(); return true; }
return false;
}
void tick() override {
if (!running) return;
uint8_t buf[64];
uint32_t n = (size-addr) < sizeof(buf) ? (size-addr) : sizeof(buf);
if (n == 0) { stop(); say("-- dump complete --"); return; }
if (mode == SPI_NOR) spiRead(addr, buf, n); else i2cRead(addr, buf, n);
if (out) out.write(buf, n); else dumpSerial(buf, n);
addr += n;
}
void draw() override {
ui::lineC(0, ui::accent(), "%s %s", mode==SPI_NOR?"SPI-NOR":"I2C-EE",
running?"DUMPING":"idle");
if (mode == SPI_NOR) ui::line(1, "JEDEC %06lX size %luKB",
(unsigned long)jedec, (unsigned long)(size/1024));
else ui::line(1, "addr 0x%02X size %luB", i2cAddr, (unsigned long)size);
if (running && size) ui::bar(2, addr/(float)size, ui::glow(), "read");
for (int i=0;i<3;i++) ui::line(4+i, "%s", msg[i]);
if (running) ui::spinner(228, ui::BODY_Y+1, ui::glow());
ui::hintBar("[m]ode [i]d [s]can [+]addr [space]go");
}
private:
void say(const char* fmt, ...) {
for (int i=2;i>0;i--) strncpy(msg[i], msg[i-1], 39);
va_list ap; va_start(ap,fmt); vsnprintf(msg[0],40,fmt,ap); va_end(ap);
}
void spiBegin() { SPI.begin(pins::PROBE[3], pins::PROBE[4], pins::PROBE[5], CS);
pinMode(CS, OUTPUT); digitalWrite(CS, HIGH); }
void probeId() {
if (mode != SPI_NOR) { say("id: SPI mode only"); return; }
spiBegin();
SPI.beginTransaction(SPISettings(8000000, MSBFIRST, SPI_MODE0));
digitalWrite(CS, LOW); SPI.transfer(0x9F);
uint8_t m=SPI.transfer(0), t=SPI.transfer(0), c=SPI.transfer(0);
digitalWrite(CS, HIGH); SPI.endTransaction();
jedec = (m<<16)|(t<<8)|c;
size = 1UL << c;
if (c < 16 || c > 26) size = 0;
say("JEDEC %06lX %s", (unsigned long)jedec, size?"":"(unknown cap)");
}
void spiRead(uint32_t a, uint8_t* b, uint32_t n) {
SPI.beginTransaction(SPISettings(8000000, MSBFIRST, SPI_MODE0));
digitalWrite(CS, LOW); SPI.transfer(0x03);
SPI.transfer((a>>16)&0xFF); SPI.transfer((a>>8)&0xFF); SPI.transfer(a&0xFF);
for (uint32_t i=0;i<n;i++) b[i]=SPI.transfer(0);
digitalWrite(CS, HIGH); SPI.endTransaction();
}
void scanI2C() {
Wire.begin(pins::GROVE_A, pins::GROVE_B);
int found = 0;
for (uint8_t a=0x08; a<0x78; a++) {
Wire.beginTransmission(a);
if (Wire.endTransmission()==0) { i2cAddr=a; found++; }
}
say("i2c: %d dev, sel 0x%02X", found, i2cAddr);
}
void i2cRead(uint32_t a, uint8_t* b, uint32_t n) {
Wire.beginTransmission(i2cAddr);
Wire.write((a>>8)&0xFF); Wire.write(a&0xFF);
Wire.endTransmission();
Wire.requestFrom((int)i2cAddr, (int)n);
for (uint32_t i=0;i<n && Wire.available();i++) b[i]=Wire.read();
}
void start() {
if (mode == SPI_NOR) { probeId(); if (!size) { say("probe chip first"); return; } }
else { Wire.begin(pins::GROVE_A, pins::GROVE_B); size = 8192; }
addr = 0;
if (sdOk) {
SD.mkdir("/dump");
char path[40];
if (mode==SPI_NOR) snprintf(path,sizeof(path),"/dump/spi_%06lX.bin",(unsigned long)jedec);
else snprintf(path,sizeof(path),"/dump/i2c_%02X.bin", i2cAddr);
out = SD.open(path, FILE_WRITE);
say("-> %s", path);
} else say("-> serial (hex)");
running = true;
}
void stop() { running = false; if (out) out.close(); }
void dumpSerial(const uint8_t* b, uint32_t n) {
for (uint32_t i=0;i<n;i++) Serial.printf("%02X", b[i]);
}
};
Module* makeBusDump() { return new BusDump(); }

129
src/modules/crypto.cpp Normal file
View File

@@ -0,0 +1,129 @@
#include "../core/module.h"
#include "../core/ui.h"
#include <SD.h>
#include <math.h>
#include "mbedtls/aes.h"
// Crypto Lab — offline analysis of a blob you dumped (Bus Dump output on
// the SD card). Helps you tell encrypted from packed from plaintext, and
// try the obvious cheap keys on firmware you own.
// ENTROPY : Shannon bits/byte over the sample (8.0 ~ encrypted/packed).
// IDENT : magic-byte sniff (gzip, ELF, PNG, JFFS2, uImage, ...).
// XOR : single-byte XOR brute; scores printable ratio, shows best.
// AES : AES-128-ECB decrypt with a key in aesKey[] (known-key).
// Loads /dump/*.bin — first file found, or cycle with [f].
class Crypto : public Module {
enum Mode { ENTROPY, IDENT, XORB, AESD } mode = ENTROPY;
static constexpr int CAP = 1024;
uint8_t buf[CAP]; int len = 0;
char fname[32] = "";
char msg[4][40] = {{0},{0},{0},{0}};
uint8_t aesKey[16] = {0}; bool haveKey = false;
public:
const char* name() const override { return "Crypto Lab"; }
const char* blurb() const override { return "entropy/id/xor/aes"; }
void onEnter() override { if (!SD.begin()) say("no SD"); else loadFirst(); }
bool onKey(char c) override {
if (c == 'm') { mode = (Mode)((mode+1)%4); analyse(); return true; }
if (c == 'f') { loadNext(); return true; }
if (c == 'g') { analyse(); return true; }
return false;
}
void draw() override {
const char* mn[] = {"ENTROPY","IDENT","XOR","AES"};
ui::lineC(0, ui::accent(), "%s file:%s (%dB)", mn[mode],
fname[0]?fname:"none", len);
for (int i=0;i<4;i++) ui::line(2+i, "%s", msg[i]);
ui::hintBar("[m]ode [f]ile [g]o (dumps in /dump)");
}
private:
void say(const char* fmt, ...) {
for (int i=3;i>0;i--) strncpy(msg[i], msg[i-1], 39);
va_list ap; va_start(ap,fmt); vsnprintf(msg[0],40,fmt,ap); va_end(ap);
}
void clearMsg(){ for(int i=0;i<4;i++) msg[i][0]=0; }
void loadFrom(File dir, const char* want) {
File f;
while ((f = dir.openNextFile())) {
if (!f.isDirectory()) {
const char* nm = f.name();
if (!want || strcmp(nm, want) > 0) {
strncpy(fname, nm, 31);
len = f.read(buf, CAP); f.close();
analyse(); return;
}
}
f.close();
}
say("no more files");
}
void loadFirst(){ File d=SD.open("/dump"); if(d){ loadFrom(d, nullptr); d.close(); } else say("mkdir /dump first"); }
void loadNext(){ File d=SD.open("/dump"); if(d){ char cur[32]; strncpy(cur,fname,31); loadFrom(d, cur); d.close(); } }
void analyse() {
clearMsg();
if (!len) { say("load a dump first"); return; }
switch (mode) {
case ENTROPY: doEntropy(); break;
case IDENT: doIdent(); break;
case XORB: doXor(); break;
case AESD: doAes(); break;
}
}
void doEntropy() {
int cnt[256] = {0};
for (int i=0;i<len;i++) cnt[buf[i]]++;
double H = 0;
for (int i=0;i<256;i++) if (cnt[i]) { double p=(double)cnt[i]/len; H -= p*log2(p); }
say("Shannon: %.2f bits/byte", H);
say(H>7.5 ? "-> encrypted/compressed" : H>5 ? "-> code/packed?" : "-> low: text/config");
}
void doIdent() {
struct M { int off; const char* sig; int n; const char* name; };
static const M sigs[] = {
{0,"\x1f\x8b",2,"gzip"}, {0,"\x7f""ELF",4,"ELF"},
{0,"\x89PNG",4,"PNG"}, {0,"BZh",3,"bzip2"},
{0,"\x27\x05\x19\x56",4,"uImage"}, {0,"\x85\x19",2,"JFFS2"},
{0,"hsqs",4,"squashfs"}, {0,"UBI#",4,"UBI"},
{0,"\xff\xd8\xff",3,"JPEG"},
};
for (auto& s : sigs)
if (len>=s.off+s.n && memcmp(buf+s.off, s.sig, s.n)==0) { say("magic: %s", s.name); return; }
say("no known magic (raw/enc?)");
}
void doXor() {
int bestKey=0; double bestScore=-1;
for (int k=0;k<256;k++) {
int print=0;
for (int i=0;i<len;i++){ uint8_t v=buf[i]^k; if(v>=32&&v<127) print++; }
double s=(double)print/len;
if (s>bestScore){ bestScore=s; bestKey=k; }
}
say("best XOR key 0x%02X", bestKey);
say("printable %.0f%%", bestScore*100);
char prev[24]; int j=0;
for (int i=0;i<20 && i<len;i++){ uint8_t v=buf[i]^bestKey; prev[j++]=(v>=32&&v<127)?v:'.'; }
prev[j]=0; say("%s", prev);
}
void doAes() {
say(haveKey ? "AES-128-ECB, custom key" : "AES-128-ECB, key=00..00");
if (!haveKey) say("(edit aesKey[] for real key)");
mbedtls_aes_context ctx; mbedtls_aes_init(&ctx);
mbedtls_aes_setkey_dec(&ctx, aesKey, 128);
uint8_t o[16];
int blocks = len/16 < 2 ? len/16 : 2;
for (int b=0;b<blocks;b++) {
mbedtls_aes_crypt_ecb(&ctx, MBEDTLS_AES_DECRYPT, buf+b*16, o);
char h[40]; int p=0;
for (int i=0;i<16;i++) p+=snprintf(h+p,sizeof(h)-p,"%02X",o[i]);
say("%s", h);
}
mbedtls_aes_free(&ctx);
}
};
Module* makeCrypto() { return new Crypto(); }

104
src/modules/exfil.cpp Normal file
View File

@@ -0,0 +1,104 @@
#include "../core/module.h"
#include "../core/ui.h"
#include <SD.h>
#include "mbedtls/base64.h"
// Exfil / Export — get captured data OFF the Cardputer for analysis on
// your workstation. Browse the SD card and stream a selected dump out
// over USB serial as base64 (paste into `base64 -d > file` on the host).
// Also writes a session manifest of what you pulled. A data-offload
// helper for your own captures — not a network channel.
class Exfil : public Module {
static constexpr int MAXF = 32;
char files[MAXF][32]; uint32_t sizes[MAXF]; int nf = 0, sel = 0;
bool sending = false; File cur; uint32_t sent = 0, total = 0;
char status[2][40] = {{0},{0}};
public:
const char* name() const override { return "Exfil"; }
const char* blurb() const override { return "export dumps->host"; }
void onEnter() override { sending=false; if (!SD.begin()) say("no SD"); else scan(); }
void onExit() override { if (cur) cur.close(); sending=false; }
bool onKey(char c) override {
if (sending) { if (c==' ') stop(); return true; }
int m = nf ? nf : 1;
if (c == ';') { sel = (sel-1+m)%m; return true; }
if (c == '.') { sel = (sel+1)%m; return true; }
if (c == 'r') { scan(); return true; }
if (c == '\r' || c == ' ') { startSend(); return true; }
if (c == 'x') { writeManifest(); return true; }
return false;
}
void tick() override {
if (!sending) return;
uint8_t in[48]; int n = cur.read(in, sizeof(in));
if (n <= 0) { Serial.println(); say("done: %s", files[sel]); stop(); return; }
unsigned char out[80]; size_t olen = 0;
mbedtls_base64_encode(out, sizeof(out), &olen, in, n);
Serial.write(out, olen);
sent += n;
}
void draw() override {
ui::lineC(0, ui::accent(), "SD export %s", sending ? "STREAMING" : "browse");
if (sending) {
ui::bar(1, total ? sent/(float)total : 0, ui::glow(), "b64->usb");
} else {
for (int i = 0; i < nf && i < 5; i++)
ui::lineC(1+i, i==sel ? ui::glow() : ui::fg(), "%c %-16s %luB",
i==sel?char(0x10):' ', files[i], (unsigned long)sizes[i]);
if (!nf) ui::line(1, "no files in /dump");
}
for (int i = 0; i < 2; i++) ui::line(6+i, "%s", status[i]);
ui::hintBar("; . pick enter send [x]manifest [`]back");
}
private:
void say(const char* fmt, ...) {
strncpy(status[1], status[0], 39);
va_list ap; va_start(ap,fmt); vsnprintf(status[0],40,fmt,ap); va_end(ap);
}
void scan() {
nf = 0; sel = 0;
File d = SD.open("/dump");
if (!d) { say("no /dump dir"); return; }
File f;
while ((f = d.openNextFile()) && nf < MAXF) {
if (!f.isDirectory()) {
strncpy(files[nf], f.name(), 31);
sizes[nf] = f.size();
nf++;
}
f.close();
}
d.close();
say("%d file(s)", nf);
}
void startSend() {
if (!nf) { say("nothing to send"); return; }
char path[48]; snprintf(path, sizeof(path), "/dump/%s", files[sel]);
cur = SD.open(path, FILE_READ);
if (!cur) { say("open fail"); return; }
total = sizes[sel]; sent = 0; sending = true;
Serial.printf("\n----- BEGIN %s (base64) -----\n", files[sel]);
say("streaming %s", files[sel]);
}
void stop() {
if (cur) cur.close();
Serial.printf("\n----- END -----\n");
sending = false;
}
void writeManifest() {
File m = SD.open("/dump/MANIFEST.txt", FILE_WRITE);
if (!m) { say("manifest fail"); return; }
m.printf("Card-Crack export manifest\n");
for (int i = 0; i < nf; i++) m.printf("%-20s %lu B\n", files[i], (unsigned long)sizes[i]);
m.close();
say("wrote MANIFEST.txt");
}
};
Module* makeExfil() { return new Exfil(); }

43
src/modules/themepick.cpp Normal file
View File

@@ -0,0 +1,43 @@
#include "../core/module.h"
#include "../core/ui.h"
#include "../core/theme.h"
#include <M5Cardputer.h>
// Live theme switcher. Preview swatches, [space]/arrows to cycle, choice
// saved to NVS so it sticks across reboots.
class ThemePicker : public Module {
public:
const char* name() const override { return "Theme"; }
const char* blurb() const override { return "restyle the UI"; }
bool onKey(char c) override {
if (c == '.' || c == ' ') { theme::set(theme::current() + 1); repaint(); return true; }
if (c == ';') { theme::set(theme::current() - 1); repaint(); return true; }
return false;
}
void onEnter() override { repaint(); }
void draw() override {
auto& d = M5.Display;
ui::lineC(0, ui::accent(), "theme: %s (%d/%d)",
theme::active().name, theme::current() + 1, theme::COUNT);
// swatch row
int y = ui::BODY_Y + 20;
for (int i = 0; i < theme::COUNT; i++) {
const Theme& t = theme::LIST[i];
int x = 6 + i * 30;
d.fillRoundRect(x, y, 24, 24, 4, ui::rgb(t.bg));
d.fillRect(x + 4, y + 4, 16, 5, ui::rgb(t.accent));
d.fillRect(x + 4, y + 11, 10, 4, ui::rgb(t.warn));
d.fillRect(x + 4, y + 16, 14, 4, ui::rgb(t.bad));
d.drawRoundRect(x, y, 24, 24, 4, i == theme::current() ? ui::glow() : ui::dim());
}
ui::line(5, "everything restyles instantly.");
ui::hintBar("; / . cycle [`]back");
}
private:
void repaint() { M5.Display.fillScreen(ui::bg());
ui::titleBar(name(), blurb()); draw(); }
};
Module* makeThemePicker() { return new ThemePicker(); }