Skip to content

fix(pro): preserve signed license fields on heartbeat rewrite#229

Open
rylinjames wants to merge 1 commit into
mainfrom
fix/pro-license-preserve-signed-fields
Open

fix(pro): preserve signed license fields on heartbeat rewrite#229
rylinjames wants to merge 1 commit into
mainfrom
fix/pro-license-preserve-signed-fields

Conversation

@rylinjames

Copy link
Copy Markdown
Collaborator

Audit §3.8 / Part 1 #16 — the unambiguous, autonomous part of the Pro-tier findings.

Problem

load_license refreshes the local heartbeat by rewriting the license file from ProLicense.to_dict(). But ProLicense models only a subset of the on-disk envelope, so the rewrite silently dropped license_id, max_seats, and key_id — which are part of the signed payload (pro/signature.py). Sequence:

  1. Signed v2 license loads, signature verifies ✅
  2. Heartbeat refresh rewrites the file without the signed fields
  3. Next load_licenseverify_license_signatureLicenseCorrupt: missing required field: license_id

Every paying customer would lock themselves out on the next server restart, the moment the Pro tier is wired.

Fix

Persist the full raw on-disk dict with only last_heartbeat_at bumped. last_heartbeat_at is not in the signed payload, so it never invalidates the signature, and any future envelope field is preserved automatically.

Test

test_heartbeat_rewrite_preserves_unmodelled_signed_fields: a license carrying license_id/max_seats/key_id survives the heartbeat rewrite across two consecutive loads, heartbeat still refreshed. Full pro-license suite (25) passes.

Out of scope (needs decisions — tracked separately)

The broader Pro gaps — license never read in production (server.pro_license never set, no --pro flag); hardware-binding-vs-signature on first activation; heartbeat sending customer_id as license_id; placeholder renewal URLs — require protocol/product decisions and are not in this PR.

🤖 Generated with Claude Code

Audit §3.8 / Part 1 #16 (the autonomous part).

load_license refreshes the local heartbeat by rewriting the license file from
ProLicense.to_dict(). But ProLicense models only a subset of the on-disk
envelope, so the rewrite silently DROPPED license_id, max_seats, and key_id —
which are part of the signed payload (pro/signature.py). The result: a signed
v2 license verifies on first load, gets rewritten without its signed fields,
then fails signature verification with LicenseCorrupt on the *second* load.
Every paying customer would lock themselves out on the next server restart the
moment the Pro tier is wired.

Fix: persist the full raw on-disk dict with only last_heartbeat_at bumped
(last_heartbeat_at is not in the signed payload, so it never invalidates the
signature). Any future envelope field is preserved automatically.

Test (tests/test_pro_license.py): a license carrying license_id/max_seats/
key_id survives the heartbeat rewrite across two consecutive loads, with the
heartbeat still refreshed. Full pro-license suite (25) passes.

Note: the broader Pro wiring gaps (license never read in production; hardware-
binding-vs-signature on first activation; heartbeat sends customer_id as
license_id; placeholder renewal URLs) need protocol/product decisions and are
tracked separately — this PR fixes the unambiguous data-loss bug only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant