Skip to content

fix(helper-auth): rebind app-password on stale device ID instead of returning 409#1

Open
terafin wants to merge 2 commits into
joeblack2k:mainfrom
intarweb:fix/helper-auth-device-rebind
Open

fix(helper-auth): rebind app-password on stale device ID instead of returning 409#1
terafin wants to merge 2 commits into
joeblack2k:mainfrom
intarweb:fix/helper-auth-device-rebind

Conversation

@terafin
Copy link
Copy Markdown

@terafin terafin commented Jun 3, 2026

Problem

Helper save uploads fail with HTTP 409 "app password is already bound to another device" even when the app-password is correctly bound to the requesting device. This breaks uploads for sgm-steamdeck-helper (and any helper): security_device_state.json shows the correct binding, yet every upload 409s.

Root cause

In authenticateHelperKey (backend/cmd/server/helper_auth.go), CHECK 3 compares the app-password record's stored BoundDeviceID against the device resolved by fingerprint (findDeviceByIdentityLocked) and returns 409 whenever they differ and the stale device ID still exists in memory:

if record.BoundDeviceID != nil && *record.BoundDeviceID != boundDevice.ID {
    if _, exists := a.devices[*record.BoundDeviceID]; exists {
        return helperAuthContext{}, http.StatusConflict, "app password is already bound to another device"
    }
}

After an RSM restart reloads security_device_state.json, the same physical machine can come back under a new device ID (re-created / re-keyed / inconsistent state). The stored BoundDeviceID and the fingerprint-resolved boundDevice.ID then legitimately diverge, so this guard 409s on a device that is actually the rightful owner — even though the code immediately below CHECK 3 already knows how to rebind the record to the current device.

Fix

  1. CHECK 3 rebind — only return 409 when the stale device is a genuinely different machine (its fingerprint doesn't match the resolved device). Otherwise fall through to the existing rebind path, which repoints the app-password at the current device ID:

    if record.BoundDeviceID != nil && *record.BoundDeviceID != boundDevice.ID {
        if oldDevice, exists := a.devices[*record.BoundDeviceID]; exists {
            if !deviceIdentityMatches(oldDevice.DeviceType, oldDevice.Fingerprint, boundDevice.DeviceType, boundDevice.Fingerprint) {
                return helperAuthContext{}, http.StatusConflict, "app password is already bound to another device"
            }
            // same physical device under a different ID — rebind below
        }
        // old bound device no longer exists (state reload) — rebind below
    }
  2. Orphan cleanupbindAppPasswordToDeviceLocked previously unbound a displaced app-password but left the dangling record in a.appPasswords forever. It now deletes that record when no other device references it (deleteAppPasswordIfOrphanedLocked), preventing unbound/unusable app-passwords from accumulating across repeated (re)bindings.

Tests

New backend/cmd/server/helper_auth_device_binding_test.go:

  • ✅ App-password correctly bound to device → upload succeeds
  • ✅ App-password bound to a stale device ID but same fingerprint → rebinds and succeeds
  • ✅ App-password whose stale device ID belongs to a genuinely different fingerprint → still 409s
  • ✅ Rebinding a device deletes the displaced orphan password
  • ✅ A displaced password still referenced by another device is not deleted

go test ./cmd/server/ passes in full; gofmt and go vet are clean.

Notes for reviewers

The original issue report proposed a second root cause — that bindAppPasswordToDeviceLocked never sets the reciprocal device.AppPasswordID. That doesn't apply to the current source: the function already sets the reciprocal device.BoundAppPasswordID (security_state.go), and appPasswordIDForDeviceLocked keys off the app-password's BoundDeviceID, not a device-side field. The verifiable over-eager 409 is the CHECK 3 path fixed here.

🤖 Generated with Claude Code


Update — also fixes the device_type drift (CHECK 1) + tested on real hardware

The stale-device-ID rebind (CHECK 3) alone wasn't enough: the SGM Steam Deck helper
enrolls with device_type="steamdeck" but uploads saves tagged with the source's
emulator type (device_type="retroarch"), keeping a stable fingerprint. Because
authenticateHelperKey keyed identity on (device_type, fingerprint), every upload
resolved/created a different device and 409'd. This adds a CHECK 1 fix: treat the
fingerprint as the device identity and device_type as variable metadata — same
fingerprint ⇒ same device (rebind cleanly), different fingerprint ⇒ still rejected.

Tested on real hardware: Steam Deck running sgm-steamdeck-helper v0.4.16 (RetroDeck
source) against a build of this branch. Before: every save 409 Conflict. After:
sync … uploaded=3 … errors=0, and a second sync reports in_sync=3. Added unit tests
for both the same-fingerprint/different-device_type (success) and different-fingerprint
(reject) cases.

@terafin terafin force-pushed the fix/helper-auth-device-rebind branch from d853056 to 0dc6a28 Compare June 3, 2026 10:14
Helper save uploads were failing with HTTP 409 "app password is already
bound to another device" even when the app-password was correctly bound to
the requesting device.

Root cause: in authenticateHelperKey, CHECK 3 rejected the request whenever
the app-password record's stored BoundDeviceID differed from the device
resolved by fingerprint AND that stale device ID still existed in memory.
After an RSM restart reloads security_device_state.json, the same physical
machine can be re-created (or re-keyed) under a new device ID, so the stored
ID and the resolved ID legitimately diverge — producing a spurious 409 on
every upload.

Fix:
- CHECK 3 now only returns 409 when the stale device is a genuinely
  different machine (its fingerprint does not match). Otherwise it falls
  through to the existing rebind logic, which repoints the app-password at
  the current device ID.
- bindAppPasswordToDeviceLocked now deletes the app-password it displaces
  when no other device still references it, so unbound/unusable records
  don't accumulate as orphans across repeated (re)bindings.

Adds unit tests covering: correctly-bound upload succeeds; stale device ID
with matching fingerprint rebinds; genuinely different device still 409s;
displaced password is cleaned up; password referenced elsewhere is kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@terafin terafin force-pushed the fix/helper-auth-device-rebind branch from 0dc6a28 to 09d3d61 Compare June 3, 2026 10:15
…ice_type drift)

A helper can present a different device_type per source while keeping a stable
fingerprint — the SGM Steam Deck helper enrolls as device_type "steamdeck" but
uploads saves tagged with the source emulator type ("retroarch"). authenticateHelperKey
keyed identity on (device_type, fingerprint), so the upload spawned a second device
and returned 409 "app password is already bound to another device" on every save.

Treat the fingerprint as the stable per-device identity and device_type as variable
metadata: when the presented fingerprint matches the bound one, keep the bound identity
(same physical device, rebind cleanly). A different fingerprint is still rejected.

Adds regression tests for both cases.
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