nigig-org/crates/robius-sms/README.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

99 lines
3.3 KiB
Markdown

# `robius-sms`
A Rust library to access and send SMS messages across multiple platforms.
Currently, advanced SMS functionality is implemented on Android only.
## Android capabilities
This crate currently supports:
- sending a single SMS
- sending bulk SMS to multiple recipients
- listing SMS messages from the device SMS database
- checking/requesting SMS permissions
- scheduling recurring SMS sends on Android
## Android manifest permissions
Add these to your `AndroidManifest.xml`:
```xml
<manifest ... >
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Only needed if you call schedule_sms(). Android 12+ refuses exact
alarms without it, and it is special-access: the user must enable
"Alarms & reminders" in Settings, a runtime prompt cannot grant
it. Without it, scheduled sends are batched by Doze. -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application ...>
<receiver
android:name="robius.sms.SmsAlarmReceiver"
android:exported="false" />
<receiver
android:name="robius.sms.SmsBootReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
```
# Standard iOS (stubs)
cargo build --target aarch64-apple-ios
# TrollStore iOS (silent SMS + message reading)
cargo build --target aarch64-apple-ios --features trollstore
## Delivery status
`send_sms` returning `Ok` means the send was handed to the platform, not
that it was delivered. Do not show that to a user as "delivered".
Where the UI makes a claim about a message, use `send_sms_tracked`:
```rust
robius_sms::set_send_result_waker(wake_my_event_loop);
robius_sms::register_send_receiver()?;
let token = robius_sms::send_sms_tracked(&request)?;
// later, on the UI thread:
for report in robius_sms::take_send_reports() {
if report.token == token {
match report.outcome {
robius_sms::SendOutcome::Sent => { /* radio accepted it */ }
robius_sms::SendOutcome::Delivered => { /* handset ack'd */ }
robius_sms::SendOutcome::SendFailed { code }
| robius_sms::SendOutcome::DeliveryFailed { code } => { /* show it */ }
}
}
}
```
## Scheduled messages are encrypted at rest
Pending schedules must outlive the process so `SmsAlarmReceiver` can send
them when the alarm fires, so recipient and body go to SharedPreferences.
They are AES-256-GCM enveloped with a non-exportable key held in the
platform `AndroidKeyStore`. If the keystore is unavailable, `schedule_sms`
**fails** rather than storing plaintext.
## Cost
You are billed per *segment*. Call `segment_count(body)` before sending:
any character outside the GSM-7 alphabet -- one emoji is enough --
forces the whole message to UCS-2, dropping the per-part limit from 160
characters to 70. Bodies longer than one part are sent with
`sendMultipartTextMessage`.
Note that the accented Latin characters common in Swahili, French and
German (e, a, o, n, u with diacritics) ARE in GSM-7 and do not trigger
this.