Skip to content

Harden passkey delegate retention, sign-out cleanup, input validation, and app re-lock - #159

Open
iam-mercy wants to merge 1 commit into
ethos-protocol:mainfrom
iam-mercy:main
Open

Harden passkey delegate retention, sign-out cleanup, input validation, and app re-lock#159
iam-mercy wants to merge 1 commit into
ethos-protocol:mainfrom
iam-mercy:main

Conversation

@iam-mercy

Copy link
Copy Markdown

Summary

Implements four related iOS security/usability issues:

What changed

PasskeyService.swift (#9)

  • Replaced the objc_setAssociatedObject hack with instance-level, controller-keyed storage (activeDelegates: [ObjectIdentifier: PasskeyDelegate]), since ASAuthorizationController.delegate is a weak reference and something has to retain the delegate until the request completes.
  • Keyed by controller identity rather than a single property so two requests in flight at once (e.g. a stray double-tap firing both register() and authenticate()) each retain their own delegate instead of one clobbering the other's reference.

RegisterView / UsernameValidation (#11)

  • Added UsernameValidation (in Models.swift): trims whitespace, enforces a 3–30 character length and an alphanumeric + _/- character set, mirroring typical WebAuthn userID/display-name constraints.
  • RegisterView now shows a friendly inline validation message instead of only gating on username.isEmpty, and passes the trimmed username through to registration.

AuthStore.signOut() (#10)

  • Now also calls: KeychainService.deleteCredentialID(), NotificationService.removeAllPendingNotifications(), BackgroundRefreshService.cancelScheduledRefresh(), OfflineCache.clearAll(), and ICloudSyncService.clearLocalAssociations() (device-local only — the iCloud KV store itself is left alone so other signed-in devices aren't affected).
  • Added the small helper methods above to each service, following each file's existing patterns.

Re-lock on background/foreground (#12)

  • AuthStore gained isLocked and handleScenePhaseChange(_:now:), which starts a timer when the app backgrounds and re-locks (independent of the passkey session) if the app was away for at least the configured timeout.
  • RootView now shows a LockScreenView (Face ID re-prompt) once isLocked is set, and a PrivacyOverlayView any time scenePhase != .active — the latter covers content immediately on every backgrounding (regardless of the timeout), so the app-switcher snapshot never captures balances.
  • Added ReLockTimeoutOption (Immediately / 30s / 1m / 5m / 15m / Never, persisted in UserDefaults, default 1 minute) and a picker in SettingsView to configure or disable it.

Verification performed

This sandbox has no Swift/Xcode toolchain and no network access to fetch one, so I could not run swift build/xcodebuild locally. I instead:

  • Read every touched file in full after editing to check syntax, types, and access levels by hand.
  • Cross-checked new/changed call sites against existing signatures (NotificationService, BackgroundRefreshService, OfflineCache, ICloudSyncService, KeychainService) and existing UI tests (AuthUITests) to confirm behavior isn't changed for already-covered paths (e.g. empty username still disables Register).
  • Followed the repo's existing two-tier test-target split (bare SPM bundle vs. Tests/HostedTests) exactly, placing each new test alongside the existing tests that exercise the same real system API (Keychain / UNUserNotificationCenter) so CI skip conventions stay consistent.

Please still run the two CI test jobs (ios-ci.yml) on this PR — that's the only place a full build/test can actually happen for this project.

Test plan

New/changed automated tests (all follow existing naming/skip conventions in the files they're added to):

  • PasskeyDelegateRetentionTests (bare bundle) — two concurrent performRequest-style calls each retain and release their own delegate without affecting the other's.
  • UsernameValidationTests (bare bundle) — trimming, min/max length, and character-set rejection/acceptance.
  • AuthStoreReLockTests / ReLockTimeoutOptionTests (bare bundle) — lock fires once the configured timeout elapses, doesn't fire early, .never never fires, unauthenticated state is never locked, and the persisted-option default/round-trip.
  • SignOutClearsLocalStateTests (hosted target) — credential ID, iCloud local association, and pending notifications are all gone after signOut(), and isLocked/isAuthenticated reset; plus an offline-cache assertion that previously cached vault data is unreadable after sign-out.
  • Small additions to existing KeychainServiceTests, OfflineCacheTests, ICloudSyncServiceTests, and BackgroundRefreshServiceTests for the new deleteCredentialID() / clearAll() / clearLocalAssociations() / cancelScheduledRefresh() methods.

Manual QA (once buildable — see "Known pre-existing issues" below):

  • Register with a too-short/too-long/invalid-character username and confirm the inline message, then with a valid one and confirm registration proceeds.
  • Sign out, then confirm no vault list flashes on next launch before authenticating and no stale check-in notifications remain.
  • Background the app past the configured re-lock timeout, foreground it, confirm the Face ID prompt appears before the vault list is visible; confirm the app-switcher preview shows the privacy screen, not balances.
  • Change the re-lock timeout in Settings (including "Never") and confirm the new behavior takes effect.

Known pre-existing issues (out of scope for this batch)

While reviewing the affected files I found that main currently does not compile, from what looks like an incompletely-resolved merge conflict (96b0e1f/09d9e92) predating this branch:

  • ViewModels/Stores.swift: VaultStore declares scheduleReminders() twice (duplicate declaration), the second of which also calls NotificationService.scheduleCheckInReminder with the wrong argument list.
  • Views/Views.swift: VaultDetailView declares load2FAStatus() twice, calls an undefined refreshTTLPeriodically(), and references several @State properties (showDeposit, showWithdraw, showManageBeneficiary, ttlRemaining) that are never declared.
  • Tests/EthosProtocolTests.swift references VaultStore.applyUpdate/deposit/withdraw/updateBeneficiary and VaultDetailView.ttlRefreshInterval, none of which currently exist in Sources/.

These are unrelated to issues #9#12 (passkey/auth/sign-out/re-lock), so per the batch scope I left them untouched and did not attempt to fix them here — flagging them since they'll block any build/CI run on main (and on this branch, which is based on it) until addressed separately.

Closes #9
Closes #10
Closes #11
Closes #12

…, and app re-lock

- PasskeyService: replace the objc_setAssociatedObject delegate-retention hack
  with instance-level, controller-keyed storage so concurrent performRequest
  calls (e.g. register() and authenticate() in flight together) each retain
  their own delegate without clobbering the other's.
- RegisterView: validate username length/character-set and trim whitespace
  client-side before registration, with an inline validation message instead
  of a generic backend error.
- AuthStore.signOut(): clear the stored credential ID, pending local
  notifications, the scheduled background refresh task, the OfflineCache
  directory, and local iCloud vault associations — not just the auth token —
  so no vault data survives sign-out on a shared/resold device.
- Add a ScenePhase-driven re-lock timer with a configurable timeout
  (SettingsView) and a privacy overlay shown whenever the app isn't in the
  foreground, so balances/beneficiary details require a fresh Face ID check
  after backgrounding and never appear in the app switcher.

Closes ethos-protocol#9, ethos-protocol#10, ethos-protocol#11, ethos-protocol#12
@drips-wave

drips-wave Bot commented Jul 27, 2026

Copy link
Copy Markdown

@iam-mercy Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant