Before 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>
Introduce a new token_store module that persists OAuth tokens in the OS
keychain (Credential Manager on Windows, Secret Service on Linux through
sync-secret-service + crypto-rust, both pure-Rust backends).
- Keychain service name matches the Tauri bundle identifier
(com.simpl.resultat) so credentials are scoped to the real app
identity.
- Transparent migration on first load: a legacy tokens.json is copied
into the keychain, then zeroed and unlinked before removal to reduce
refresh-token recoverability from unallocated disk blocks.
- Store-mode flag (keychain|file) persisted next to the auth dir.
After a successful keychain write the store refuses to silently
downgrade to the file fallback, so a subsequent failure forces
re-authentication instead of leaking plaintext.
- New get_token_store_mode command exposes the current mode to the
frontend so a settings banner can warn users running on the file
fallback.
- auth_commands.rs refactored: all tokens.json read/write/delete paths
go through token_store; check_subscription_status now uses
token_store::load().is_some() to trigger migration even when the
24h throttle would early-return.
Refs #66
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The listener `app.listen("deep-link://new-url", ...)` did not reliably
fire when tauri-plugin-single-instance (deep-link feature) forwarded a
simpl-resultat://auth/callback URL to the running instance. The user
saw the browser complete the OAuth flow, the app regain focus, and
then sit in "loading" forever because the listener never received the
URL.
Switch to the canonical Tauri v2 API — `app.deep_link().on_open_url()`
via DeepLinkExt — which is directly coupled to the deep-link plugin
and catches URLs from both initial launch and single-instance forwards.
Also surface OAuth error responses: if the callback URL contains an
`error` parameter instead of a `code`, emit `auth-callback-error` so
the UI can show the error instead of staying stuck in "loading".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The auto-update gate added in #48 requires the Base edition, but the
license server (#49) needed to grant Base does not exist yet. This
chicken-and-egg left the only current user — myself — unable to
receive the critical v0.7.1 OAuth callback fix via auto-update.
Add EDITION_FREE to the auto-update feature tiers as a temporary
measure. The gate will be restored to [BASE, PREMIUM] once paid
activation works end-to-end via the Phase 2 license server.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Maximus Account sign-in flow was broken in v0.7.0: clicking "Sign in"
opened Logto in the browser, but when the OAuth2 callback fired
simpl-resultat://auth/callback?code=..., the OS launched a second app
instance instead of routing the URL to the running one. The second
instance had no PKCE verifier in memory, and the original instance
never received the deep-link event, leaving it stuck in "loading".
Fix: register tauri-plugin-single-instance (with the deep-link feature)
as the first plugin. It forwards the callback URL to the existing
process, which triggers the existing deep-link://new-url listener and
completes the token exchange.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Update the default LOGTO_APP_ID to match the Native App registered
in the Logto instance at auth.lacompagniemaximus.com.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The auth callback is handled exclusively via the deep-link handler in
lib.rs — exposing it as a JS-invocable command is unnecessary attack
surface. The frontend listens for auth-callback-success/error events
instead.
Plaintext token storage documented as known limitation (see #66).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use write_restricted() for auth/last_check file (consistent 0600)
- Add useAuth hook to the hooks table in docs/architecture.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
account.json contains PII and subscription_status — apply the same
restricted file permissions as tokens.json.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace hand-rolled base64 encoder with base64::URL_SAFE_NO_PAD crate
- Set 0600 permissions on tokens.json via write_restricted() helper (Unix)
- Replace chrono_now() .unwrap() with .unwrap_or_default()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- extract_auth_code now URL-decodes the code parameter to handle
percent-encoded characters from the OAuth provider
- Replace Mutex::lock().unwrap() with .lock().map_err() in start_oauth
and handle_auth_callback to avoid panics on poisoned mutex
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace hash_pin(pin)? with hash_pin(pin).ok() so that a rehash
failure does not propagate as an error. The user can now switch
profiles even if the Argon2id re-hashing step fails — the PIN
is still correctly verified, and the legacy hash remains until
the next successful login.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add automatic re-hashing of legacy SHA-256 PINs to Argon2id on
successful verification, returning new hash to frontend for persistence
- Use constant-time comparison (subtle::ConstantTimeEq) for both
Argon2id and legacy SHA-256 hash verification
- Add unit tests for hash_pin, verify_pin (Argon2id and legacy paths),
re-hashing flow, error cases, and hex encoding roundtrip
- Update frontend to handle VerifyPinResult struct and save rehashed
PIN hash via profile update
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace SHA-256 with Argon2id (m=64MiB, t=3, p=1) for PIN hashing.
Existing SHA-256 hashes are verified transparently via format detection
(argon2id: prefix). New PINs are always hashed with Argon2id.
Addresses CWE-916: Use of Password Hash With Insufficient Computational Effort.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous test refactor wrapped both keys in their respective DER
envelopes. CI surfaced the asymmetry: jsonwebtoken's two from_ed_der
constructors expect different inputs.
- EncodingKey::from_ed_der → PKCS#8 v1 wrapped (ring's
Ed25519KeyPair::from_pkcs8 path). The 16-byte prefix + 32-byte seed
blob is correct.
- DecodingKey::from_ed_der → raw 32-byte public key. Internally it
becomes ring's UnparsedPublicKey::new(&ED25519, key_bytes), which
takes the bare bytes, NOT a SubjectPublicKeyInfo wrapper.
The test was building an SPKI DER for the public key, so verification
saw a malformed key and failed every signature with InvalidSignature
(`accepts_well_formed_base_license` and `activation_token_matches_machine`).
Drop the SPKI helper, pass `signing_key.verifying_key().to_bytes()`
straight into DecodingKey::from_ed_der. Inline doc-comment captures
the asymmetry so the next person doesn't fall in the same hole.
cargo CI flagged: `unresolved import ed25519_dalek::pkcs8::LineEnding`. The
`LineEnding` re-export path varies between pkcs8/spki/der versions, so the
test code that called `to_pkcs8_pem(LineEnding::LF)` won't compile against
the dependency tree we get with ed25519-dalek 2.2 + pkcs8 0.10.
Fix:
- Drop the `pem` feature from the ed25519-dalek dev-dependency.
- In tests, build the PKCS#8 v1 PrivateKeyInfo and SubjectPublicKeyInfo
DER blobs manually from the raw 32-byte Ed25519 seed/public key. The
Ed25519 layout is fixed (16-byte prefix + 32-byte key) so this is short
and stable.
- Pass the resulting DER bytes to `EncodingKey::from_ed_der` /
`DecodingKey::from_ed_der`.
Refactor:
- Extract `strict_validation()` and `embedded_decoding_key()` helpers so
the validation config (mandatory exp/iat for CWE-613) lives in one
place and production callers all share the same DecodingKey constructor.
- `validate_with_key` and `validate_activation_with_key` now take a
`&DecodingKey` instead of raw PEM bytes; production builds the key
once via `embedded_decoding_key()`.
- New canary test `embedded_public_key_pem_parses` fails fast if the
embedded PEM constant ever becomes malformed.
Introduces the offline license infrastructure for the Base/Premium editions.
- jsonwebtoken (EdDSA) verifies license JWTs against an embedded Ed25519
public key. The exp claim is mandatory (CWE-613) and is enforced via
Validation::set_required_spec_claims.
- Activation tokens (server-issued, machine-bound) prevent license.key
copying between machines. Storage is wired up; the actual issuance flow
ships with Issue #49.
- get_edition() fails closed to "free" when the license is missing,
invalid, expired, or activated for a different machine.
- New commands/entitlements module centralizes feature → tier mapping so
Issue #48 (and any future gate) reads from a single source of truth.
- machine-uid provides the cross-platform machine identifier; OS reinstall
invalidates the activation token by design.
- Tests cover happy path, expiry, wrong-key signature, malformed JWT,
unknown edition, and machine_id matching for activation tokens.
The embedded PUBLIC_KEY_PEM is the RFC 8410 §10.3 test vector, clearly
labelled as a development placeholder; replacing it with the production
public key is a release-time task.
The previous approach deleted migration records to force re-application,
but this is dangerous for migration 2 which DELETEs all categories and
keywords before re-inserting them, wiping user customizations.
Now computes the expected SHA-384 checksum (matching sqlx) and updates
the stored checksum in _sqlx_migrations, so the migration is recognized
as already applied without being re-run.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add repair_migrations Tauri command that deletes stale migration 1
checksum from _sqlx_migrations before Database.load(). Migration 1
is idempotent (CREATE IF NOT EXISTS) so re-applying is safe.
Fixes "migration 1 was previously applied but has been modified".
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Restore seed_categories.sql to its original content so the migration 2
checksum matches existing databases. Move the level-3 insurance
subcategories (310-312) into a new migration 7 using INSERT OR IGNORE.
Add .catch() on connectActiveProfile() to surface DB errors.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Support up to 3 levels of categories (e.g., Dépenses récurrentes →
Assurances → Assurance-auto) while keeping SQL JOINs bounded and
existing 2-level branches fully compatible.
Changes across 14 files:
- Types: add "level3" pivot field, depth property on budget row types
- Reports: grandparent JOIN for 3-level resolution in dynamic reports
- Categories: depth validation (max 3), auto is_inputable management,
recursive tree operations, 3-level drag-drop with subtree validation
- Budget: 3-level grouping with intermediate subtotals, leaf-only
aggregation, depth-based indentation (pl-8/pl-14)
- Seed data: Assurances split into Assurance-auto/habitation/vie
- i18n: level3 translations for FR and EN
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each profile gets its own SQLite database file for complete data isolation.
Profile selection screen at launch, sidebar switcher for quick switching,
and optional 4-6 digit PIN for privacy. Existing database becomes the
default profile with seamless upgrade.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Change imported_files UNIQUE constraint from (source_id, file_hash) to
(source_id, filename) so files with identical content but different names
each get their own record. Update createImportedFile to look up existing
records by filename instead of hash.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add export (JSON/CSV) and import (full replace) to the Settings page.
Export supports 3 modes (transactions+categories, transactions only,
categories only) with optional password encryption using Argon2id key
derivation. Import detects encrypted .sref files, prompts for password,
and shows a destructive confirmation modal before replacing data.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
v0.2.6 still had comment lines in schema.sql that changed the SHA-384
checksum. sqlx requires byte-for-byte match with the originally applied
migration SQL. schema.sql is now identical to the v0.2.4 original.
Migrations 4 (is_inputable column) and 5 (import_config_templates table)
in lib.rs handle adding these to existing databases.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
schema.sql was modified in v0.2.5 to include is_inputable column and
import_config_templates table. Since schema.sql is include_str!'d into
migration 1, this changed its SHA-256 checksum in sqlx's migration
tracker, blocking migrations 4 and 5 from running.
Reverts schema.sql to its original v0.2.4 state so the checksum matches
and new migrations can apply. Fixes both "no such table:
import_config_templates" and is_inputable defaulting to false.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add reusable import config templates so users can save and apply CSV
parsing configurations across different import sources. Includes
database table, service, hook integration, and template UI in the
source config panel.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Import: persist `has_header` flag to DB (migration v3) so headerless
CSVs like Desjardins don't lose their first data row on re-import.
- Categories: promote children to root on parent deletion instead of
cascading deactivation, preventing invisible orphans.
- Categories: add re-initialize button to reset all categories and
keywords to seed defaults.
- Bump version to 0.2.1 across tauri.conf.json, package.json, Cargo.toml.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a Settings page with about card (app name + version) and an update
section that uses the Tauri v2 updater plugin to check GitHub Releases,
download signed installers, and relaunch. Includes full state machine
(idle/checking/available/downloading/readyToInstall/installing/error)
with progress bar and retry. Database in %APPDATA% is never touched.
- Add tauri-plugin-updater and tauri-plugin-process (Rust + npm)
- Configure updater endpoint, pubkey placeholder, and passive install mode
- Add signing env vars and updaterJsonPreferNsis to release workflow
- Add Settings nav item, route, and fr/en translations
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add v2 migration with 6 parent categories, 36 child categories, and ~85
keywords extracted from the expense summary to enable auto-categorization.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>