Skip to content

Store secrets locally so the app works without the server - #5

Draft
66Ton99 wants to merge 1 commit into
mainfrom
codex/offline-local-replica
Draft

66Ton99 wants to merge 1 commit into
mainfrom
codex/offline-local-replica

Conversation

@66Ton99

@66Ton99 66Ton99 commented Aug 12, 2026 •

Copy link
Copy Markdown

Implements M0 + M1 of PLAN.md — the autonomy half of the offline replica work. The server side (passly_api) is untouched; that is M2.

Why

Requirement R0: a device that has synchronised once must stay fully usable for an unlimited time, even if the server is switched off for good. Two things stood in the way:

  1. Secrets were never stored on the device — showing a password always went to GET /secrets/resource/{id}.json.
  2. An unreachable server blocked unlocking, even though the passphrase is verified locally.

M1 — autonomy

Local secret store

  • New Secret entity holding the OpenPGP ciphertext exactly as the server sent it, keyed by resource, cascade-deleted with it. Database v24; the migration is purely additive and needs no network, so an app update applied offline cannot strand an account.
  • SecretInteractor is now local-first: it decrypts the stored ciphertext and only asks the network for a secret this device has never stored, caching whatever comes back. Every consumer benefits — password reveal, TOTP, autofill, passkey, and re-encryption for sharing.
  • The paginated resources index is requested with contain[secret], so secrets land page by page during the first refresh. Autonomy is reached before first sign-in finishes, and an interrupted refresh still leaves everything it downloaded fully usable offline.
  • Create and update store the ciphertext they just produced, so an offline read after an edit returns the new secret, never a stale one. If the account somehow was not among the recipients, the stale row is dropped rather than served.

Offline unlock (R6)

  • ServerNotReachable no longer blocks sign-in. The passphrase has already been verified locally at that point, so an unreachable server says nothing about whether someone may open their own data. If the account carries a local replica (HasLocalReplicaUseCase), unlocking continues and the app runs on local data until synchronisation becomes possible again.
  • An account that has never synchronised still needs the server and still gets the existing "server not reachable" screen.

Nothing added here expires local data, and no server response deletes it.

M0 — groundwork for the delta protocol

  • docs/sync-protocol.md — normative, cross-platform spec for sync/1: the change journal, seq cursor, watermark, the four endpoints, apply rules, the error table (no error makes local data unavailable), offline write queue and conflict rules, and per-platform storage bindings.
  • New core/sync module: SyncTrigger, SyncStatus / SyncStatusRepository, SyncResult, NetworkStateProvider, and SyncBackoff (the documented 30s→6h ladder that saturates and never gives up).
  • WorkManager added to the version catalog.

Verified without code changes

Documented in place so they are not "fixed" backwards later:

  • The database key lives in EncryptedSharedPreferences and is not bound to biometrics — re-enrolling a fingerprint cannot destroy the store.
  • GetFeatureFlagsUseCase already returns the last known snapshot offline, because SaveFeatureFlagsUseCase writes every key.
  • OpenPgp.timeOffsetSeconds defaults to 0, so crypto falls back to device time when the server cannot be asked.
  • The passkey hot path fixed itself: RefreshSessionUseCase is now only reachable for a passkey this device never stored.

Testing

  • ./gradlew assembleDebug unitTest — green.
  • New SecretInteractorTest (4 cases) pins the core guarantee: a cached secret is decrypted with zero network interaction; a fetched secret is stored for later; a failed store does not fail the read; an unreachable server only matters when there is no local copy.
  • ktlint/detekt reports compared before and after: no new findings beyond InvalidPackageDeclaration on the 8 new files, which is the repo-wide baseline (2018 pre-existing occurrences from net.svaroh.passly packages living in com/passbolt/mobile/android directories).

Reviewer notes

  • Audit noise: contain[secret]=1 makes ResourcesIndexController write a SecretAccesses row per resource on every full refresh. PLAN.md §4.5 introduces a dedicated sync event type for this — until M2 lands, secret-access reports and notifications will be noisy.
  • Known gap: if the server is reachable but PostSignInActionsInteractor cannot fetch feature flags, entry is still blocked by the existing featureflagserror screen. The "server is gone for good" scenario fails earlier, at ServerNotReachable, so it is unaffected — left alone deliberately.
  • Deviation from the plan: no Resource.secretModified column; Secret.modified already carries it. core/sync sources use directories matching their package rather than the legacy com/passbolt/mobile/android tree.
  • Version name bumped to 4.0.0. projectVersionCode left at 64 per .codex/AGENTS.md — say the word if this release needs it raised.
  • The instrumented migration tests (migrate23To24, cascade delete) were not run — no device was attached. The migration SQL matches Room's exported 24.json byte for byte.

🤖 Generated with Claude Code

Requirement R0 says a synchronised device must stay fully usable for an
unlimited time, even if the server is switched off for good. Two things
stood in the way: secrets were never stored on the device, and an
unreachable server blocked unlocking.

Local secret store:
- new Secret entity holding the OpenPGP ciphertext exactly as the server
  sent it, keyed by resource and cascade-deleted with it; database v24
  with a purely additive, network independent migration
- SecretInteractor now reads the local copy first and only asks the
  network for a secret this device has never stored, caching whatever it
  gets back
- the resources index is requested with contain[secret], so secrets land
  page by page during the first refresh and autonomy is reached before
  the first sign in finishes rather than at some later point
- creating or editing a resource stores the ciphertext it just produced,
  so an offline read after an edit returns the new secret, never a stale
  one

Offline unlock:
- the passphrase is verified locally, so an unreachable server says
  nothing about whether someone may open their own data; if the account
  carries a local replica, unlocking continues and the app runs on local
  data until synchronisation becomes possible again
- an account that has never synchronised still needs the server, and
  still shows the existing "server not reachable" screen

Nothing here expires local data, and no server response deletes it.

Also lands the M0 groundwork for the delta protocol: docs/sync-protocol.md
as the normative cross-platform spec, and a core/sync module with the
status model, network state and the documented retry ladder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant