A refunded license was revoked on the server but kept working in the app:
the edition is resolved offline from the key alone, and the flag added by
#347 lasted the session only and never touched the edition.
Rust
- license.check, next to the key, holds what the license server last said
about it: {key_sha256, attempted_at, revoked}. Never the key itself. A
missing, unreadable or invalid file, or a state about another key, reads
as "no state": not revoked, check due.
- entitlements_from_dir resolves a revoked key to ("free", []), which also
locks auto-update. read_license and store_license report the state.
- check_license_revocation: POST /v1/licenses/verify with the product and
the key only. Nothing is sent without a stored key that validates, nor
within 24 h of the last attempt, which is written before the request.
Fail-open: only "valid" lifts the state, only "revoked" sets it.
- Activation, machine list and deactivation record a license_revoked answer
under the key they sent.
- fetch_price reads error.code of a 403 and names license_revoked instead of
premium_required. It records nothing: the price route judges the license
of the activation token, which the client cannot tie to the stored key,
and 0.15.0 left the token of a previous key next to a new one (#367).
Front
- LicenseContext takes the revoked state from Rust (info.revoked); the
session flag and markRevoked are gone. The check starts once per app
start, writes its outcome to the in-app log, and reloads the context only
when the state changed. A revoked key entered again stays Free.
- UpsellGate and the License card name the revocation.
Docs: ADR 0017 amendment (D13 replaced, the first unsolicited request and
its bounds), architecture, user guide, price contract annex, changelogs.
Refs #365
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review approved the previous head with five suggestions; this commit
applies the four that touch the repository (the fifth is the manual check
in the pull request description).
- Tests: a valid token issued for another machine goes with any row of
this machine. Two rows added to the deactivation table: the reviewer's
mutation that kept such a token stayed green before them.
- ADR 0020, removal condition: counted from the last raw-claim token the
server issued, not from a client version. A Free 0.15.0 no longer
updates and can still activate with the raw id months from now.
- ADR 0020: names what the transition leaves behind when a machine is
activated again while its raw row is still active (a second row, one
more slot), and what a public key does not cover (whoever already holds
the OS id can recompute the derived one).
- "No command hands the OS id to the webview" was wider than the code: the
server's list still carries it for a row from before the derivation.
Bounded in the ADR, architecture.md and the licenseService.ts comment.
No behaviour change. Rust 168, vitest 1314.
Refs #350
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Activation sent `machine_uid::get()` as is: /etc/machine-id on Linux,
MachineGuid on Windows. machine-id(5) calls that id confidential and asks
applications to send a keyed hash under a fixed, application-specific key
instead. The license server stores what it receives next to the license
email and the host name, lists it back and signs it into the activation
token.
- derive_machine_id: HMAC-SHA256 under a frozen key, 64 lowercase hex
characters, pinned by two reference vectors. An empty OS id is refused,
never hashed.
- Activation sends the derived identifier and requires the issued token to
carry it. Nothing else originates a machine identifier.
- A machine activated before this change stays activated: a valid token
whose claim is the raw id of this machine still reads "Activated" (D1).
- The machine list flags `is_this_machine` in Rust, for a row under either
identifier (D3). `ActivationStatus` no longer carries an identifier and
the unused `get_machine_id` command is removed: no command hands the OS
id to the webview any more.
- After a deactivation the local token is dropped when it stood for the
freed slot: any row of this machine takes it, except the old raw row
while the token stands for the derived one.
- ADR 0020 records the derivation, the transition and the condition for
removing the raw-id compatibility.
Behaviour changes beyond the issue text: the machine list and a
deactivation no longer fail on an unreadable OS id (best effort), the
activation status then reads "not activated" instead of failing, and the
unused `store_activation_token` command refuses a token issued for the
raw id.
No new dependency (hmac and sha2 were already declared); Cargo.lock is
untouched. Rust 151 -> 168 tests, vitest 1311 -> 1314.
Refs #350
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- store_license_in drops activation.token when the stored key changes.
The token names the previous license to the price server (its sub is
the license id, the key carries the email), so the card showed the
machine as activated while prices were refused for the old license.
The same key entered again keeps its token; a refused key changes
nothing.
- get_activation_status goes through activation_status_in(dir,
machine_id, key), tested in a temporary directory: a missing,
expired, foreign-machine, foreign-key or unreadable token reads as
not activated. It alone decides "Not activated" and "Activate".
- LicenseCard is keyed by licenseIdentity(info): another key remounts
the body, which re-reads the activation and drops the previous key's
machine list.
- invalidSignature asks to check the copy first. jsonwebtoken checks
the signature before decoding the payload, so a damaged copy often
reads as a signature failure (characterization test). User guide and
changelogs aligned.
- Render tests: the other errors stay visible under Machines, and the
edition's green check gives way to an alert for a revoked key.
Refs #347
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Rust (license_commands.rs)
- LicenseError gains InvalidPrefix, InvalidSignature, Malformed and
UnknownEdition. validate_with_key returns it (ExpiredSignature maps
to LicenseExpired), and store_license, read_license and
validate_license_key reject with the same JSON as the network
commands. store_license_in and read_license_in take a directory, so
the Storage mapping is tested at the source.
- D12: the edition comes from the license key alone.
resolve_license_entitlements no longer takes the activation token,
and current_entitlements reads only license.key
(entitlements_from_dir). A token that had expired (2-year server
lifetime) or came from another machine (restored data) used to drop
a valid key to free, while a key copied without its token kept its
edition. get_activation_status and persist_activation_token keep the
strict token validation, and the key keeps its strict validation
(CWE-613).
Front
- LicenseCard names the load error, and LicenseKeyForm (extracted so a
static render reaches it) names a refused key, through
parseLicenseError: no raw backend string reaches the card.
- D13: a license_revoked answer is recorded in LicenseContext for the
whole session, because the card unmounts when the user leaves
Settings > Users. Only a different key clears it. Card-level
message, alert icon, Activate disabled through selectCanActivate.
- Machine counter n/limit, "Not activated", activating and
deactivating labels, dates in fr-CA/en-CA, and "No expiry date"
beyond 50 years (licenseExpiry.ts, for the 100-year keys of
maximus-api#63).
- license.removeLicense and license.machines.activateError removed;
the price authFailed message points to activating the machine.
Tests: 15 new Rust tests (resolution from a data directory, named
variants, the 100-year key of #363, a key without exp) replace 3
machine-binding tests; vitest covers the card and form renders, the
context revocation, the hook wiring and the locales.
test: reproduces #347 (an expired token of this machine resolved a
valid Base key to free on 5d549d6).
Docs: CHANGELOG FR/EN, guide section 12 and in-app docs,
architecture.md, ADR 0017 amendment for #347, qa-update-cycle.md,
api-contract-prices.md.
Fixes#347Fixes#360
Refs #363
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The Activate button also stays disabled while a deactivation runs
(selectCanActivate): an activation racing the deactivation after a
409 could reach the server first, clear the machine-limit message and
leave the next click to a 429.
- activate_body builds the activation request and omits machine_name
when the host name is unreadable; the server answers 400 invalid_input
to a null. A pure test pins it.
- A deactivation whose 2xx body cannot be read stays an invalid_response.
Counting it as a success would make the command delete this machine's
local token for nothing. A mock-server test pins it.
Proven red on the fixed tree: Activate live during a deactivation 1,
machine_name sent as null 1, unreadable 2xx body read as empty 1.
The same review found that maximus-api answers 500 to the reactivation
of a machine deactivated before (unique constraint without is_active):
tracked in maximus/maximus-api#70, outside this PR.
Refs #270
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The idempotent-deactivation test now pins the route's other 404:
license_not_found stays an error. The previous negative case was a
403 that the deactivate route never returns, so swallowing
license_not_found (an unknown key shown as deactivated, the local
token deleted) went unnoticed.
- A test deactivates this machine and checks that the re-read activation
status brings the Activate button back. Removing that re-read used to
leave the suite green.
- The Activate button stays disabled while the list loads
(selectCanActivate), the counterpart of selectCanDeactivate: a verify
answer landing after a successful activation can no longer mark as
fresh a list that misses this machine.
- CHANGELOG: the old /simpl-resultat address is redirected by the site
since la-compagnie-maximus#153, so the "Buy" bullet now says the
button opens the product page directly, in the app's language. The
test comment is moved to the past tense.
Proven red on the fixed tree: idempotence swallowing license_not_found
1, no status re-read after deactivation 1, Activate live during a list
load 1.
Refs #270
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A 404 without a known code (missing route, proxy error page) is now
pinned as a server error by two table rows, so "key not found" can
never be shown for it.
- The message-key test spells out the expected key of every code
instead of comparing the map with itself, and checks the keys are
distinct: swapping two entries now fails.
- Idempotent deactivation moves into deactivate_idempotent, without
AppHandle, tested against a mock server (404 no_active_activation ->
done, 403 revoked -> error).
- Deactivate buttons stay disabled while the list reloads
(selectCanDeactivate), so a machine deactivated during a reload
cannot reappear.
- The rate-limit message uses i18next plural forms (rateLimit_one /
rateLimit_other with count): no more "1 seconds".
- CHANGELOG: drop the bullet about the edition updating after an
activation. The edition does not change with or without an
activation token, so nothing is observable; refresh() stays.
Proven red on the fixed tree: 404 without a known code read as
LicenseNotFound 1, license_expired mapped to the revoked key 2,
idempotence removed 1, buttons live during a reload 1, {seconds}
instead of {count} 2.
Refs #270
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Activation, deactivation and verify POST to /v1/licenses/* with
"product":"simpl-resultat" instead of the legacy /licenses/* paths,
which cost a 308 on every call.
- New LicenseError enum, serialized as {code} like FetchPriceError and
read from error.code of the /v1 envelope. A 200 {valid:false} from
verify is now a coded refusal instead of an empty machine list, and an
HTML error page on a non-2xx maps to server instead of
invalid_response. Deactivation is idempotent: no_active_activation
counts as done.
- The three calls live in *_inner functions without AppHandle, tested
against a mockito server that only declares the /v1 routes.
- useLicenseMachines hook with an exported reducer. The activation error
survives the list load it triggers (machine_limit_reached opens the
list), a deactivation removes the machine locally without re-running
verify (4 requests on the machine-limit path, under the server's
5/min/IP limit), and refresh() follows every successful activation or
deactivation.
- license.errors.* in FR and EN; purchaseUrl() opens
/{fr|en}/produits/simpl-resultat.
test: reproduces #270. Each original defect, reintroduced on the final
tree, turns tests red: legacy activate path 5, missing product 2,
envelope read as a string 3, verify valid:false as an empty list 2,
strict body parse on a non-2xx 2, untrimmed key 1, list load clearing
the activation error 4, verify re-run after deactivation 2, empty state
despite a list error 1, no refresh after activation 3, old purchase
URL 4.
Fixes#270
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Re-gate auto-update to Base+Premium now that paid activation works
end-to-end (absorbs #271), and align the Rust entitlement layer with the
TS matrix shipped in #297:
- FEATURE_TIERS: auto-update -> [base, premium]; the 'temporarily open'
carve-out and its test are gone (free_allows_auto_update_temporarily
-> free_denied_auto_update). Dead rows web-sync, cloud-backup and
advanced-reports are purged (no call-site anywhere; advanced-reports
-> Premium contradicted the TS reports-advanced -> Base+ matrix).
Only auto-update remains on the Rust side.
- features[] override, fail-closed in Free (CWE-863): new
current_entitlements() resolves the edition AND the signed features[]
through the same machine-binding path — every downgrade path returns
('free', []) so a copied license.key can never keep its signed
features. check_entitlement combines them via the new pure
is_entitled(): is_feature_allowed(feature, edition) ||
features.contains(feature), with a defense-in-depth free short-circuit
mirroring the TS isEntitled. current_edition() now delegates to
current_entitlements() — single resolution path, no drift possible.
- dev-override: new Cargo feature (off by default, never in a release
feature set — CWE-489: debug_assertions could be flipped on a custom
release build and become a Premium backdoor). Only when compiled in,
SR_DEV_EDITION forces the edition (free|base|premium) to test tiers
locally. A feature-off test proves the env var has zero effect in
normal builds; feature-on companions (env access serialized by a
mutex) cover cargo test --features dev-override.
No Tauri command signature changes: check_entitlement keeps its
(feature: String) -> Result<bool, String> contract for useUpdater.ts
and ErrorPage.tsx.
Validation: cargo check + cargo test (106 passed, feature off) +
cargo test --features dev-override + npm test (871) + npm run build.
Resolves#301
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Before this commit, `cargo test --doc --manifest-path src-tauri/Cargo.toml`
failed: the indented formula at return_calculator.rs:12-13 was parsed by
rustdoc as a Rust code block and the pseudo-math (`R = ... sum(CF_i)`)
did not compile. Pre-existing since commit 531624b.
Wrapping the formula in an explicit `\`\`\`text` fence tells rustdoc to
render but not compile-test the block. `cargo test --doc` now passes
(0 doctests, no failures).
Also adds the consolidated #187 entry to CHANGELOG.md and CHANGELOG.fr.md
under Fixed/Corrigé summarizing all six fixes (S1, S2, S3, S4, S5, S7) —
S6 already factorized, S8 deferred to backlog, S9 obsolete.
Suggestion S7 from worker note on #176 (#187).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the placeholder public key with the one whose private
counterpart is now held by the maximus-api license server. The old
key had no licenses issued against it (the server did not exist), so
no users are affected.
The 34 Rust unit tests still pass — license_commands tests use
ad-hoc test keypairs rather than the embedded one, and
embedded_public_key_pem_parses confirms the new PEM is valid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The schema's transactions table uses `date` (see schema.sql:67), not
`transaction_date`. Compile-checked the column name was correct.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces the pre-migration SREF backup to the user so they can roll back a
category migration without digging into the filesystem:
- 90-day dismissable banner at the top of Settings > Categories pointing to
the automatic backup (hidden once reverted, once dismissed, or past 90d).
- Permanent "Restore a backup" entry in Settings > Categories, available as
long as a migration journal exists (even past the 90-day window).
- Confirmation modal with two-step consent, red Restore button, fallback
file picker when the recorded path is missing, PIN prompt for encrypted
SREF files, full-page reload on success.
Internals:
- New `categoryRestoreService` wrapping `read_import_file` +
`importTransactionsWithCategories` with stable error codes
(file_missing, read_failed, parse_failed, wrong_envelope_type,
needs_password, wrong_password, import_failed).
- New `file_exists` Tauri command for the pre-flight presence check.
- On success: `categories_schema_version=v2` + merge `reverted_at` into
`last_categories_migration`.
- Pure `shouldShowBanner` / `isWithinBannerWindow` helpers with tests.
- FR/EN i18n keys under `settings.categoriesCard.restore*`.
- CHANGELOG entries in both locales.
Closes#122
Livraison 1 du milestone spec-refonte-seed-categories-ipc. Applies the
new v1 IPC (Indice des prix à la consommation) taxonomy to freshly
created profiles while leaving existing v2 profiles untouched until the
migration wizard (upcoming issue #121) prompts them to move.
- Migration v8 (additive only):
- ALTER TABLE categories ADD COLUMN i18n_key TEXT
- INSERT OR IGNORE user_preferences.categories_schema_version=v2
(existing profiles tagged as v2 for later migration)
- consolidated_schema.sql rewritten with the full v1 seed and
categories_schema_version='v1' default for brand-new profiles
- src/data/categoryTaxonomyV1.json bundled as the TS-side source of
truth (consumed by #116 categoryTaxonomyService next)
- categoriesSeed.* i18n namespace (FR/EN) — 150 entries each
- CategoryTree and CategoryCombobox fall back to the raw `name` when
i18n_key is null (user-created categories stay literal)
- CategoryTreeNode and CategoryRow gain the i18n_key field end-to-end
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wrapper around dataExportService that creates and verifies a full SREF
backup before the v2->v1 categories migration. Throws on any failure to
ensure migration aborts cleanly.
- Generates filename <ProfileName>_avant-migration-<ISO8601>.sref
- Writes to ~/Documents/Simpl-Resultat/backups/ (creates dir if missing)
- Verifies integrity via re-read + SHA-256 checksum
- Reuses profile PIN for encryption when protected
- Adds two minimal Tauri commands: ensure_backup_dir, get_file_size
- Stable error codes (BackupError) to map to i18n keys in the UI layer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#67
Add opt-in Feedback Hub widget integrated into the Settings Logs card. Routes through a Rust command to bypass CORS and centralize privacy audit. First submission triggers a one-time consent dialog; three opt-in checkboxes (context, logs, identify with Maximus account) all unchecked by default. Wording and payload follow the cross-app conventions in la-compagnie-maximus/docs/feedback-hub-ops.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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 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>
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>
- 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>
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>
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>