feat: license validation commands + entitlements system (#46) #56

Merged
maximus merged 3 commits from issue-46-license-commands-entitlements into main 2026-04-09 19:35:33 +00:00
Owner

Fixes #46

Summary

  • Adds jsonwebtoken (EdDSA) for offline license JWT verification
  • New commands/license_commands.rs : validate_license_key, store_license, store_activation_token, read_license, get_edition, get_machine_id
  • New commands/entitlements.rs : centralized feature→tier mapping + check_entitlement Tauri command
  • exp claim mandatory on every license JWT (CWE-613)
  • Activation tokens (machine-bound) prevent license.key copy between machines
  • current_edition fails closed to "free" on any error/mismatch

Notes

  • The embedded PUBLIC_KEY_PEM is a dev placeholder from RFC 8410 §10.3 test vectors. Must be replaced with the production public key before shipping.
  • The activation token issuance flow comes with #49 (license server) and #53 (frontend activation).
  • 8 unit tests cover happy path, expiry, wrong key, malformed JWT, unknown edition, machine_id matching.

Test plan

  • cargo test in src-tauri/ (run on a machine with Rust toolchain)
  • App boots without regression (existing commands still registered)
  • get_edition returns "free" on a fresh install (no license file)
Fixes #46 ## Summary - Adds `jsonwebtoken` (EdDSA) for offline license JWT verification - New `commands/license_commands.rs` : `validate_license_key`, `store_license`, `store_activation_token`, `read_license`, `get_edition`, `get_machine_id` - New `commands/entitlements.rs` : centralized feature→tier mapping + `check_entitlement` Tauri command - `exp` claim mandatory on every license JWT (CWE-613) - Activation tokens (machine-bound) prevent `license.key` copy between machines - `current_edition` fails closed to `"free"` on any error/mismatch ## Notes - The embedded `PUBLIC_KEY_PEM` is a dev placeholder from RFC 8410 §10.3 test vectors. Must be replaced with the production public key before shipping. - The activation token issuance flow comes with #49 (license server) and #53 (frontend activation). - 8 unit tests cover happy path, expiry, wrong key, malformed JWT, unknown edition, machine_id matching. ## Test plan - [ ] `cargo test` in `src-tauri/` (run on a machine with Rust toolchain) - [ ] App boots without regression (existing commands still registered) - [ ] `get_edition` returns `"free"` on a fresh install (no license file)
maximus added 1 commit 2026-04-09 12:50:02 +00:00
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.
Author
Owner

Review (sprint inline) — APPROVE

Le code respecte le scope de l'issue et implémente les correctifs sécurité demandés au spec-monetisation review.

Points vérifiés

  • jsonwebtoken (EdDSA) utilisé comme dep primaire (pas ed25519-dalek seul)
  • Claim exp mandatory via set_required_spec_claims(&["exp", "iat"]) (CWE-613)
  • validate_exp = true explicitement assigné en plus de la validation par défaut (défense en profondeur si la default change)
  • Activation token séparé avec validation machine_id matching local (anti-copie)
  • current_edition fail-closed sur toute erreur (free)
  • Module entitlements centralise feature→tier — pas de checks dispersés dans le code
  • Cross-platform machine ID via machine-uid
  • Tests : happy path, exp, wrong key, malformed JWT, unknown edition, machine_id
  • Aucun secret dans le diff (le PEM est un placeholder documenté venant du RFC 8410)
  • Fail-closed: missing license = free, invalid signature = free

Suggestions non bloquantes

  1. Validation locale impossible sans toolchain Rust : aucune CI ne build sur les PRs (release.yml ne tourne que sur tags v*). Il serait utile d'ajouter une CI check.yml qui run cargo check + cargo test sur chaque push pour rattraper les erreurs avant merge.
  2. Test manquant : un test explicite vérifiant qu'un JWT sans claim exp est rejeté serait un bon canary pour CWE-613, complémentaire à rejects_expired_license.
  3. PUBLIC_KEY_PEM placeholder : actuellement c'est la moitié publique d'un test vector RFC dont on ne connaît pas la clé privée. Cela rend les tests manuels via l'UI impossibles tant que #49 n'existe pas. Acceptable pour la Phase 1, mais à garder en tête.

Aucun problème critique. Le code est prêt pour merge une fois validé sur une machine avec toolchain Rust.

## Review (sprint inline) — APPROVE Le code respecte le scope de l'issue et implémente les correctifs sécurité demandés au spec-monetisation review. ### Points vérifiés - ✅ `jsonwebtoken` (EdDSA) utilisé comme dep primaire (pas `ed25519-dalek` seul) - ✅ Claim `exp` mandatory via `set_required_spec_claims(&["exp", "iat"])` (CWE-613) - ✅ `validate_exp = true` explicitement assigné en plus de la validation par défaut (défense en profondeur si la default change) - ✅ Activation token séparé avec validation `machine_id` matching local (anti-copie) - ✅ `current_edition` fail-closed sur toute erreur (`free`) - ✅ Module `entitlements` centralise feature→tier — pas de checks dispersés dans le code - ✅ Cross-platform machine ID via `machine-uid` - ✅ Tests : happy path, exp, wrong key, malformed JWT, unknown edition, machine_id - ✅ Aucun secret dans le diff (le PEM est un placeholder documenté venant du RFC 8410) - ✅ Fail-closed: missing license = `free`, invalid signature = `free` ### Suggestions non bloquantes 1. **Validation locale impossible sans toolchain Rust** : aucune CI ne build sur les PRs (release.yml ne tourne que sur tags `v*`). Il serait utile d'ajouter une CI `check.yml` qui run `cargo check` + `cargo test` sur chaque push pour rattraper les erreurs avant merge. 2. **Test manquant** : un test explicite vérifiant qu'un JWT sans claim `exp` est rejeté serait un bon canary pour CWE-613, complémentaire à `rejects_expired_license`. 3. **PUBLIC_KEY_PEM placeholder** : actuellement c'est la moitié publique d'un test vector RFC dont on ne connaît pas la clé privée. Cela rend les tests manuels via l'UI impossibles tant que #49 n'existe pas. Acceptable pour la Phase 1, mais à garder en tête. Aucun problème critique. Le code est prêt pour merge une fois validé sur une machine avec toolchain Rust.
maximus force-pushed issue-46-license-commands-entitlements from a9eacc8b9a to c95ab579a2 2026-04-09 13:35:31 +00:00 Compare
maximus force-pushed issue-46-license-commands-entitlements from c95ab579a2 to 99fef19a6b 2026-04-09 14:02:24 +00:00 Compare
maximus added 1 commit 2026-04-09 14:59:15 +00:00
fix(rust): use DER-built keys in license tests, drop ed25519-dalek pem feature
Some checks failed
PR Check / rust (push) Failing after 10m20s
PR Check / frontend (push) Successful in 2m15s
PR Check / rust (pull_request) Failing after 9m30s
PR Check / frontend (pull_request) Successful in 2m7s
69e136cab0
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.
maximus added 1 commit 2026-04-09 15:12:13 +00:00
fix(rust): pass raw public key bytes to DecodingKey::from_ed_der
All checks were successful
PR Check / rust (push) Successful in 15m54s
PR Check / frontend (push) Successful in 2m15s
PR Check / rust (pull_request) Successful in 16m7s
PR Check / frontend (pull_request) Successful in 2m15s
2e9df1c0b9
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.
maximus merged commit 59cefe8435 into main 2026-04-09 19:35:33 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#56
No description provided.