nigig-org/THREAT_MODEL.md
nigig-ci 737a3e5d5d
Some checks failed
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
security(sms): encrypt scheduled payloads, report real send status (E3, E7)
Closes the two Phase E items I had left open and documented as open.

E3 -- scheduled message bodies were plaintext on disk.

  Pending schedules must outlive the process so SmsAlarmReceiver can
  send them when the alarm fires and so they survive a reboot, so
  recipient and body go to SharedPreferences. MODE_PRIVATE is the right
  primitive -- the file is UID-scoped -- but the contents were in the
  clear, readable by anything running as the same UID and swept into
  cloud backup by default. Same asset class as the inbox
  (THREAT_MODEL.md T-I4).

  Adds SmsScheduleCrypto: AES-256-GCM, fresh IV per value, key generated
  inside the platform AndroidKeyStore and non-exportable. An attacker
  with the prefs file but not the keystore gets ciphertext.

  Deliberately NOT androidx.security.EncryptedSharedPreferences: that is
  a Gradle dependency, and this crate compiles its Java with bare javac
  against android.jar (see build.rs), so using it would mean a Gradle
  build or a vendored jar. AndroidKeyStore and javax.crypto are both in
  android.jar and give the property that matters.

  The key is deliberately NOT user-authentication-bound: an alarm fires
  while the device may be locked and the receiver must decrypt with no
  user present. This protects against another app and against an
  extracted backup, which is the threat in scope -- not against someone
  holding an unlocked handset.

  Fails CLOSED. If the keystore is unavailable, encrypt returns null and
  schedule_sms errors rather than writing plaintext. A row that cannot
  be decrypted -- wrong key after a reinstall, tampering, or written by
  an older build -- is treated exactly like a missing row and skipped;
  sending a garbled body would be worse than not sending.

E7 -- "sent" was a guess.

  Both the sentIntent and deliveryIntent arguments were null, so nothing
  could report back. send_sms returning Ok meant "the JNI call
  returned", not that the radio accepted the message and certainly not
  that it arrived -- and the UI rendered that as a tick. A send rejected
  for no service, no SIM or a throttled radio was indistinguishable from
  a delivered one.

  Adds send_sms_tracked, which attaches real PendingIntents and returns
  a correlating token, plus SmsSentReceiver to collect the platform
  result and SendOutcome/SendReport to express it: Sent (radio accepted)
  is now a different value from Delivered (handset acknowledged), and
  failures carry the RESULT_ERROR_* code.

  Three details worth recording:
    - multipart takes ArrayList<PendingIntent>, one entry per part, so
      the intent is repeated part_count times. Passing null here, as
      before, meant no status for exactly the messages most likely to
      fail: the long ones.
    - the request code is derived from (token, kind), or the two intents
      collide and the delivery report overwrites the send report.
    - the broadcast is package-scoped and the receiver registered
      NOT_EXPORTED, so another app cannot forge a delivery report.

  If the receiver class is unavailable the send still goes out with null
  intents: losing the status report is much better than losing the
  message.

Also fixes A5 on the scheduled path. SmsAlarmReceiver still called
sendTextMessage directly, so a scheduled message over 160 GSM-7
characters -- or 70 with any emoji -- was silently truncated by the
carrier. It now divides and sends multipart, as the Rust send path has
since Phase A.

CI: two gates, both negative-tested by reverting the fix and confirming
they fail. One asserts schedule_sms never writes request.recipient or
request.body directly; the other asserts the send path still passes
sent/delivery intents in both the single-part and multipart calls.

THREAT_MODEL.md T-I4 and the delivery-confirmation row move from open to
fixed, with the residual risk stated: callers may still use the
untracked send_sms, which remains honest about meaning only "handed to
the platform".

Tests: robius-sms 25 -> 28.
Verified: 13/13 CI checks, clippy -D warnings clean on host and
aarch64-linux-android, both new Java classes javac-compile and dex.

NOT verified on a device. The keystore round-trip, the broadcast
delivery and the token correlation all need an emulator or handset;
there is still no CI runner on this repo.
2026-08-02 07:58:44 +00:00

6.5 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 Recipient and body for pending schedules were stored in MODE_PRIVATE SharedPreferences in plaintext — UID-scoped, but readable on disk and swept into cloud backup. Now AES-256-GCM enveloped via SmsScheduleCrypto, with a non-exportable key generated in the platform AndroidKeyStore. Fails closed: if the keystore is unavailable the schedule is refused rather than written in the clear. androidx.security was not usable — this crate compiles its Java with bare javac against android.jar, so the platform keystore was the correct primitive. ✅ 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 Low Was: both PendingIntents were null, so nothing could report back and Ok was indistinguishable from delivered. send_sms_tracked now attaches real sent/delivery PendingIntents and returns a correlating token; SmsSentReceiver collects the platform result. Residual risk is only that callers may still use the untracked send_sms, which remains honest about meaning "handed to the platform". (Phase E7)

6. References