Skip to content

feat: add device profiles#950

Open
wizzomafizzo wants to merge 1 commit into
mainfrom
feat/device-profiles
Open

feat: add device profiles#950
wizzomafizzo wants to merge 1 commit into
mainfrom
feat/device-profiles

Conversation

@wizzomafizzo

@wizzomafizzo wizzomafizzo commented Jun 13, 2026

Copy link
Copy Markdown
Member

Summary

  • add profile CRUD, switching, active profile state, and profile card ZapScript commands
  • add profile-scoped playtime limits/history attribution and require-profile launch gate
  • document profile APIs/notifications and update go-zapscript to v0.14.0

Summary by CodeRabbit

  • New Features
    • Added device profiles feature: create/update/delete profiles with individual playtime limits per profile
    • PIN-protected profile switching via API or NFC scans
    • New "require profile for launch" setting to gate media launching until a profile is active
    • Media history now attributed to the active profile
    • Profile management API methods and active profile notifications

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This pull request implements a complete device profiles feature: passwordless preference snapshots with optional per-profile PIN protection, profile-scoped playtime limits, API endpoints for CRUD and switching, NFC card-based activation bypassing PIN checks, and a configuration gate requiring active profiles before media launch. The system attributes media history to profiles at recording time and uses a pluggable limits provider to layer profile overrides over global playtime settings.

Changes

Device Profiles Feature

Layer / File(s) Summary
API contracts and models
docs/api/methods.md, docs/api/notifications.md, docs/ARCHITECTURE.md, pkg/api/models/models.go, pkg/api/models/params.go, pkg/api/models/responses.go, pkg/config/configprofiles.go, pkg/config/configprofiles_test.go
New REST/RPC API documentation for profiles CRUD and switching. Constants for notification and method routing. Request/response types for profile operations, including NewProfileParams, UpdateProfileParams, SwitchProfileParams, and ProfileResponse. Configuration structure for ProfilesRequireForLaunch setting with tests.
Database schema, ORM, and media history attribution
pkg/database/database.go, pkg/database/userdb/migrations/20260612000000_create_profiles_table.sql, pkg/database/userdb/profiles.go, pkg/database/userdb/profiles_test.go, pkg/database/userdb/media_history.go, pkg/database/userdb/media_history_test.go, pkg/database/userdb/media_history_property_test.go
Migration creating Profiles and DeviceState tables with profile CRUD SQL helpers. Extended MediaHistory schema with nullable ProfileID column and partial index. New GetMediaHistoryByProfile method scoping queries to specific profiles. Profile not-found and device-state upsert/delete operations. Integration tests for CRUD, uniqueness, active-state cleanup, and media attribution.
Profiles service core
pkg/service/profiles/pin.go, pkg/service/profiles/pin_test.go, pkg/service/profiles/profiles.go, pkg/service/profiles/profiles_test.go, pkg/service/profiles/switchid.go, pkg/service/profiles/switchid_test.go, pkg/service/profiles/wordlist.txt
Profile lifecycle management with Create, Update, Delete, List operations. PIN hashing via PBKDF2-HMAC-SHA256 with configurable length and salt. PIN verification with sliding-window rate limiting per profile. Active profile persistence/restoration including dangling-reference cleanup. Switch ID generation from embedded 1,295-word list. Comprehensive unit and integration tests for all paths.
Playtime limits provider abstraction and profile resolution
pkg/service/playtime/provider.go, pkg/service/profiles/resolver.go, pkg/service/profiles/resolver_test.go
Pluggable LimitsProvider interface for decoupling limit source from limits manager. LimitsResolver layering profile-specific limit overrides (when active) over global config fallbacks. Parses duration strings to zero for unlimited semantics. Tests verifying override precedence and inheritance.
Playtime limits manager refactoring for profile awareness
pkg/service/playtime/limits.go, pkg/service/playtime/profile_limits_test.go
Updated LimitsManager to use pluggable LimitsProvider instead of direct config access. New ResetSession() method triggered by NotificationProfilesActive to reset session tracking on profile switches. Daily usage calculation now queries profile-scoped history when active profile available. All limit enforcement gates via limits.PlaytimeLimitsEnabled(). Tests for profile-scoped queries, partial enables, and session resets.
API handlers and server integration
pkg/api/methods/profiles.go, pkg/api/methods/profiles_test.go, pkg/api/methods/settings.go, pkg/api/models/requests/requests.go, pkg/api/server.go, pkg/api/server_post_test.go, pkg/api/server_startup_test.go, pkg/api/server_ws_e2e_test.go, pkg/api/ws_dispatcher_test.go
Handlers for profiles list, profiles.new, profiles.update, profiles.delete, profiles.active, profiles.switch with error mapping. Settings endpoints now expose and accept ProfilesRequireForLaunch. Server wires profiles.Service into RequestEnv for both WebSocket and HTTP POST dispatch. Updated test harnesses to match new function signatures.
ZapScript commands and token queue integration
pkg/zapscript/profile.go, pkg/zapscript/profile_test.go, pkg/zapscript/commands.go, pkg/service/queues.go, pkg/service/require_profile_test.go
Commands profile.switch:<switchId> and profile.clear parsed from NFC scans returning ProfileSwitchRequest. Token queue applies profile switches via applyProfileSwitch helper. Launch gating: when profiles_require_for_launch enabled and media-launching commands present with no active profile, launch is blocked with fail sound. Tests verifying all paths and state transitions.
State management and active profile notifications
pkg/service/state/state.go, pkg/service/state/active_profile_test.go, pkg/api/notifications/notifications.go
State tracks active profile snapshot with deep copy on set to prevent external mutations. SetActiveProfile emits profiles.active notification. ActiveProfile getter returns copy or nil. Notification broadcaster sends payload including profile or null on deactivation. Tests verifying copy semantics and notification emissions.
Service startup, configuration, and media attribution
pkg/service/context.go, pkg/service/service.go, pkg/service/media_history_tracker.go, pkg/config/config.go
Service initialization creates profiles.Service, restores persisted active profile, wires LimitsResolver into limits manager with profile-aware enablement. Config struct includes optional Profiles field. Media history tracker captures active profile at entry creation time for attribution. API startup receives profiles service for handler injection.
Testing infrastructure and integration tests
pkg/testing/helpers/db_mocks.go, (test files in layers above)
Mock database methods for profile CRUD and device-state operations. Integration tests in profiles_test.go covering CRUD, constraints, active-state cleanup, media attribution. Behavioral tests verifying launch gating and command processing with active profiles.
Dependency and test call-site updates
go.mod, test helper updates in multiple files
Bumped go-zapscript from v0.13.0 to v0.14.0. Updated test harnesses to pass profiles service through new handler/server function signatures.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • ZaparooProject/zaparoo-core#838: Both PRs extend pkg/service/queues.go with token launch flow changes—main PR adds profile-switch handling and profiles-required gating, while related PR introduces launcher override and preflight behaviors.
  • ZaparooProject/zaparoo-core#747: Main PR's profile attribution in media_history_tracker.go builds directly on the media history tracker component introduced in the related PR.

🐰 Profiles now protect the playtime realm,
NFC cards unlock without a helm,
PIN-guarded switches, limits per child,
Scan a phrase, the access is wild.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/device-profiles

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
pkg/service/profiles/resolver.go (1)

94-103: 💤 Low value

Consider explicitly handling "0" as a special case for clarity.

The code currently relies on time.ParseDuration error handling to interpret the string "0" (without a unit suffix) as unlimited. While this works defensively, explicitly handling "0" would make the intent clearer and align with the documented behavior in line 35.

♻️ Proposed refactor for explicit "0" handling
 func parseLimit(s string) time.Duration {
-	if s == "" {
+	if s == "" || s == "0" {
 		return 0
 	}
 	d, err := time.ParseDuration(s)
 	if err != nil {
 		return 0
 	}
 	return d
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/service/profiles/resolver.go` around lines 94 - 103, The parseLimit
function currently relies on time.ParseDuration to treat the string "0" as an
invalid duration and return 0; update parseLimit to explicitly handle the
literal "0" (or possibly "0s") as a special case by checking if s == "0" (and/or
s == "0s") at the top and returning 0 immediately, then fall through to
time.ParseDuration for other values and keep the existing error handling that
returns 0 on parse failure; this clarifies intent in parseLimit and aligns with
the documented behavior.
pkg/service/profiles/profiles_test.go (1)

108-131: ⚡ Quick win

Rate limiting test doesn't cover the TOCTOU race.

TestActivateByID_RateLimited verifies single-threaded rate limiting behavior but won't catch the race condition flagged in profiles.go#L311-L346. The race only manifests when multiple goroutines call ActivateByID concurrently for the same profile.

📝 Example concurrent test to catch the race
func TestActivateByID_ConcurrentRateLimiting(t *testing.T) {
	t.Parallel()
	svc, mockDB, _ := newTestService(t)
	
	mockDB.On("GetProfile", "profile-1").Return(pinProfile(t, "1234"), nil)
	
	var wg sync.WaitGroup
	errors := make(chan error, 10)
	
	// Fire 10 concurrent wrong-PIN attempts
	for range 10 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			_, err := svc.ActivateByID("profile-1", "9999")
			errors <- err
		}()
	}
	wg.Wait()
	close(errors)
	
	incorrectCount := 0
	rateLimitedCount := 0
	for err := range errors {
		if errors.Is(err, ErrPINIncorrect) {
			incorrectCount++
		} else if errors.Is(err, ErrPINRateLimited) {
			rateLimitedCount++
		}
	}
	
	// After fixing the race, we expect at most pinAttemptLimit incorrect,
	// rest should be rate-limited
	assert.LessOrEqual(t, incorrectCount, pinAttemptLimit)
	assert.GreaterOrEqual(t, rateLimitedCount, 10-pinAttemptLimit)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/service/profiles/profiles_test.go` around lines 108 - 131, Add a
concurrent test that reproduces the TOCTOU race by invoking svc.ActivateByID
concurrently for the same profile: create
TestActivateByID_ConcurrentRateLimiting (or augment
TestActivateByID_RateLimited) to spin up multiple goroutines that call
ActivateByID with a wrong PIN while mocking GetProfile to return the known PIN,
collect errors into a channel, then assert that at most pinAttemptLimit calls
return ErrPINIncorrect and the remainder return ErrPINRateLimited; this will
surface the race in profiles.go handling around ActivateByID and the pin attempt
window logic so you can fix the synchronization there.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/api/methods.md`:
- Line 2458: The "Profile object" heading is using too many hashes (shown as
"##### Profile object") and jumps from the previous "##" level; update that
heading to the correct level to follow the surrounding section hierarchy (e.g.,
change "##### Profile object" to "### Profile object" or the appropriate level
used by the nearby "Profiles" section) so the markdown outline/navigation is
consistent; locate the literal "Profile object" heading in docs/api/methods.md
and adjust the hash count to match the surrounding headings.

In `@docs/api/notifications.md`:
- Around line 420-423: The docs declare the notifications parameter "profile"
with type "object" but its description says it can be null; update the parameter
type to explicitly show it is nullable (use "object | null") so the contract is
unambiguous—edit the table row for the "profile" field in the notifications API
docs and change the Type column from "object" to "object | null" while keeping
the existing description.

In `@pkg/api/models/params.go`:
- Around line 183-190: The handler currently accepts both ProfileID and SwitchID
and silently prefers ProfileID; update the HandleProfilesSwitch request
validation to enforce mutual exclusivity per SwitchProfileParams: if both params
are non-nil/non-empty return a 400 error (with a clear message like "provide
exactly one of profileId or switchId") instead of proceeding, and add a
unit/integration test that posts a payload containing both profileId and
switchId and asserts the handler responds with the expected bad-request error;
touch the SwitchProfileParams handling branch in HandleProfilesSwitch to perform
this check early before any branch-specific logic.

In `@pkg/service/profiles/pin.go`:
- Around line 61-64: The pbkdf2.Key call is incorrect: use the signature
Key(password []byte, salt []byte, iter int, keyLen int, h func() hash.Hash)
[]byte; convert the pin string to []byte and pass parameters in that order
(password, salt, pinIterations, pinKeyLen, sha256.New), capture only the
returned []byte (no err) e.g. key := pbkdf2.Key([]byte(pin), salt,
pinIterations, pinKeyLen, sha256.New), then proceed with whatever
encoding/return the derived key instead of checking a non-existent error; update
any references in the same function (e.g., where key, err were used) to reflect
the single returned value.
- Around line 94-97: The pbkdf2.Key call in pkg/service/profiles/pin.go is using
the wrong parameter order and types and incorrectly expects an error; update the
call to use pbkdf2.Key with password as []byte(pin), salt as the []byte salt,
iterations as the iter int, key length as len(expected), and sha256.New as the
final parameter (pbkdf2.Key returns only []byte), then remove the err handling
around that call and adjust subsequent logic that compares the resulting key
(e.g., where `expected` is used) to use the returned []byte key.

In `@pkg/service/profiles/profiles.go`:
- Around line 311-346: The checkPIN function suffers a TOCTOU race: it computes
recent from s.pinAttempts under s.mu, releases the lock to call VerifyPIN, then
later reuses the stale recent when appending, causing lost attempts; fix by
re-loading and re-filtering the current attempts after reacquiring the lock
(i.e., after VerifyPIN and before appending) or by keeping the lock across the
VerifyPIN call if acceptable; specifically, in checkPIN re-acquire s.mu and
recompute attempts := s.pinAttempts[p.ProfileID] and recent = filtered attempts
using pinAttemptWindow before doing s.pinAttempts[p.ProfileID] = append(recent,
now), using the same pinAttemptLimit/pinAttemptWindow variables to enforce rate
limiting correctly.

---

Nitpick comments:
In `@pkg/service/profiles/profiles_test.go`:
- Around line 108-131: Add a concurrent test that reproduces the TOCTOU race by
invoking svc.ActivateByID concurrently for the same profile: create
TestActivateByID_ConcurrentRateLimiting (or augment
TestActivateByID_RateLimited) to spin up multiple goroutines that call
ActivateByID with a wrong PIN while mocking GetProfile to return the known PIN,
collect errors into a channel, then assert that at most pinAttemptLimit calls
return ErrPINIncorrect and the remainder return ErrPINRateLimited; this will
surface the race in profiles.go handling around ActivateByID and the pin attempt
window logic so you can fix the synchronization there.

In `@pkg/service/profiles/resolver.go`:
- Around line 94-103: The parseLimit function currently relies on
time.ParseDuration to treat the string "0" as an invalid duration and return 0;
update parseLimit to explicitly handle the literal "0" (or possibly "0s") as a
special case by checking if s == "0" (and/or s == "0s") at the top and returning
0 immediately, then fall through to time.ParseDuration for other values and keep
the existing error handling that returns 0 on parse failure; this clarifies
intent in parseLimit and aligns with the documented behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0d7327b2-dfc7-41e1-851b-52a67c72b3db

📥 Commits

Reviewing files that changed from the base of the PR and between 665fe00 and dd34275.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (52)
  • docs/ARCHITECTURE.md
  • docs/api/methods.md
  • docs/api/notifications.md
  • go.mod
  • pkg/api/methods/profiles.go
  • pkg/api/methods/profiles_test.go
  • pkg/api/methods/settings.go
  • pkg/api/models/models.go
  • pkg/api/models/params.go
  • pkg/api/models/requests/requests.go
  • pkg/api/models/responses.go
  • pkg/api/notifications/notifications.go
  • pkg/api/server.go
  • pkg/api/server_post_test.go
  • pkg/api/server_startup_test.go
  • pkg/api/server_ws_e2e_test.go
  • pkg/api/ws_dispatcher_test.go
  • pkg/config/config.go
  • pkg/config/configprofiles.go
  • pkg/config/configprofiles_test.go
  • pkg/database/database.go
  • pkg/database/userdb/media_history.go
  • pkg/database/userdb/media_history_property_test.go
  • pkg/database/userdb/media_history_test.go
  • pkg/database/userdb/migrations/20260612000000_create_profiles_table.sql
  • pkg/database/userdb/profiles.go
  • pkg/database/userdb/profiles_test.go
  • pkg/platforms/platforms.go
  • pkg/service/context.go
  • pkg/service/media_history_tracker.go
  • pkg/service/playtime/limits.go
  • pkg/service/playtime/profile_limits_test.go
  • pkg/service/playtime/provider.go
  • pkg/service/profiles/pin.go
  • pkg/service/profiles/pin_test.go
  • pkg/service/profiles/profiles.go
  • pkg/service/profiles/profiles_test.go
  • pkg/service/profiles/resolver.go
  • pkg/service/profiles/resolver_test.go
  • pkg/service/profiles/switchid.go
  • pkg/service/profiles/switchid_test.go
  • pkg/service/profiles/wordlist.txt
  • pkg/service/queues.go
  • pkg/service/require_profile_test.go
  • pkg/service/scan_behavior_test.go
  • pkg/service/service.go
  • pkg/service/state/active_profile_test.go
  • pkg/service/state/state.go
  • pkg/testing/helpers/db_mocks.go
  • pkg/zapscript/commands.go
  • pkg/zapscript/profile.go
  • pkg/zapscript/profile_test.go

Comment thread docs/api/methods.md

When no profile is active the device behaves exactly as it did before profiles existed: global playtime limits apply and history is unattributed.

##### Profile object

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix heading level jump in the Profiles section.

Line 2458 jumps from ## to #####, which violates markdown heading increment rules and hurts generated outline/navigation.

Suggested diff
-##### Profile object
+### Profile object
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
##### Profile object
### Profile object
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 2458-2458: Heading levels should only increment by one level at a time
Expected: h3; Actual: h5

(MD001, heading-increment)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/api/methods.md` at line 2458, The "Profile object" heading is using too
many hashes (shown as "##### Profile object") and jumps from the previous "##"
level; update that heading to the correct level to follow the surrounding
section hierarchy (e.g., change "##### Profile object" to "### Profile object"
or the appropriate level used by the nearby "Profiles" section) so the markdown
outline/navigation is consistent; locate the literal "Profile object" heading in
docs/api/methods.md and adjust the hash count to match the surrounding headings.

Source: Linters/SAST tools

Comment thread docs/api/notifications.md
Comment on lines +420 to +423
| Key | Type | Required | Description |
| :------ | :----- | :------- | :------------------------------------------------------------------ |
| profile | object | Yes | The new active profile, or null when the device deactivated. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Document profile as nullable in the parameter type.

Line 420 currently lists profile as object, but the description says it may be null when deactivated. Please make the type explicit (object | null) to keep the contract unambiguous.

Suggested diff
-| profile | object | Yes      | The new active profile, or null when the device deactivated.       |
+| profile | object \| null | Yes | The new active profile, or null when the device deactivated.       |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| Key | Type | Required | Description |
| :------ | :----- | :------- | :------------------------------------------------------------------ |
| profile | object | Yes | The new active profile, or null when the device deactivated. |
| Key | Type | Required | Description |
| :------ | :----- | :------- | :------------------------------------------------------------------ |
| profile | object \| null | Yes | The new active profile, or null when the device deactivated. |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/api/notifications.md` around lines 420 - 423, The docs declare the
notifications parameter "profile" with type "object" but its description says it
can be null; update the parameter type to explicitly show it is nullable (use
"object | null") so the contract is unambiguous—edit the table row for the
"profile" field in the notifications API docs and change the Type column from
"object" to "object | null" while keeping the existing description.

Comment thread pkg/api/models/params.go
Comment on lines +183 to +190
// SwitchProfileParams switches the device's active profile. Exactly one of
// ProfileID or SwitchID selects the target; both omitted (or null) means
// deactivate. PIN is required when the target profile has one set.
type SwitchProfileParams struct {
ProfileID *string `json:"profileId"`
SwitchID *string `json:"switchId"`
PIN *string `json:"pin"`
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify that the profiles.switch handler validates mutual exclusivity of ProfileID and SwitchID.

# Search for the switch handler implementation
rg -n -A 20 'MethodProfilesSwitch|profiles\.switch' --type=go -g '!*_test.go'

Repository: ZaparooProject/zaparoo-core

Length of output: 2791


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Locate the handler implementation
rg -n --type=go 'HandleProfilesSwitch' .

# 2) Inspect nearby code for validation (look for ProfileID/SwitchID checks)
rg -n --type=go -S 'ProfileID|SwitchID|profileId|switchId|SwitchProfileParams' pkg/api

# 3) If found, print a focused excerpt around the handler function
# (Use rg again to find the file path and then show the surrounding lines)
file="$(rg -l --type=go 'func\s+HandleProfilesSwitch' . || true)"
echo "handler_file=$file"
if [ -n "$file" ]; then
  rg -n --type=go -A 200 'func\s+HandleProfilesSwitch' "$file"
fi

Repository: ZaparooProject/zaparoo-core

Length of output: 6274


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show switch-related tests around HandleProfilesSwitch
rg -n 'TestHandleProfilesSwitch' pkg/api/methods/profiles_test.go
echo "----"
sed -n '1,280p' pkg/api/methods/profiles_test.go

Repository: ZaparooProject/zaparoo-core

Length of output: 7982


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the params struct definition
sed -n '140,220p' pkg/api/models/params.go

# Inspect validation helper implementation (where ValidateAndUnmarshal lives)
rg -n --type=go 'func\s+ValidateAndUnmarshal' -S pkg | head

Repository: ZaparooProject/zaparoo-core

Length of output: 3642


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show validation helper implementation
sed -n '1,220p' pkg/api/validation/validator.go

Repository: ZaparooProject/zaparoo-core

Length of output: 6513


Enforce ProfileID/SwitchID mutual exclusivity in profiles.switch handler
SwitchProfileParams docs say “Exactly one of ProfileID or SwitchID selects the target”, but HandleProfilesSwitch simply prioritizes profileId when both are non-nil/non-empty (it takes the ProfileID branch first) and does not error on conflicting inputs. Add validation to reject requests where both profileId and switchId are provided (and cover with a test).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/api/models/params.go` around lines 183 - 190, The handler currently
accepts both ProfileID and SwitchID and silently prefers ProfileID; update the
HandleProfilesSwitch request validation to enforce mutual exclusivity per
SwitchProfileParams: if both params are non-nil/non-empty return a 400 error
(with a clear message like "provide exactly one of profileId or switchId")
instead of proceeding, and add a unit/integration test that posts a payload
containing both profileId and switchId and asserts the handler responds with the
expected bad-request error; touch the SwitchProfileParams handling branch in
HandleProfilesSwitch to perform this check early before any branch-specific
logic.

Comment on lines +61 to +64
key, err := pbkdf2.Key(sha256.New, pin, salt, pinIterations, pinKeyLen)
if err != nil {
return "", fmt.Errorf("failed to derive PIN hash: %w", err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Critical: Incorrect pbkdf2.Key function call.

The call to pbkdf2.Key has multiple issues:

  1. Wrong parameter order: The signature is func Key(password, salt []byte, iter, keyLen int, h func() hash.Hash) []byte, but the code passes the hash function first.
  2. Wrong type: The pin parameter is a string but must be []byte.
  3. No error return: pbkdf2.Key returns only []byte, not ([]byte, error).
🔧 Proposed fix
-	key, err := pbkdf2.Key(sha256.New, pin, salt, pinIterations, pinKeyLen)
-	if err != nil {
-		return "", fmt.Errorf("failed to derive PIN hash: %w", err)
-	}
+	key := pbkdf2.Key([]byte(pin), salt, pinIterations, pinKeyLen, sha256.New)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
key, err := pbkdf2.Key(sha256.New, pin, salt, pinIterations, pinKeyLen)
if err != nil {
return "", fmt.Errorf("failed to derive PIN hash: %w", err)
}
key := pbkdf2.Key([]byte(pin), salt, pinIterations, pinKeyLen, sha256.New)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/service/profiles/pin.go` around lines 61 - 64, The pbkdf2.Key call is
incorrect: use the signature Key(password []byte, salt []byte, iter int, keyLen
int, h func() hash.Hash) []byte; convert the pin string to []byte and pass
parameters in that order (password, salt, pinIterations, pinKeyLen, sha256.New),
capture only the returned []byte (no err) e.g. key := pbkdf2.Key([]byte(pin),
salt, pinIterations, pinKeyLen, sha256.New), then proceed with whatever
encoding/return the derived key instead of checking a non-existent error; update
any references in the same function (e.g., where key, err were used) to reflect
the single returned value.

Comment on lines +94 to +97
key, err := pbkdf2.Key(sha256.New, pin, salt, iterations, len(expected))
if err != nil {
return false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Critical: Incorrect pbkdf2.Key function call (same issue as HashPIN).

The call to pbkdf2.Key has the same issues as in HashPIN:

  1. Wrong parameter order: Hash function should be the last parameter.
  2. Wrong type: pin is a string but must be []byte.
  3. No error return: pbkdf2.Key returns only []byte.
🔧 Proposed fix
-	key, err := pbkdf2.Key(sha256.New, pin, salt, iterations, len(expected))
-	if err != nil {
-		return false
-	}
+	key := pbkdf2.Key([]byte(pin), salt, iterations, len(expected), sha256.New)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/service/profiles/pin.go` around lines 94 - 97, The pbkdf2.Key call in
pkg/service/profiles/pin.go is using the wrong parameter order and types and
incorrectly expects an error; update the call to use pbkdf2.Key with password as
[]byte(pin), salt as the []byte salt, iterations as the iter int, key length as
len(expected), and sha256.New as the final parameter (pbkdf2.Key returns only
[]byte), then remove the err handling around that call and adjust subsequent
logic that compares the resulting key (e.g., where `expected` is used) to use
the returned []byte key.

Comment on lines +311 to +346
func (s *Service) checkPIN(p *database.Profile, pin string) error {
if p.PINHash == "" {
return nil
}
if pin == "" {
return ErrPINRequired
}

s.mu.Lock()
now := s.now()
attempts := s.pinAttempts[p.ProfileID]
recent := attempts[:0]
for _, at := range attempts {
if now.Sub(at) < pinAttemptWindow {
recent = append(recent, at)
}
}
if len(recent) >= pinAttemptLimit {
s.pinAttempts[p.ProfileID] = recent
s.mu.Unlock()
return ErrPINRateLimited
}
s.mu.Unlock()

if !VerifyPIN(pin, p.PINHash) {
s.mu.Lock()
s.pinAttempts[p.ProfileID] = append(recent, now)
s.mu.Unlock()
return ErrPINIncorrect
}

s.mu.Lock()
delete(s.pinAttempts, p.ProfileID)
s.mu.Unlock()
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Race condition in PIN rate limiting allows bypass.

The checkPIN method has a TOCTOU (time-of-check to time-of-use) race between lines 333 and 336. The recent slice is computed inside the first lock (lines 322–327), then the lock is released to call VerifyPIN (line 335), then reacquired (line 336) and the stale recent is appended to (line 337).

Race scenario:

  • Goroutine A: locks, reads attempts = [], filters to recent = [], unlocks, starts VerifyPIN (slow)
  • Goroutine B: locks, reads attempts = [], filters to recent = [], unlocks, calls VerifyPIN (wrong PIN), locks, appends to map: attempts = [t1], unlocks
  • Goroutine A: finishes VerifyPIN (wrong PIN), locks, sets attempts = append([], t2) = [t2], unlocks

Result: The t1 attempt is lost. Future checks see only [t2] instead of [t1, t2], allowing extra PIN attempts beyond the limit.

🔒 Proposed fix: re-filter attempts after acquiring the second lock
 	if !VerifyPIN(pin, p.PINHash) {
 		s.mu.Lock()
-		s.pinAttempts[p.ProfileID] = append(recent, now)
+		now := s.now()
+		attempts := s.pinAttempts[p.ProfileID]
+		recent := attempts[:0]
+		for _, at := range attempts {
+			if now.Sub(at) < pinAttemptWindow {
+				recent = append(recent, at)
+			}
+		}
+		s.pinAttempts[p.ProfileID] = append(recent, now)
 		s.mu.Unlock()
 		return ErrPINIncorrect
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/service/profiles/profiles.go` around lines 311 - 346, The checkPIN
function suffers a TOCTOU race: it computes recent from s.pinAttempts under
s.mu, releases the lock to call VerifyPIN, then later reuses the stale recent
when appending, causing lost attempts; fix by re-loading and re-filtering the
current attempts after reacquiring the lock (i.e., after VerifyPIN and before
appending) or by keeping the lock across the VerifyPIN call if acceptable;
specifically, in checkPIN re-acquire s.mu and recompute attempts :=
s.pinAttempts[p.ProfileID] and recent = filtered attempts using pinAttemptWindow
before doing s.pinAttempts[p.ProfileID] = append(recent, now), using the same
pinAttemptLimit/pinAttemptWindow variables to enforce rate limiting correctly.

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