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>
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.