Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions backend/cmd/server/helper_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,22 @@ func (a *app) authenticateHelperKey(compactKey string, identity helperIdentity,
hasBoundIdentity := strings.TrimSpace(record.BoundDeviceType) != "" && strings.TrimSpace(record.BoundFingerprint) != ""
if hasBoundIdentity {
if identity.isComplete() && !deviceIdentityMatches(identity.DeviceType, identity.Fingerprint, record.BoundDeviceType, record.BoundFingerprint) {
return helperAuthContext{}, http.StatusConflict, "app password is already bound to another device"
// A helper can present a different device_type per source while keeping a
// stable fingerprint — e.g. the SGM Steam Deck helper enrolls as
// device_type "steamdeck" but uploads saves tagged with the source's
// emulator type ("retroarch"). The fingerprint is the stable per-device
// identity; device_type is variable metadata. When the fingerprint still
// matches the bound one, treat it as the same physical device and keep the
// bound identity (so the existing device resolves and we rebind cleanly).
// Only a genuinely different fingerprint is another device — reject that.
if strings.EqualFold(strings.TrimSpace(identity.Fingerprint), strings.TrimSpace(record.BoundFingerprint)) {
identity = helperIdentity{
DeviceType: record.BoundDeviceType,
Fingerprint: record.BoundFingerprint,
}
} else {
return helperAuthContext{}, http.StatusConflict, "app password is already bound to another device"
}
}
if !identity.isComplete() {
identity = helperIdentity{
Expand All @@ -379,10 +394,21 @@ func (a *app) authenticateHelperKey(compactKey string, identity helperIdentity,
return helperAuthContext{}, http.StatusConflict, "device already has a different app password bound"
}

// CHECK 3 — the app-password record stores a device ID that differs from the
// device we resolved by identity. This happens routinely after an RSM restart
// reloads security_device_state.json: a device can be re-created (or re-keyed)
// with a new ID even though it is the same physical machine. We must only reject
// the request when the stale ID belongs to a genuinely different device — i.e.
// one whose fingerprint does not match. Otherwise we fall through and let the
// rebind below repoint the app-password at the current device ID.
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"
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 to the current device.
}
// Old bound device no longer exists (state reload) — rebind to the current device.
}

now := time.Now().UTC()
Expand Down
212 changes: 212 additions & 0 deletions backend/cmd/server/helper_auth_device_binding_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
package main

import (
"net/http"
"testing"
"time"
)

// newHelperAuthTestApp returns an app with empty device / app-password state so
// each test can construct the exact binding scenario it needs without the seed
// data created by newApp(). securityStateFile is cleared so no state is persisted
// to disk during the test.
func newHelperAuthTestApp() *app {
a := newApp()
a.devices = map[int]device{}
a.appPasswords = map[string]appPassword{}
a.nextDeviceID = 1
a.nextAppPasswordID = 1
a.securityStateFile = ""
return a
}

// mintHelperAppPassword creates an app-password and returns its record ID along
// with the compact form a helper would present for authentication.
func mintHelperAppPassword(t *testing.T, a *app, now time.Time) (string, string) {
t.Helper()
record, plain := a.createAppPasswordLocked("test-helper", now)
_, compact, ok := normalizeAppPasswordInput(plain)
if !ok {
t.Fatalf("failed to normalize generated app password %q", plain)
}
return record.ID, compact
}

// An app-password that is correctly bound to the requesting device must
// authenticate cleanly (this is the regular happy-path upload).
func TestAuthenticateHelperKeyCorrectlyBoundSucceeds(t *testing.T) {
a := newHelperAuthTestApp()
now := time.Now().UTC()
a.devices[1] = device{ID: 1, DeviceType: "linux-x86", Fingerprint: "deck-1", LastSeenAt: now, SyncAll: true, CreatedAt: now}
a.nextDeviceID = 2

keyID, compact := mintHelperAppPassword(t, a, now)
rec := a.appPasswords[keyID]
devID := 1
rec.BoundDeviceID = &devID
rec.BoundDeviceType = "linux-x86"
rec.BoundFingerprint = "deck-1"
a.appPasswords[keyID] = rec
d := a.devices[1]
d.BoundAppPasswordID = &keyID
a.devices[1] = d

ctx, status, msg := a.authenticateHelperKey(compact, helperIdentity{DeviceType: "linux-x86", Fingerprint: "deck-1"}, helperMetadata{})
if status != 0 {
t.Fatalf("expected success, got status %d (%s)", status, msg)
}
if ctx.AppPassword.BoundDeviceID == nil || *ctx.AppPassword.BoundDeviceID != 1 {
t.Fatalf("expected app password bound to device 1, got %v", ctx.AppPassword.BoundDeviceID)
}
}

// The regression case: the record stores a stale device ID (the old device no
// longer exists after a state reload) but the same physical fingerprint resolves
// to a current device. The request must rebind rather than return 409.
func TestAuthenticateHelperKeyRebindsStaleDeviceID(t *testing.T) {
a := newHelperAuthTestApp()
now := time.Now().UTC()
// The live device is ID 7 (e.g. re-created with a new ID after a restart).
a.devices[7] = device{ID: 7, DeviceType: "linux-x86", Fingerprint: "deck-1", LastSeenAt: now, SyncAll: true, CreatedAt: now}
a.nextDeviceID = 8

keyID, compact := mintHelperAppPassword(t, a, now)
rec := a.appPasswords[keyID]
staleID := 99 // device 99 no longer exists
rec.BoundDeviceID = &staleID
rec.BoundDeviceType = "linux-x86"
rec.BoundFingerprint = "deck-1"
a.appPasswords[keyID] = rec

ctx, status, msg := a.authenticateHelperKey(compact, helperIdentity{DeviceType: "linux-x86", Fingerprint: "deck-1"}, helperMetadata{})
if status != 0 {
t.Fatalf("expected rebind success, got status %d (%s)", status, msg)
}
if got := a.appPasswords[keyID].BoundDeviceID; got == nil || *got != 7 {
t.Fatalf("expected app password rebound to device 7, got %v", got)
}
if ctx.Device.ID != 7 {
t.Fatalf("expected context device 7, got %d", ctx.Device.ID)
}
}

// A stale device ID that points to a device with a *different* fingerprint is a
// genuinely different machine: the 409 guard must still fire.
func TestAuthenticateHelperKeyRejectsDifferentDevice(t *testing.T) {
a := newHelperAuthTestApp()
now := time.Now().UTC()
a.devices[1] = device{ID: 1, DeviceType: "linux-x86", Fingerprint: "deck-1", LastSeenAt: now, SyncAll: true, CreatedAt: now}
a.devices[2] = device{ID: 2, DeviceType: "linux-x86", Fingerprint: "deck-2", LastSeenAt: now, SyncAll: true, CreatedAt: now}
a.nextDeviceID = 3

keyID, compact := mintHelperAppPassword(t, a, now)
rec := a.appPasswords[keyID]
otherID := 2 // points at a device with a different fingerprint
rec.BoundDeviceID = &otherID
rec.BoundDeviceType = "linux-x86"
rec.BoundFingerprint = "deck-1"
a.appPasswords[keyID] = rec

_, status, _ := a.authenticateHelperKey(compact, helperIdentity{DeviceType: "linux-x86", Fingerprint: "deck-1"}, helperMetadata{})
if status != http.StatusConflict {
t.Fatalf("expected 409 conflict for a genuinely different device, got %d", status)
}
if got := a.appPasswords[keyID].BoundDeviceID; got == nil || *got != 2 {
t.Fatalf("expected binding to remain on device 2 after rejection, got %v", got)
}
}

// Rebinding a device to a new app-password must delete the password it displaces
// (now unbound and unusable) rather than leaving it to accumulate as an orphan.
func TestBindAppPasswordDeletesDisplacedOrphan(t *testing.T) {
a := newHelperAuthTestApp()
now := time.Now().UTC()
a.devices[1] = device{ID: 1, DeviceType: "linux-x86", Fingerprint: "deck-1", LastSeenAt: now, SyncAll: true, CreatedAt: now}
a.nextDeviceID = 2

old, _ := a.createAppPasswordLocked("old", now)
a.bindAppPasswordToDeviceLocked(old.ID, a.devices[1])
fresh, _ := a.createAppPasswordLocked("fresh", now)

a.bindAppPasswordToDeviceLocked(fresh.ID, a.devices[1])

if _, ok := a.appPasswords[old.ID]; ok {
t.Fatalf("expected displaced orphan %s to be deleted", old.ID)
}
if got := a.appPasswords[fresh.ID].BoundDeviceID; got == nil || *got != 1 {
t.Fatalf("expected fresh password bound to device 1, got %v", got)
}
if got := a.devices[1].BoundAppPasswordID; got == nil || *got != fresh.ID {
t.Fatalf("expected device 1 to reference fresh password, got %v", got)
}
}

// A displaced password that is still referenced by another device must NOT be
// deleted by the orphan sweep.
func TestBindAppPasswordKeepsPasswordReferencedElsewhere(t *testing.T) {
a := newHelperAuthTestApp()
now := time.Now().UTC()
shared, _ := a.createAppPasswordLocked("shared", now)
sharedID := shared.ID
a.devices[1] = device{ID: 1, DeviceType: "linux-x86", Fingerprint: "deck-1", BoundAppPasswordID: &sharedID, LastSeenAt: now, SyncAll: true, CreatedAt: now}
a.devices[2] = device{ID: 2, DeviceType: "linux-x86", Fingerprint: "deck-2", BoundAppPasswordID: &sharedID, LastSeenAt: now, SyncAll: true, CreatedAt: now}
a.nextDeviceID = 3

fresh, _ := a.createAppPasswordLocked("fresh", now)
a.bindAppPasswordToDeviceLocked(fresh.ID, a.devices[1])

if _, ok := a.appPasswords[sharedID]; !ok {
t.Fatalf("password still referenced by device 2 must not be deleted")
}
}

// A helper may present a different device_type while keeping a stable fingerprint
// (e.g. the SGM Steam Deck helper enrolls as "steamdeck" but uploads saves tagged
// with the source emulator type "retroarch"). The fingerprint is the device
// identity, so this must authenticate and stay bound to the same device.
func TestAuthenticateHelperKeyDifferentDeviceTypeSameFingerprintSucceeds(t *testing.T) {
a := newHelperAuthTestApp()
now := time.Now().UTC()
a.devices[1] = device{ID: 1, DeviceType: "steamdeck", Fingerprint: "deck-1", LastSeenAt: now, SyncAll: true, CreatedAt: now}
a.nextDeviceID = 2

keyID, compact := mintHelperAppPassword(t, a, now)
rec := a.appPasswords[keyID]
devID := 1
rec.BoundDeviceID = &devID
rec.BoundDeviceType = "steamdeck"
rec.BoundFingerprint = "deck-1"
a.appPasswords[keyID] = rec
d := a.devices[1]
d.BoundAppPasswordID = &keyID
a.devices[1] = d

ctx, status, msg := a.authenticateHelperKey(compact, helperIdentity{DeviceType: "retroarch", Fingerprint: "deck-1"}, helperMetadata{})
if status != 0 {
t.Fatalf("expected success on same-fingerprint different-device_type, got %d (%s)", status, msg)
}
if ctx.AppPassword.BoundDeviceID == nil || *ctx.AppPassword.BoundDeviceID != 1 {
t.Fatalf("expected app password to stay bound to device 1, got %v", ctx.AppPassword.BoundDeviceID)
}
}

// A genuinely different fingerprint is a different physical device and must still
// be rejected — an app-password is bound to one device.
func TestAuthenticateHelperKeyDifferentFingerprintRejected(t *testing.T) {
a := newHelperAuthTestApp()
now := time.Now().UTC()
a.devices[1] = device{ID: 1, DeviceType: "steamdeck", Fingerprint: "deck-1", LastSeenAt: now, SyncAll: true, CreatedAt: now}
a.nextDeviceID = 2

keyID, compact := mintHelperAppPassword(t, a, now)
rec := a.appPasswords[keyID]
devID := 1
rec.BoundDeviceID = &devID
rec.BoundDeviceType = "steamdeck"
rec.BoundFingerprint = "deck-1"
a.appPasswords[keyID] = rec

if _, status, _ := a.authenticateHelperKey(compact, helperIdentity{DeviceType: "steamdeck", Fingerprint: "other-deck"}, helperMetadata{}); status != http.StatusConflict {
t.Fatalf("expected 409 on different fingerprint, got %d", status)
}
}
25 changes: 25 additions & 0 deletions backend/cmd/server/security_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,10 @@ func (a *app) bindAppPasswordToDeviceLocked(passwordID string, d device) {
previous.BoundDeviceType = ""
previous.BoundFingerprint = ""
a.appPasswords[*current] = previous
// The displaced password is no longer bound to this device. If no other
// device references it either, delete it instead of leaving an orphaned,
// unusable record behind to accumulate over repeated (re)bindings.
a.deleteAppPasswordIfOrphanedLocked(*current, d.ID)
}
}

Expand Down Expand Up @@ -626,6 +630,27 @@ func (a *app) bindAppPasswordToDeviceLocked(passwordID string, d device) {
a.saveDeviceLocked(d)
}

// deleteAppPasswordIfOrphanedLocked removes an app-password that is no longer
// bound to any device. excludeDeviceID is the device currently being rebound to a
// different password (its own stale link to passwordID is about to be replaced),
// so it is not treated as a remaining reference. This keeps displaced, unusable
// app-passwords from accumulating without disturbing passwords still in use.
func (a *app) deleteAppPasswordIfOrphanedLocked(passwordID string, excludeDeviceID int) {
record, ok := a.appPasswords[passwordID]
if !ok || record.BoundDeviceID != nil {
return
}
for deviceID, candidate := range a.devices {
if deviceID == excludeDeviceID {
continue
}
if candidate.BoundAppPasswordID != nil && *candidate.BoundAppPasswordID == passwordID {
return
}
}
delete(a.appPasswords, passwordID)
}

func applyHelperMetadataToDevice(input device, metadata helperMetadata, seenAt time.Time) device {
out := input
if seenAt.IsZero() {
Expand Down