nigig-org/crates/nigig-pay-storage
andodeki d567978119
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
Payment domain, storage, platform and UI / isolated-payment-tests (push) Failing after 2m19s
Payment domain, storage, platform and UI / payment-ui-tests (push) Has been cancelled
feat(pay): key rotation for the encrypted ledger (R2.2)
Examined R2.2 the way R2.1 turned out to need, rather than assuming the
whole item was device-blocked.

Most of 3.1 was already present: DatabaseKeyProvider, open_encrypted,
wrong-key rejection distinct from corruption, keystore-unavailable failing
closed, and a test asserting no PII appears in the raw file.

## The real gap was rotation, and it is pure logic

A StaticTestKeyProvider key lives forever. An Android Keystore key does not:
KeyPermanentlyInvalidatedException is thrown after fingerprint re-enrolment,
adding or removing a screen lock, or a device restore. That is ordinary, not
exceptional. With no rotation path the only responses were "lose the ledger"
or "keep using a key that no longer exists" — and the second is not
available, because the key is gone.

Deleting the ledger is not an option either. It destroys the record of money
that may have left the account, which is the same reasoning that makes
retention.rs refuse to sweep unreconciled rows.

rotate_key uses PRAGMA rekey, which re-encrypts every page inside SQLCipher's
own transaction, then proves the new key reads the data before returning — a
rekey that reported success but left the file unreadable would otherwise only
surface on the next launch, by which time the old key may be gone.

5 tests: records survive rotation, the superseded key stops working, an empty
key is refused without damaging the file, rotation is repeatable, and the
schema version is untouched. Verified by neutering rotate_key: 3 fail.

Storage tests 41 -> 46.

## Ordering, documented at the trait

The caller persists the new key only after rotate_key returns Ok. The reverse
order leaves a stored key that does not open the file. This order leaves, at
worst, a re-keyed file whose new key was not saved — recoverable by rotating
again from the old key still in the keystore.

## What remains

The JNI call itself: KeyGenParameterSpec with user authentication required,
the AndroidKeyStore provider, and catching KeyPermanentlyInvalidatedException.
The full contract is written on DatabaseKeyProvider so it is not rediscovered
from scratch. Tracked as R2.2b. Everything except the platform call is
already exercised by the sqlcipher suite.

## Validation

  storage 46 (was 41) with --features sqlcipher                  pass
  domain 148 / platform 64 / mpesa 29 / pay-ui 78                pass
  clippy -p nigig-pay-ui --no-deps -D warnings                   0 errors
  builds: pay, mpesa, core                                       pass
  rotation injection: 3 tests fail when rotate_key is neutered   pass
2026-08-02 09:37:00 +00:00
..
src feat(pay): key rotation for the encrypted ledger (R2.2) 2026-08-02 09:37:00 +00:00
Cargo.lock feat(pay): complete the partially-done review phases 2026-07-27 15:07:28 +00:00
Cargo.toml feat(pay): complete Phase 3, start Phase 4 2026-07-27 15:34:38 +00:00