nigig-org/REVIEWS/IMPLEMENTATION_AUDIT.md
andodeki 23fce675de
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
Payment domain, storage, platform and UI / isolated-payment-tests (push) Has been cancelled
Payment domain, storage, platform and UI / payment-ui-tests (push) Has been cancelled
feat(pay): USSD automation on by default; containment moves to packaging
Option A, as requested. Plus an audit of the whole review against the code.

## The default flips

  nigig-pay-ui   default = []            (leaf stays off; see below)
  nigig-pay      default = ["demo"]
  nigig-mpesa    default = ["demo"]
  pageflipnav    default = ["native", "demo"]

`cargo run -p pageflipnav` now drives *334#, shows the PIN field and
dispatches. That is the app's primary function and it works out of the box.

A release build opts out:

  cargo build -p pageflipnav --no-default-features --features native

This was not one line. A first attempt flipped the four `default =` lines
and the opt-out still leaked: the app crates depended on nigig-pay-ui with
its own defaults, so `--no-default-features` on pageflipnav was silently
re-enabled one level down. Verified with a compile probe rather than
cargo tree, which truncates. The inner deps now carry
`default-features = false`, and the probe confirms both directions:
default -> demo ON, --no-default-features -> demo OFF.

ADR 0007's Play-policy note is untouched. The containment requirement of
review item 0.1 is not dropped — the flag exists, CI exercises both
directions, and a shipped build still cannot dispatch. What changed is
which way it points by default, so development and device testing are not
fighting it.

CI guards inverted to match: they now assert automation is on by default
*and* that the packaging opt-out still works. check-no-pin-capture.sh now
probes the packaging build, since the default legitimately captures a PIN.

## REVIEWS/IMPLEMENTATION_AUDIT.md

Every phase checked against the code, not against the tranche notes. Where
they disagreed the code won. Summary: phases 0-5 and 7 done bar 5.2 and
Keystore provisioning; phase 6 substantially done with the thread_local
session ownership outstanding; phase 8 partly.

## B7 found live while auditing

Month navigation had never been examined. Both copies of the transactions
widget still stepped months with Duration::days(31) and years with
Duration::days(366). Reproduced before touching it:

  2025-12-28 -1 month => 2025-11-27   (drifts a day)
  2026-03-30 -1 month => 2026-02-27   (drifts; repeated steps skip a month)
  2027-06-15 +1 year  => 2028-06-15   (366d wrong on a non-leap year)

Now uses checked_add_months/checked_sub_months, which clamp to the end of
the target month, and checked_add_signed on the day path. An
unrepresentable date leaves the view where it was.

Neither claimed done nor flagged open — simply never looked at. That is the
argument for auditing code rather than notes.

## Validation

  domain 137 / storage 41 / platform 64 / mpesa 29 / pay-ui 61   pass
  cargo check: pay-ui, pay, mpesa, core (default and opt-out)    pass
  demo-on-by-default probe, both directions                      pass
  no-PIN-capture guard against the packaging build               pass

Unrelated and still blocking a full APK: nigig-map fails to compile on
clean HEAD (12 errors, no field center_lat on ViewportState).
2026-08-02 08:06:43 +00:00

216 lines
9.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Nigig Pay — implementation audit against the consolidated review
- **Date:** 2026-07-29
- **Reviewed against:** `REVIEWS/NIGIG_PAY_CONSOLIDATED_REVIEW.md`
- **Method:** every claim below was checked against the code, not against the
tranche notes in `PAY_CAD_IMPLEMENTATION_STATUS.md`. Where the two disagree,
the code wins and the disagreement is recorded.
## Summary
| Phase | Scope | State |
|---|---|---|
| 0 | Immediate containment | **Done**, with 0.1 deliberately re-pointed (below) |
| 1 | Build, ownership, quality gates | **Done** for the owned crates |
| 2 | Domain layer | **Done** |
| 3 | Secure repository | **Done** except Android Keystore provisioning |
| 4 | No blocking I/O in `draw_walk` | **Done and verified** |
| 5 | Harden platform gateways | **Done** except 5.2, which is not an engineering task |
| 6 | Truthful UX | **Substantially done**; session ownership outstanding |
| 7 | Test strategy | **Done** except an adversarial corpus for the UI layer |
| 8 | Verification and release discipline | **Partly**; simulator and property tests exist, pilot discipline does not |
**Test counts:** domain 137, platform 64, storage 41, pay UI 61, M-Pesa
observation 29. All pass under the isolated runners.
---
## Phase 0 — containment
| Item | State | Evidence |
|---|---|---|
| 0.1 dispatch behind a compile-time flag | **Implemented, default re-pointed** | `demo` exists at every level and CI exercises both directions. It is now **on** by default because USSD automation is the product's primary function; a release build uses `--no-default-features`. Containment moved to packaging, not removed. |
| 0.2 no fail-open biometric | **Done** | `AuthorizationAttempt`: only an explicit success grants dispatch. A displayed prompt does not. |
| 0.3 remove PIN capture | **Done for packaging builds** | `form_pin` is `#[cfg(feature = "demo")]`; `tools/check-no-pin-capture.sh` proves by compile probe that it does not exist under `--no-default-features`. A default build captures a PIN because it dispatches. |
| 0.4 SMS/USSD output is evidence, not verification | **Done** | `ObservedEvidence`; no API returns a settled state from a parse. |
| 0.5 no fake desktop success | **Done** | `MockGateway` is `cfg`-gated and `compile_error!`s in release without an explicit opt-in. |
| 0.6 stop persisting raw SMS | **Done** | Only a digest is retained; `store.rs` omits `raw_message` from disk. |
| 0.7 pinned toolchain / recovery branch | **Done** | `rust-toolchain.toml`, `--locked` builds. |
| 0.8 threat model and risk register | **Done** | `THREAT_MODEL.md`, `PAYMENT_RISK_REGISTER.md`. |
### On 0.1, explicitly
The review asked for a compile-time flag so that a *production* build cannot
dispatch. That was implemented as default-off, which had two consequences the
review did not intend:
1. ordinary development and device testing could not use the app's main
feature without knowing an undocumented flag;
2. the flag was **unreachable from `pageflipnav`**, the crate that builds the
APK, until commit `9f0e133`. No APK could enable automation by any means.
The flag now defaults on and a packaging build opts out. The safety property
the review wanted — *a shipped build cannot dispatch* — is still available and
CI-enforced; it is now a release decision rather than a development obstacle.
---
## Phase 1 — build, ownership, quality gates
Done for `nigig-pay-domain`, `nigig-pay-storage`, `nigig-pay-platform`:
checked-in lockfiles, `--locked` builds, `cargo fmt --check`,
`clippy -D warnings`, `cargo-deny`, and a Makepad-import boundary check.
Ownership is recorded in ADR 0002 and machine-checked.
**Not done:** the same gates do not apply to `nigig-pay-ui`, which is linted
only by `cargo test --lib`. Residual counts there: **10 `unwrap()`**, **6
`let _ =`**, 0 `dbg!`/`println!`.
---
## Phase 2 — domain layer
Complete. `Money` (exact minor units), the validated state machine,
`PaymentCoordinator`, correlated operations, separated outcomes, no automatic
retry on ambiguity, and one `compute_adjusted_amount`.
---
## Phase 3 — secure repository
SQLite with migrations, single writer, `Result` on every operation,
retention/erasure policy, integrity checks, and online backup/restore.
SQLCipher works and is tested under a feature.
**Not done:** 3.1 key provisioning. The Android Keystore `DatabaseKeyProvider`
does not exist, so encryption at rest is available but unkeyed by a real
hierarchy.
---
## Phase 4 — blocking I/O
Done and **verified by compilation**, which earlier tranches could not do.
`reload()` is out of `draw_walk`, the SMS scan is on a real timer, summaries
are computed on the event path, and the storage worker owns the only writer.
---
## Phase 5 — platform gateways
`nigig-pay-platform` owns the seam: single-flight correlation, fail-closed
classification driven by dispatch progress, typed correlated events, a strict
SMS reader, a mock that cannot ship, and an honest web refusal.
`#![forbid(unsafe_code)]`; `UssdGateway` is `!Send`/`!Sync`.
**Not done — 5.2, the AccessibilityService legal review.** This is a business
decision about Google Play policy exposure, recorded as blocking in ADR 0007.
No amount of engineering closes it.
---
## Phase 6 — truthful UX
`PaymentPresentation` makes the exit criterion a type: no `Success` variant is
reachable from untrusted evidence, and no `Failed` variant from a missing SMS.
`ConfirmationSummary` makes every required term mandatory and binds consent to
a digest of the exact terms shown. `PaymentBatch` makes `3/2` progress
unrepresentable.
**Not done:** the `thread_local!` `PayFlowHandler` still owns the USSD session
lifecycle, the shadow pending-store writes, and the bulk queue. Moving those
changes who cancels on teardown and who observes an out-of-order callback,
which needs ADR 0007's device matrix.
**Not done:** 6.6 bulk controls — per-item review, limits, pause/resume and
audit export.
---
## Phase 7 — tests
7.17.2, 7.4, 7.67.8 done. 7.3 done: UI tests run in CI. 7.5 done for the
platform SMS reader — spoofing, forged codes, replay, unicode.
**Not done:** an adversarial corpus for the UI layer specifically, and no
consent-safe real-world SMS corpus exists in the repository.
---
## Phase 8 — verification and operations
8.1 deterministic simulator and 8.2 property tests exist in the domain crate.
**Not done:** 8.3 fuzzing, 8.4 SBOM and formal security review, 8.5 operational
telemetry, 8.6 pilot discipline. These are programme activities rather than
code changes.
---
## Priority defects — current state
| Priority | Defect | State |
|---|---|---|
| P0 | Biometric error dispatches anyway (S2) | Fixed — `AuthorizationAttempt` |
| P0 | PIN in plaintext prefs (S1) | Fixed — Java scrub; field absent in packaging builds |
| P0 | SMS marks payment verified (B2) | Fixed — evidence only |
| P0 | Auto-retry of ambiguous USSD (B3) | Fixed — dispatch budget + spent grants |
| P0 | `f64` money ledger (B6) | **Partly** — arithmetic and boundary validated; stored representation still `f64` |
| P0 | Plaintext records, swallowed writes (S3/B16) | Fixed — SQLite, explicit errors |
| P0 | Duplicate parser/store/flow copies (A5) | Fixed — ADR 0002, CI-enforced |
| P0 | Classification written, never read (B1) | Fixed |
| P1 | `thread_local` coordinator (A2) | **Open** — the remaining Phase 6 item |
| P1 | 5-minute no-SMS → Failed (U4) | Fixed — `PendingConfirmation` |
| P1 | Hard-coded 2024 fee table (U9) | **Open** — still a static table |
| P1 | Fee lookup silently free (B5) | Fixed at every call site found |
| P1 | Bulk USSD flow (U7) | Accounting fixed; controls not built |
| P1 | Disk I/O in `draw_walk` (P1) | Fixed and verified |
| P1 | Broken month navigation (B7) | **Fixed in this audit** — was live; see below |
| P1 | Lossy PSV escaping (B4) | Fixed |
| P2 | Custom JSON parser (B10) | **Unverified** |
| P2 | Header forgery, cert pinning (S7/S8) | **Open** — exchange APIs untouched |
| P3 | Matrix session encryption (S11) | **Open** |
---
## B7, found live during this audit
The tranche notes had never examined month navigation. It was still broken in
**both** copies of the transactions widget:
```rust
MpesaFilterMode::Month => current + Duration::days(31 * delta),
MpesaFilterMode::Year => current + Duration::days(366 * delta),
```
Reproduced before changing anything:
```
2026-03-31 -1 month => 2026-02-28 (correct by luck)
2025-12-28 -1 month => 2025-11-27 (drifted a day)
2026-03-30 -1 month => 2026-02-27 (drifts; repeated steps skip a month)
2027-06-15 +1 year => 2028-06-15 (366d is wrong on a non-leap year)
```
Now uses `checked_add_months`/`checked_sub_months`, which clamp to the end of
the target month, and `checked_add_signed` on the day path so an absurd delta
reports overflow instead of panicking. An unrepresentable date leaves the view
where it was rather than jumping somewhere arbitrary.
This is a good illustration of why this audit reads code rather than notes:
the item was neither claimed done nor flagged open, it was simply never
looked at.
## What I would prioritise next
1. **`nigig-map` does not compile** (12 errors, `no field center_lat`). This
blocks a full `pageflipnav` build today and is unrelated to Pay. Nothing
else matters until an APK can be produced.
2. **5.2** — the Play-policy decision. It determines whether the USSD rail has
a future at all, and several open items (6.6 bulk, the coordinator
migration) are only worth doing if it does.
3. **The `thread_local` migration**, once a device is available to exercise
the matrix.
4. **U9 versioned fee policy** — the 2024 table will go stale silently.
5. **Quality gates for `nigig-pay-ui`** — 10 `unwrap()` in a payment UI is the
same defect class the domain crates already ratchet against.