Some checks failed
repo hygiene / hygiene (push) Has been cancelled
sms / gates (push) Waiting to run
sms / robius-sms (push) Waiting to run
sms / android (push) Waiting to run
sms / nigig-sms (push) Waiting to run
sms / supply-chain (push) Waiting to run
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-map / test (push) Has been cancelled
E1 -- the inbox was written to disk in plaintext. offline_store wrote every SMS body to app_data_dir/offline_store/sms_messages.json as pretty-printed JSON. SMS is the transport for OTPs, banking codes and M-Pesa confirmations, so that file was the user's complete authentication history sitting in app-private storage -- readable by anything running as the same UID, and included in backups. This repository already knew the answer. THREAT_MODEL.md T-I2 records "Raw SMS persisted to PSV file" as fixed in Phase 0, with raw_message omitted from save_to_disk() so it "lives in memory only". The SMS app then re-introduced the same defect at larger scale: the entire inbox rather than just M-Pesa messages, and with no retention limit until D6. OfflineSmsMessage.body is now #[serde(skip)]. Dropping the field rather than encrypting it is the deliberate choice: every consumer already reads the device provider FIRST and writes the cache second (nigig-sms fetch_from_device, and the mpesa and pay transaction pages), so the provider is the system of record and no body needs to survive a restart. Encryption would keep the plaintext reachable to anything holding the key. Not writing it removes the asset. Two consequences handled: sms_key() no longer hashes the body, since a reloaded row has an empty one and dedupe would otherwise never match its own cached entry and grow a duplicate per refresh; and the cached first paint shows a neutral placeholder rather than a blank preview for the instant before the provider read lands. E9 -- the Linux backend's dependencies were pure cost. robius-sms declared polkit =0.17.0 and gio =0.17.0 for target_os = "linux". sys/linux.rs references neither: all twelve functions return Err(PermanentlyUnavailable). Those two crates dragged in glib and proc-macro-error and were the origin of RUSTSEC-2024-0370 and RUSTSEC-2024-0429 for every consumer of this crate. Deleting the block removes 340 lines from Cargo.lock. polkit, gio, glib and proc-macro-error no longer appear in the workspace at all, which also closes the LGPL-2.1 linkage question outright rather than routing around it as Phase B did for nigig-build alone. E4 -- ROBIUS_SMS_BOOT_LIB was a code-injection vector. build.rs interpolated that environment variable straight into a Java string literal, which is then compiled, dexed and loaded at runtime with the app's full permissions. A value containing a quote closes the literal and injects arbitrary Java that runs on the device at boot. Build-time environment is not trusted input. Now validated against [A-Za-z0-9_]+ and the build fails loudly otherwise. Tested both ways: an exec payload is rejected, a legitimate name builds. E5 -- undefined behaviour in the dex loader. new_direct_byte_buffer was handed RECEIVER_BYTECODE.as_ptr() as *mut u8 -- a &'static [u8] in .rodata cast to a mutable pointer, when the API is documented as taking writable memory and InMemoryDexClassLoader may write through it. Now copies into an owned allocation and leaks it, which is correct rather than lazy: the buffer backs a ClassLoader cached in a OnceLock for the process lifetime. E6 -- two bindings for one native method. rustRestoreSchedules was both exported #[no_mangle] and registered dynamically via register_native_methods. Which one won was unspecified. Kept the dynamic one, because the class is loaded from an in-memory dex and is not on the JVM's search path, so symbol binding is not guaranteed to find it. E8 -- no send rate limiting. Nothing capped send rate, and the bulk UI exists to blast a scraped directory. Android's practical throttle is ~30 messages per 30 minutes per app, past which sends are silently dropped -- so an unthrottled batch both overspends and fails opaquely. Adds SendRateLimiter, a pure token bucket taking an explicit clock so it is unit-testable without sleeping, wired into the bulk sender. A 200-recipient blast now stops at 30 and says why. E10 -- robius-sms carried no license field, so cargo-deny needed a [[licenses.clarify]] override asserting one. Stated in the manifest; override removed. E2 was already satisfied by D6 (retention capped at 5,000). E7/E11 are documented rather than fixed, which is the honest status: delivery confirmation needs real PendingIntents plumbed through (A8 documents that Ok != delivered), and sender validation cannot be solved client-side. Both are now rows in THREAT_MODEL.md instead of findings in a markdown report -- along with T-I2b for E1 and T-I4 for E3, which is NOT done: scheduled message bodies are still plaintext in SharedPreferences. CI: adds a gate asserting OfflineSmsMessage.body keeps #[serde(skip)]. Removing that attribute silently resumes writing plaintext and nothing else would fail. Negative-tested. deny-nigig-build.toml drops to 2 exemptions (from 5 before Phase B). Tests: robius-sms 21 -> 25. Verified: 12/12 CI jobs, clippy -D warnings clean on host and aarch64-linux-android, cargo deny "advisories ok, bans ok, licenses ok, sources ok", clippy ratchet holds at 49.
6 KiB
6 KiB
Nigig-Pay Threat Model
1. System Overview
Nigig-pay is a Makepad-based mobile wallet app that automates M-Pesa USSD payments on Android. The Rust code drives an AccessibilityService that types into the USSD dialog, reads confirmation SMS via an SMS inbox scraper, and persists transaction records locally.
Trust Boundary
| Zone | Description |
|---|---|
| Device | User's Android phone. Makepad UI, Rust business logic, local PSV files. |
| USSD Gateway | Safaricom's USSD channel. Untrusted input — cannot prove payment. |
| SMS Inbox | Device SMS app. Forgeable, spoofable, replayable. |
| Server (future) | Backend for reconciliation. Not yet implemented. |
2. Assets
| Asset | Sensitivity | Storage |
|---|---|---|
| M-Pesa PIN | Critical | SharedPreferences (Android) — now scrubbed on session end |
| Phone number | High | SharedPreferences, PSV file |
| Transaction code | Medium | PSV file (unencrypted) |
| Amount, party, balance | Medium | PSV file (unencrypted) |
| Raw SMS text | High | In-memory only. Omitted from the payment PSV (Phase 0) and from offline_store SMS cache (Phase E1). Sensitivity raised from Medium: SMS carries OTPs and banking codes, not just payment confirmations. |
| Biometric auth state | Low | In-memory only |
3. Threat Actors
| Actor | Capability | Goal |
|---|---|---|
| Malicious app | Same-device, same UID | Read SharedPreferences, SMS, PSV files |
| Compromised SMS | Spoofed sender/address | Inject fake M-Pesa confirmation to steal goods |
| Malicious insider | Physical access | Extract PIN from SharedPreferences |
| Network MITM | USSD intercept | Replay or modify USSD session |
4. Threats (STRIDE)
S — Spoofing
| ID | Threat | Mitigation | Status |
|---|---|---|---|
| T-S1 | Spoofed M-Pesa SMS causes false "Verified" | ObservedEvidence renamed to clarify it's not proof. Server confirmation required. |
✅ Phase 0 |
| T-S2 | SMS sender spoofing (not from Safaricom) | No sender validation in SMS scraper. Requires server-side confirmation. | ⚠️ Phase 1 |
T — Tampering
| ID | Threat | Mitigation | Status |
|---|---|---|---|
| T-T1 | PSV file modified on disk | No integrity check. Needs HMAC or encrypted store. | ⚠️ Phase 1 |
| T-T2 | SharedPreferences modified to inject PIN | Now scrubbed on session end; but no encryption-at-rest. | ⚠️ Phase 1 |
R — Repudiation
| ID | Threat | Mitigation | Status |
|---|---|---|---|
| T-R1 | User denies initiating USSD | No server-side audit trail. Biometric gate helps for demo. | ⚠️ Phase 2 |
I — Information Disclosure
| ID | Threat | Mitigation | Status |
|---|---|---|---|
| T-I1 | PIN persists in SharedPreferences indefinitely | session.rs clear() now removes KEY_PIN + all keys. Java finishPin()/fail()/closeUssdAfterResult() also scrub PIN. |
✅ Phase 0 |
| T-I2 | Raw SMS persisted to PSV file | raw_message now omitted from save_to_disk(). Lives in memory only. |
✅ Phase 0 |
| T-I2b | Raw SMS re-persisted by the SMS app | The same defect reappeared at larger scale: offline_store::upsert_sms_messages wrote every inbox message body to app_data_dir/offline_store/sms_messages.json as pretty-printed plaintext, uncapped. Reachable by the same "malicious app, same UID" adversary already in this model, and by any backup extraction. OfflineSmsMessage.body is now #[serde(skip)], so only metadata is written; retention is capped at 5,000 rows. The device provider remains the system of record, so nothing is lost. |
✅ Phase E1 |
| T-I4 | Scheduled SMS bodies in SharedPreferences | robius-sms stores recipient and body in MODE_PRIVATE SharedPreferences for pending schedules. UID-scoped, which is the right primitive, but unencrypted at rest and included in cloud backup by default. Not yet addressed. |
⚠️ Phase E3 |
| T-I3 | PIN leaked in logs | No evidence in current code, but no policy prevents it. | ⚠️ Phase 1 |
D — Denial of Service
| ID | Threat | Mitigation | Status |
|---|---|---|---|
| T-D1 | Auto-retry floods Safaricom | 5 retries with exponential backoff. No idempotency key. | ⚠️ Phase 1 |
E — Elevation of Privilege
| ID | Threat | Mitigation | Status |
|---|---|---|---|
| T-E1 | USSD dispatched without biometric auth | Biometric error now fails closed. Previously dispatched anyway. | ✅ Phase 0 |
| T-E2 | USSD dispatched in production without demo flag | dispatch_ussd, retry_dispatch, start_bulk_payments gated behind #[cfg(feature = "demo")]. |
✅ Phase 0 |
| T-E3 | Bulk payments in production | Gated behind demo feature. No authorized provider rail. |
✅ Phase 0 |
5. Open Risks
| Risk | Severity | Notes |
|---|---|---|
f64 used for money throughout |
Critical | MpesaTransaction.amount: f64, totals() sums f64. IEEE 754 rounding = financial loss. |
| No server-side confirmation | Critical | Local SMS observation cannot prove payment. |
| PSV escaping lossy (` | ↔~`) |
Medium |
| Classification lost on reload | Medium | load_from_disk() hardcodes classification: Default::default(). |
| Hard-coded 2024 fee table | Low | Fee schedules are static since Jan 2024. |
| No encrypted transport | High | When server integration exists, TLS is required. |
| No sender validation on SMS read | High | T-S2 remains open. Nothing verifies an SMS actually came from the claimed sender, so a spoofed M-Pesa confirmation is indistinguishable from a real one to the parser. Mitigation requires server-side confirmation; documented rather than fixed. |
| Delivery is not confirmed | Medium | send_sms returning Ok means the send was handed to the platform, not delivered — both PendingIntents are null. UI must not present Ok as "delivered". Documented in the crate (Phase A8). |
6. References
- NIGIG_PAY_CONSOLIDATED_REVIEW.md — Full audit findings
- PAYMENT_RISK_REGISTER.md — Per-feature risk register