feat: HMAC-verified account cache (#80) #85
No reviewers
Labels
No labels
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#85
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-80-subscription-integrity"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes #80
Refs #66
Summary
Closes the
subscription_statustampering trap. Before this PR, writing{"subscription_status": "active"}toaccount.jsongranted Premium features without touching the Logto session.account_cachemodule: HMAC-SHA256 signs the serialised AccountInfo with a 32-byte keychain-stored key.load_verifiedfails closed on legacy/tampered/missing-key payloads.license_commands::check_account_editionnow usesload_verified— Premium stays locked until the next token refresh re-signs the cache.handle_auth_callback,refresh_auth_token,logout,get_account_inforefactored throughaccount_cache.Choice vs issue options
The issue listed three options. Picked Option B (HMAC signed cache) because:
check_entitlement/current_edition.get_account_infostill returns the profile info for display.Test plan
cargo test23/23 pass (6 new tests for account_cache: sign/verify, tamper detection, wrong key, envelope serde, key encode roundtrip, key length validation)npm run buildcleanBefore this change, `license_commands::check_account_edition` read `account.json` directly and granted Premium when `subscription_status` was `"active"`. Any local process could write that JSON and bypass the paywall without ever touching the Logto session. Introduce `account_cache` with: - `save(app, &AccountInfo)` — signs the serialised AccountInfo with HMAC-SHA256 and writes a `{"data", "sig"}` envelope. The 32-byte key lives in the OS keychain (service `com.simpl.resultat`, user `account-hmac-key`) alongside the OAuth tokens from #78. - `load_unverified` — accepts both signed and legacy payloads for UI display (name, email, picture). The license path must never use this. - `load_verified` — requires a valid HMAC signature; returns None for legacy payloads, missing keychain, tampered data. Used by `check_account_edition` so Premium stays locked until the next token refresh re-signs the cache. - `delete` — wipes both the file and the keychain key on logout so the next session generates a fresh cryptographic anchor. `auth_commands::handle_auth_callback` and `refresh_auth_token` now call `account_cache::save` instead of writing the file directly. `logout` clears both stores. `get_account_info` delegates to `load_unverified` so upgraded users see their profile immediately. Trust boundary: the HMAC key lives in the keychain and shares its security model with the OAuth tokens. If the keychain is unreachable, the gating path refuses to grant Premium (fail-closed), which matches the store_mode policy introduced in #78. Refs #66, CWE-345 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>Review — APPROVE ✓
Security
hmaccrate — primitive correcteverify_sliceest constant-time (pas de timing leak)rand::thread_rng().fill_bytes(OsRng-backed)load_verifiedretourne None → Premium reste gatedlogoutsupprime bien la clé HMAC du keychain pour que la prochaine session ait un anchor fraisCorrectness
save()signeserde_json::to_vec(&account),load_verifiedre-sérialiseenvelope.data→ mêmes bytes (serde produit dans l'ordre des champs du struct)license_commands::check_account_editionutilise bienload_verified(pasload_unverified)get_account_infoutiliseload_unverifiedpour que l'UI affiche l'email d'un utilisateur upgradé depuis v0.7.x sans re-loginQuality
load_verified(gating) /load_unverified(display)Observations mineures
verify_rejects_tampered_payload,verify_rejects_wrong_key), mais un test d'intégration bout-à-bout serait valuable.{data, sig}bien formé mais avec une sig invalide,load_verifiedretourne None. C'est bien géré.Verdict : APPROVE