Skip to content

fix(passkeys): make Git sync durable and recover remote credentials on miss - #104

Merged
pando85 merged 34 commits into
mainfrom
fix/98-passkey-auto-sync-index-retrieve
Jul 31, 2026
Merged

fix(passkeys): make Git sync durable and recover remote credentials on miss#104
pando85 merged 34 commits into
mainfrom
fix/98-passkey-auto-sync-index-retrieve

Conversation

@forkline-bot

@forkline-bot forkline-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

Summary

Fixes #98 by moving passkey auto-sync out of AppPasskeyProviderActivity.lifecycleScope and into durable, one-shot WorkManager work, and also makes stale devices recover passkeys that already exist in remote Git during Credential Manager discovery.

The normal path remains local and fast. Network I/O is only attempted when the complete local credential lookup produces no matching candidate.

Outbound sync

  • Successful CREATE/GET ceremonies enqueue a one-shot PasskeySyncWorker instead of launching Git work in the provider Activity lifecycle.
  • The WebAuthn response is returned immediately.
  • Work is network-constrained, event-driven, and uses APPEND_OR_REPLACE, so new ceremonies do not cancel an in-flight Git operation.
  • WorkManager can reopen PasswordRepository after process death.
  • PasskeyGitSyncEngine performs headless add/commit/pull/push using stored non-interactive credentials.
  • Pull success uses JGit PullResult.isSuccessful, including rebase outcomes.
  • Push results are validated and transient failures use bounded retry/backoff.

Remote credential miss recovery

When Credential Manager asks APS for a passkey:

  1. APS queries the local index first.
  2. If any candidate matches, it returns immediately with no network access.
  3. If the entire request has no local candidate, APS performs at most one serialized pull-only refresh for that lookup.
  4. The pull diffs the old/new Git HEADs and reports the exact changed paths, including renames.
  5. For each changed fido2/<rp>/<credential-id>.gpg path, IndexedPasskeyStorage addresses that exact file directly: it resolves its source version, decrypts only that file's metadata, validates credential ID and RP/path binding, and upserts it. A deleted path removes the corresponding indexed entry.
  6. No passkey directory/tree scan is performed on the normal incremental path; work is proportional to the number of changed passkey files.
  7. The tracked repository generation is advanced and APS retries the original lookup once, allowing a newly downloaded remote passkey to appear in the same Credential Manager flow.

A full index invalidation/rebuild is retained only as a correctness fallback for merge conflicts, .gpg-id changes, unknown/unavailable source versions, duplicate credential IDs, malformed or inconsistent passkey paths, or other incremental-reconciliation failures.

UX and concurrency

  • Local-hit login behavior is unchanged and remains immediate.
  • A stale-device miss may incur one Git round trip; the interactive refresh uses a shorter transport timeout than durable background sync.
  • Concurrent misses are coalesced by refresh generation: if another request completed the refresh after a request's local lookup, that request reuses the refreshed index instead of pulling again. Later independent misses are not suppressed by a time-based cooldown.
  • CancellationSignal cancels the provider request coroutine and suppresses callbacks after Android no longer needs the result.
  • All foreground and background Git mutations share GitOperationCoordinator, preventing concurrent JGit access to the same worktree.
  • No periodic polling is introduced.
  • Interactive-only Git authentication still falls back cleanly to manual sync.

Tests

  • Git operation serialization and lock release after failures.
  • Changed-path classification ensures unrelated Git updates do not invalidate the passkey index and passkey/.gpg-id changes do.
  • Incremental Git reconciliation verifies a newly downloaded passkey is added to the index with one exact metadata load and no second full scan, and that a remotely deleted passkey is removed without rescanning.

Resolves #98

forkline-dev[bot] added 3 commits July 30, 2026 23:46
…ity destruction

Replace lifecycleScope-based git sync in AppPasskeyProviderActivity with
a PasskeySyncWorker (CoroutineWorker) that runs independently of the
activity lifecycle. The previous approach launched sync in lifecycleScope
and immediately called finish(), which cancelled the coroutine before
the git operation could complete.

The worker performs git add/commit/pull/push directly using JGit with
non-interactive SSH/HTTPS authentication. It gracefully fails when
authentication requires user interaction (biometric prompts, host key
verification dialogs) and logs the failure.

Fixes #98
@forkline-bot

forkline-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown
Author

Maintainability Review

Reviewed the branch for maintainability issues. Applied two high-value fixes:

Applied now

1. SSH connection leak in AppPasskeyRemoteRefresher (bug)

  • Location: AppPasskeyRemoteRefresher.kt:65-91
  • Problem: NonInteractiveSshSession (which holds the SSHClient connection) was created during the pull but never closed. git.close() only closes the JGit repository, not the underlying SSH transport. Each SSH-based pull would leak a connection.
  • Fix: Track the session via callback and close it in the finally block before git.close().
  • Risk/scope: Low — adds cleanup only, no behavioral change.

2. onClearCredentialStateRequest uses fragile cast instead of abstract property (bug)

  • Location: PasskeyCredentialProviderService.kt:211-216
  • Problem: Casts passkeyStorage as? PasskeyRepositoryState instead of using the already-available passkeyRepositoryState abstract property. This depends on an implementation detail (that the storage instance also implements PasskeyRepositoryState) and would silently skip invalidation if the storage wrapper changes.
  • Fix: Use passkeyRepositoryState directly.
  • Risk/scope: Trivial — same runtime behavior with current DI setup, but correctly decoupled.

Recommend, but defer

3. Duplicated SSH session/process classes (refactor)

  • Location: NonInteractiveSshSession/NonInteractiveSshProcess in AppPasskeyRemoteRefresher.kt vs SshjSession/SshjProcess in SshjSessionFactory.kt
  • Problem: NonInteractiveSshProcess is identical to SshjProcess. The session classes share most structure. However, the existing SshjSession is tightly coupled to interactive auth (requires FragmentActivity, SshAuthMethod). Extracting a shared base would require refactoring the interactive flow.
  • Decision: Defer — real duplication but extracting a shared abstraction is out of scope for this PR and carries regression risk in the interactive SSH path.

Do not pursue

  • Redundant null check on remoteRefresher at line 110 of PasskeyCredentialProviderService — idiomatic Kotlin smart-cast pattern for abstract properties, no functional impact.
  • setTimeout called inside transport config callback — follows JGit convention, no functional issue.

@forkline-bot

forkline-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown
Author

Updated approach

Replaced the lifecycle-scoped maybeSyncToGit() with the existing reactive pull mechanism.

Changes:

  • Removed maybeSyncToGit() from AppPasskeyProviderActivity — it launched GitOp.SYNC in ProcessLifecycleOwner scope then immediately called finish(), causing JobCancellationException
  • The existing PasskeyCredentialProviderService.onBeginGetCredentialRequest() already implements reactive pull: when no local credentials are found for an RP, it calls PasskeyRemoteRefresher.refresh() (git pull), invalidates the index, and re-lists metadata
  • Wired PASSKEY_AUTO_GIT_SYNC preference to AppPasskeyRemoteRefresher so users can disable reactive pull from settings

UX impact:

  • WebAuthn response returns immediately (no network I/O delay)
  • Sync runs reactively only when needed (credential not found for RP during discovery)
  • No WorkManager, no periodic background checks
  • Users with interactive-only auth (biometric SSH keys) will see graceful failure; manual sync from main app remains available

Resolves: #98


This PR was generated by Forkline — AI-powered code contributions.
The agent analyzed the issue and implemented this fix autonomously.

@forkline-bot

forkline-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown
Author

Fix pushed:

Fixed the CI failure. The issue was a flaky test in AtomicCredentialWriterTest.overwrite existing file atomically that used Thread.sleep(50) and checked lastModified() timestamps, which is unreliable on CI filesystems with coarse timestamp resolution.

Changes made:

  • Removed the timing-dependent assertion that checked target.lastModified() >= originalModified
  • Replaced with deterministic file size verification
  • Removed the Thread.sleep(50) call

The fix has been pushed to fix/98-passkey-auto-sync-index-retrieve and all tests pass locally.

@pando85
pando85 force-pushed the fix/98-passkey-auto-sync-index-retrieve branch from 158fbb1 to 4d3213d Compare July 31, 2026 13:23
@forkline-bot

forkline-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown
Author

⏸️ CI Auto-Fix paused

A user commit was detected after an automated fix. CI auto-fix is now paused
to prevent conflicts with your changes.

To resume CI auto-fix, use the /reset or /reset-ci-auto-fix command.

@pando85 pando85 changed the title fix: reactive passkey sync on index retrieve failure fix(passkeys): make auto-sync survive provider activity teardown Jul 31, 2026
@pando85 pando85 changed the title fix(passkeys): make auto-sync survive provider activity teardown fix(passkeys): make Git sync durable and recover remote credentials on miss Jul 31, 2026
@pando85
pando85 merged commit 22892f0 into main Jul 31, 2026
5 checks passed
@pando85
pando85 deleted the fix/98-passkey-auto-sync-index-retrieve branch July 31, 2026 15:04
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.

Passkey auto-sync is cancelled when provider activity finishes

1 participant