Skip to content

feat: correctly handle invalid fingerprint#29

Merged
matejsmycka merged 6 commits into
tests-and-spec-fixesfrom
fprintd-no-match-uvinvalid
Apr 10, 2026
Merged

feat: correctly handle invalid fingerprint#29
matejsmycka merged 6 commits into
tests-and-spec-fixesfrom
fprintd-no-match-uvinvalid

Conversation

@matejsmycka

Copy link
Copy Markdown
Owner

Stacked on #27. When fprintd reports verify-no-match (wrong finger), the daemon now returns CTAP2_ERR_UV_INVALID (0x3F) instead of CTAP2_ERR_OPERATION_DENIED (0x27). The browser SHOULD retry on 0x3F per CTAP 2.1 §8.2 — so the user gets re-prompted automatically instead of having to click "sign in" from scratch.

What changes

Layer Before After
fprintd/fprintd.go processVerifyStatus returned a generic fmt.Errorf(...) for no-match Returns the new sentinel ErrNoMatch
main.go::fprintdVerifier Always set VerifyResult{OK: false} on failure errors.Is(err, fprintd.ErrNoMatch) → sets Reason: ReasonNoMatch
main.go::handleGetAssertion / handleMakeCredential Always wrote StatusOperationDenied on !result.OK Calls new statusForFailure(r) → maps ReasonNoMatchStatusUVInvalid, else OperationDenied
ctap2/ctap2.go Missing StatusUVInvalid constant Adds StatusUVInvalid = 0x3F
pinentryVerifier unchanged unchanged — pinentry is just yes/no, no equivalent of "wrong finger"

Spec citation

CTAP 2.1 §8.2 status code table row 0x3F:

CTAP2_ERR_UV_INVALID — built-in user verification unsuccessful. The platform SHOULD retry.

Tests

  • TestStatusCodeValuesMatchSpec + new row asserting StatusUVInvalid == 0x3F
  • TestProcessVerifyStatus/verify-no-match now checks errors.Is(err, ErrNoMatch) (sentinel is the contract)
  • TestGetAssertion_VerifierRejection split into:
    • user canceled (no Reason) → expects StatusOperationDenied
    • fingerprint no-match → expects StatusUVInvalid
  • New TestMakeCredential_VerifierRejection mirrors the same two sub-cases for the MakeCredential path

Load-bearing: making statusForFailure always return OperationDenied makes both fingerprint_no-match sub-cases fail with expected 0x3f, got 0x27.

CI matrix verified locally

go vet ./...
go test -race -count=1 -timeout 60s ./...
GOOS=linux GOARCH=386  go test -count=1 -timeout 60s ./...
GOOS=linux GOARCH=arm GOARM=5 go build -v ./...

Merge order

This PR is based on tests-and-spec-fixes (#27), not main — it depends on processVerifyStatus, the VerifyResult struct, and TestStatusCodeValuesMatchSpec all of which land in #27. Once #27 merges into main, GitHub will rebase this onto main automatically.

matejsmycka and others added 6 commits April 10, 2026 18:53
When fprintd reports verify-no-match (the user's finger doesn't match
any enrolled template), the daemon used to return CTAP2_ERR_OPERATION_DENIED
(0x27) — the same code as "user pressed Cancel". Browsers treat that as
a hard failure and surface it to the page; the user has to click "sign in"
again from scratch.

Per CTAP 2.1 §8.2, the spec value for "built-in user verification
unsuccessful" is CTAP2_ERR_UV_INVALID (0x3F), with the description:
"The platform SHOULD retry." Browsers receiving 0x3F automatically
re-prompt for verification, which is the right UX for "wrong finger,
try again".

Changes:

- ctap2/ctap2.go: add StatusUVInvalid = 0x3F. Cross-checked against
  CTAP 2.1 §8.2 status code table (FIDO Alliance spec PDF row
  0x3F = CTAP2_ERR_UV_INVALID).
- fprintd/fprintd.go: add ErrNoMatch sentinel. processVerifyStatus
  returns it (instead of fmt.Errorf) when fprintd's signal body says
  result == "verify-no-match". Other terminal failures still get a
  generic wrapped error.
- main.go:
    - VerifyResult gains a Reason field (VerifyFailureReason enum;
      ReasonUnspecified zero-value, ReasonNoMatch).
    - fprintdVerifier.VerifyUser inspects fprintd's returned error with
      errors.Is(fprintd.ErrNoMatch) and sets Reason: ReasonNoMatch
      when it matches. pinentryVerifier doesn't set Reason — pinentry
      is just a yes/no dialog with no equivalent of "wrong finger".
    - New helper statusForFailure(VerifyResult) byte maps Reason to
      the appropriate CTAP2 status code:
        ReasonNoMatch → ctap2.StatusUVInvalid (0x3F)
        anything else → ctap2.StatusOperationDenied (0x27)
    - handleGetAssertion and handleMakeCredential both call
      statusForFailure when the verifier returns !result.OK, instead
      of always returning OperationDenied.

Tests:

- TestStatusCodeValuesMatchSpec extended with the StatusUVInvalid row.
- TestProcessVerifyStatus's verify-no-match case now checks
  errors.Is(err, ErrNoMatch) instead of an error message string
  literal — the sentinel is the contract.
- TestGetAssertion_VerifierRejection split into two sub-cases:
    "user canceled (no Reason)" → expects StatusOperationDenied
    "fingerprint no-match"      → expects StatusUVInvalid
- New TestMakeCredential_VerifierRejection mirrors the same two cases
  on the MakeCredential path.

Verified the new fix is load-bearing: making statusForFailure always
return OperationDenied makes both fingerprint_no-match sub-cases fail
with "expected 0x3f, got 0x27".

CI matrix verified locally:
  go vet ./...
  go test -race -count=1 -timeout 60s ./...
  GOOS=linux GOARCH=386  go test -count=1 -timeout 60s ./...
  GOOS=linux GOARCH=arm GOARM=5 go build -v ./...

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
A single wrong-finger scan used to bubble straight up to the browser as
CTAP2_ERR_UV_INVALID. Per CTAP 2.1 §8.2 the browser SHOULD retry on 0x3F
but Chrome (and others) surface NotAllowedError to the page on the first
attempt instead — same UX as if the user had pressed Cancel.

Real FIDO biometric authenticators (YubiKey Bio, Touch ID, Windows
Hello) all retry internally up to N attempts before reporting failure
to the platform. This commit makes linux-id behave the same way.

Changes in fprintd/fprintd.go:

- New constants: maxAttempts=3, attemptGap=200ms, totalDeadline=30s.
- verifyFingerprint() now drives a retry loop bounded by a single
  context.WithTimeout. Between attempts it calls VerifyStop, sleeps the
  gap (interruptible by the deadline), and calls VerifyStart again.
- ErrNoMatch from a single attempt is non-fatal as long as the budget
  isn't exhausted; non-no-match terminal errors (verify-disconnected,
  etc) still abort immediately because retrying can't help.
- waitForVerifyResult(ctx, sigCh) extracted as a pure helper that
  drains signals until a terminal result or the context fires. This
  replaces the old time.After-in-loop pattern (which leaked timers
  and didn't bound total wall-clock time across iterations).

Tests in fprintd/fprintd_test.go:

- TestWaitForVerifyResult covers six paths with synthetic dbus.Signal
  values pushed through a buffered channel:
    success on first signal
    no-match returns ErrNoMatch
    non-terminal scan(s) then success
    non-terminal scan then no-match
    disconnected returns wrapped error (not reported as no-match)
    context deadline returns timeout error

  No D-Bus connection or sleep dependency.

verifyFingerprint() itself is still untested directly because it needs
a real D-Bus connection, but its retry-loop behaviour is now expressed
in terms of waitForVerifyResult, which IS tested.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two issues uncovered by user testing of the retry loop:

1. Stale signals between attempts. After fprintd reports verify-no-match
   on attempt N and we call VerifyStop+VerifyStart for attempt N+1, the
   buffered sigCh may still hold late signals from the previous
   verification cycle (sometimes a duplicate verify-no-match, sometimes
   a verify-stopped emitted by fprintd as it tears down). The next
   waitForVerifyResult would consume one of those instead of waiting
   for a new scan, so the user only ever scans once even though the
   loop iterates 3 times. Fix: drain sigCh between attempts, after the
   gap and before VerifyStart.

2. No visibility into retry behavior. The original log only showed the
   final failure. Add log lines per attempt:
     "fprintd: attempt 1/3, awaiting fingerprint"
     "fprintd: attempt 2/3 (previous: no-match), restarting verify"
     "fprintd: attempt 3/3 (previous: no-match), restarting verify"
     "fprintd: all 3 attempts exhausted with no match"
   so a journal tail conclusively shows whether retry iterated.

Also bumps attemptGap from 200ms to 500ms because some sensors don't
recognize the finger lift in 200ms and would re-process the same scan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
WebAuthn flows in Chrome (and likely Firefox) commonly send two
GetAssertion requests for one user "sign in" click: a preflight that
the browser uses to discover credentials and probe UV state, then the
real request with the actual challenge. Hardware authenticators with
biometric UV (YubiKey Bio, Touch ID, Windows Hello) all cache the UV
result for ~5 seconds so the second request doesn't re-prompt the
user. linux-id had no cache, so the user had to scan their finger
twice per login.

Adds a uvCacheTTL = 5 * time.Second window to fprintd.Fprintd. After
verifyFingerprint() returns nil, the wall-clock timestamp is recorded
on lastSuccessAt under the existing mutex. The next VerifyPresence
call within uvCacheTTL short-circuits with Result{OK: true} without
spawning a goroutine and without calling fprintd at all. Failed
verifications are NOT cached — only successes — so a wrong-finger
attempt always re-prompts.

Concurrency: lastSuccessAt is read at the top of VerifyPresence under
f.mu and written at the end of the verification goroutine under the
same lock, so the cache is race-clean. The cache check is sequenced
AFTER the in-flight check (f.active != nil) so multiple concurrent
calls during a verification still attach to the same channel.

Threading-related security note: the cache only short-circuits within
the same linux-id process, and only for ~5 seconds after a real
verification by the same user. This matches the spec-allowed pattern
described in CTAP 2.1 §6.5 and is exactly how hardware authenticators
behave.

Tests in fprintd/fprintd_test.go:

  TestVerifyPresence_CachesRecentSuccess
    Mocks verifyFingerprintFunc to count invocations. First
    VerifyPresence triggers it; second VerifyPresence within the TTL
    must return OK without invoking it.

  TestVerifyPresence_DoesNotCacheFailure
    Mocks verifyFingerprintFunc to return ErrNoMatch. The failure
    must NOT populate lastSuccessAt; the next call must invoke
    verifyFingerprintFunc again.

  TestVerifyPresence_CacheExpires
    Manipulates lastSuccessAt to simulate cache expiry, asserts the
    next call invokes verifyFingerprintFunc again.

Mocking is via a package-level var verifyFingerprintFunc =
verifyFingerprint, swapped under defer in each test. Avoids needing
a struct field on Fprintd or a real D-Bus connection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…fier

The previous version added the cache inside fprintd.Fprintd. That meant
pinentry mode (the default --auth) still got two prompts per WebAuthn
sign-in click, because Chrome sends a preflight + real GetAssertion pair
and pinentry has no equivalent dedup. User confirmed the symptom:
"prompts twice even without using fprintd I think the page wants it twice."

The fix is to put the cache at the verifier-abstraction level so any
backend benefits.

main.go:
  - New cachingVerifier wraps any UserVerifier. The first call delegates
    to inner.VerifyUser, and on success records lastOK = now(). The next
    call within uvCacheTTL (5s) returns Result{OK: true} immediately
    without ever invoking inner. Failures are not cached.
  - newServer wraps the chosen pinentry/fprintd verifier in
    cachingVerifier so both modes benefit from the cache.
  - When the cache hits, log "verifier: UV cache hit, skipping prompt"
    so users can confirm in journalctl/stdout that the second prompt
    was correctly suppressed.
  - cachingVerifier.PerformsUV() forwards to the inner verifier so the
    AuthFlagUV flag in CTAP2 responses stays honest.
  - cachingVerifier has a swappable now() field used in tests to fast-
    forward the clock, so cache-expiry tests don't need real sleeps.

fprintd/fprintd.go:
  - Removed the per-Fprintd cache, the uvCacheTTL constant, the
    verifyFingerprintFunc package var, and lastSuccessAt field. Single
    source of truth lives in the verifier wrapper now.

main_test.go: 4 new tests for the wrapper, all using fakeVerifier:
  TestCachingVerifier_CachesRecentSuccess     — second call hits cache
  TestCachingVerifier_DoesNotCacheFailure     — failed result is NOT cached
  TestCachingVerifier_CacheExpires            — fast-forward clock past TTL
  TestCachingVerifier_PerformsUVForwardsToInner — UV semantics preserved

fprintd/fprintd_test.go:
  - Removed the 3 cache tests; the equivalent assertions now live on
    the wrapper in main_test.go.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User reproduced "have to scan the right finger twice" with this log:

  19:42:02 got Ctap2Cmd MakeCredential
  19:42:02 fprintd: attempt 1/3, awaiting fingerprint
  19:42:02 fprintd: attempt 2/3 (previous: no-match), restarting verify
  19:42:03 fprintd: attempt 2/3 matched

Attempt 1 → 2 in the same wall-clock second, no time for a real human
scan in between. A phantom verify-no-match signal is arriving on
attempt 1 immediately after VerifyStart and consuming the slot. Then
the user's actual scan lands on attempt 2.

The previous drainSignals call only ran inside the `if attempt > 1`
branch, so attempt 1 had no protection. Likely cause: when a fresh
verifyFingerprint() runs back-to-back after a previous one (e.g.
GetAssertion fails, browser immediately sends MakeCredential), some
combination of fprintd session-start state and godbus's signal
delivery pipeline produces a near-instant phantom signal at the start
of the new session.

Changes:

- New constant settleDelay = 100ms.
- Drain logic moved out of the `if attempt > 1` branch and runs on
  every attempt: after VerifyStart, sleep settleDelay (interruptible
  by ctx) so any phantoms have a chance to arrive in sigCh, then
  drainSignals consumes them. Real fingerprint scans take much longer
  than 100ms so this can't accidentally drop a legitimate scan.
- drainSignals now returns the count of dropped signals; if > 0 we
  log how many phantoms were eaten so the next reproduction makes
  the issue visible: "drained N phantom signal(s) before attempt M".
- waitForVerifyResult logs every signal it receives via
  "fprintd: signal %q done=%v" so a journal tail shows the actual
  fprintd VerifyStatus stream and can pinpoint any future weirdness.

If this fix works, the user's MakeCredential block should look like:

  got Ctap2Cmd MakeCredential
  fprintd: attempt 1/3, awaiting fingerprint
  fprintd: drained 1 phantom signal(s) before attempt 1   ← NEW
  fprintd: signal "verify-match" done=true                ← NEW
  fprintd: attempt 1/3 matched

i.e. the phantom is dropped, the real scan matches on attempt 1, no
"have to touch it again" UX problem.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@matejsmycka matejsmycka merged commit d5a541e into tests-and-spec-fixes Apr 10, 2026
2 checks passed
@matejsmycka matejsmycka deleted the fprintd-no-match-uvinvalid branch April 10, 2026 18:06
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