# ADMIN — Internal license operations Creator/operator-only reference. End users should read `USER-GUIDE.md` instead. This doc covers everything the creator does that buyers never see: generating the signing keypair, minting license blobs, the dev vs. production key story, and how to recover from key loss. --- ## TL;DR — I just need a license for my dev machine You're running from source, so the repo's embedded dev keypair signs and verifies. No env vars needed. ```bash python scripts/generate_license.py \ --name "Michael Dombaugh" \ --email michael.dombaugh@gmail.com \ --tier core ``` Copy the `DTLIC2:…` blob from stdout, then activate: ```bash python -m src.license_cli activate "DTLIC2:..." \ --name "Michael Dombaugh" \ --email michael.dombaugh@gmail.com ``` Verify: ```bash python -m src.license_cli status ``` License lands at `~/.datatools/license.json`, valid 1 year. > The `--name` / `--email` you pass to `activate` **must** match the values > the blob was minted with — they're part of the signed payload. --- ## Key model (Ed25519, asymmetric) | Key | Lives where | Used for | |-----|------------|---------| | **Private** (32 bytes hex) | Creator's password manager / KMS only | Signing license blobs | | **Public** (32 bytes hex) | Baked into the shipped binary | Verifying blobs at activation | The split is the whole point: an attacker with a copy of the binary still can't mint blobs — they'd need the private key, which never ships. There's also an in-tree **dev keypair** (`src/license/_dev_keypair.py`) derived deterministically from a seed. It's used when no env vars are set, so devs/tests can sign and verify locally without juggling secrets. Frozen builds that still use it are rejected at startup by `assert_production_safe` — see `src/license/crypto.py:84`. Blob format prefix: `DTLIC2:` (v1 was HMAC; v2 is Ed25519). --- ## One-time setup — generating the production keypair Run once, before the first paid release. ```bash python scripts/generate_keypair.py --output keypair.env ``` You'll get: ``` DATATOOLS_LICENSE_PRIVKEY=<64 hex chars> # KEEP SECRET DATATOOLS_LICENSE_PUBKEY=<64 hex chars> # BAKE INTO BUILD ``` Then: 1. **Stash the private key** in a password manager / KMS / hardware token. Losing it means no more renewals — see "Recovery" below. 2. **Delete `keypair.env`** from disk once stored. 3. **Set the public key** as `DATATOOLS_LICENSE_PUBKEY` in the PyInstaller build environment. The shipped binary embeds it via the env at freeze time. --- ## Minting a buyer license (production) With the production private key loaded: ```bash export DATATOOLS_LICENSE_PRIVKEY= python scripts/generate_license.py \ --name "Buyer Name" \ --email buyer@example.com \ --tier core \ --years 1 \ --output buyer.dtlic ``` Flags: | Flag | Default | Notes | |------|---------|-------| | `--name` | required | Buyer's full name. Goes into signed payload. | | `--email` | required | Buyer's email. Goes into signed payload. | | `--tier` | `core` | One of: `lite`, `core`, `pro` | | `--years` | `1` | Lifetime in years | | `--key` | random | Override the auto-generated license key | | `--output` / `-o` | stdout | Write blob to file instead of printing | Deliver the blob to the buyer either inline in the purchase email or as the attached `.dtlic` file. --- ## Tiers | Tier | Features | |------|---------| | **lite** | Deduplicator, Text Cleaner, Format Standardizer | | **core** | All 9 tools | | **pro** | All 9 tools + future Pro-only features | Source of truth: `src/license/features.py::all_features_for_tier`. --- ## Useful one-liners Mint a free internal/team license (dev key, no env needed): ```bash python scripts/generate_license.py --name "QA Bot" --email qa@datatools.app --tier core --years 5 ``` Mint with a stable, human-readable key: ```bash python scripts/generate_license.py --name "Acme Corp" --email ops@acme.com \ --tier pro --key "DT1-PRO-ACME-2026" ``` Renew an existing buyer (just re-mint with the same email; they paste the new blob): ```bash python -m src.license_cli renew "DTLIC2:..." ``` Check what's active locally: ```bash python -m src.license_cli status ``` Wipe a local license (move to a new machine, debug a buyer issue): ```bash python -m src.license_cli deactivate ``` --- ## Customer record-keeping — the issuance log Every successful `scripts/generate_license.py` run appends one JSON line to a local **issuance log**. This is the creator-side system of record for "who has a license" until the server-side flow in `docs/LICENSE-SERVER.md` lands. **Path:** `~/.datatools-creator/issued.jsonl` (override with `$DATATOOLS_ISSUANCE_LOG`). Mode 600. Outside the buyer-facing `~/.datatools/` dir so it never gets bundled into a shipped install. **Format** — one record per line: ```json { "license_key": "DT1-CORE-5dd8e1db-d90c4656", "name": "Michael Dombaugh", "email": "michael.dombaugh@gmail.com", "tier": "core", "issued_at": "2026-05-13T22:10:27Z", "expires_at": "2031-05-13T22:10:27Z", "blob": "DTLIC2:..." } ``` The full blob is stored so you can re-deliver to a buyer who lost their email without re-minting (the re-minted blob would have a different signature and would invalidate any device they'd already activated against the old one). **Useful operations:** ```bash # Full list of issued licenses cat ~/.datatools-creator/issued.jsonl | jq # Find by buyer email jq -r 'select(.email == "buyer@example.com")' ~/.datatools-creator/issued.jsonl # Count by tier jq -r .tier ~/.datatools-creator/issued.jsonl | sort | uniq -c # Licenses expiring in the next 30 days jq -r 'select(.expires_at < "'"$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"'") | .email' \ ~/.datatools-creator/issued.jsonl # Re-deliver a buyer's blob jq -r 'select(.email == "buyer@example.com") | .blob' \ ~/.datatools-creator/issued.jsonl ``` **Skipping the log** for test mints: pass `--no-log`. Never use this for real buyer fulfillment — an unlogged mint is invisible to every future query and to the eventual server-side migration. **Backup:** treat this file like a small business ledger. Copy it into your password manager / encrypted cloud sync alongside the private key. Losing it doesn't break anything cryptographically (you can still mint new licenses) but it does lose the customer list. **Migrating to the server:** the JSONL schema is intentionally close to the planned `licenses` table in `docs/LICENSE-SERVER.md`. Once the server is up, a one-shot import script will read the JSONL and insert each row. --- ## Recovery — what if the private key is lost? Existing licenses keep working until they expire (the public key in the shipped binary still verifies them). What breaks: - **Renewals** — you can't mint a new blob for an existing buyer. - **New sales** — you can't mint anything. Path forward: 1. Generate a new keypair (`scripts/generate_keypair.py`). 2. Ship a new build with the new public key. 3. Re-issue every active buyer a new blob signed by the new private key. 4. Communicate the upgrade path to buyers. Treat the private key like a code-signing cert — back it up to two independent secure locations. --- ## Files & code pointers | Path | Purpose | |------|---------| | `scripts/generate_keypair.py` | One-time keypair generation | | `scripts/generate_license.py` | Mint a signed blob | | `src/license/crypto.py` | Sign / verify / dev-key detection | | `src/license/_dev_keypair.py` | In-tree dev keypair (never ships in prod) | | `src/license/manager.py` | `assert_production_safe` startup check | | `src/license/features.py` | Tier → features mapping | | `src/license_cli.py` | End-user `activate` / `status` / `renew` / `deactivate` | | `~/.datatools/license.json` | Where activated licenses are stored on each machine | | `~/.datatools-creator/issued.jsonl` | Creator-side issuance log (one JSON line per mint) | | `docs/LICENSE-SERVER.md` | Design for the future online issuance + record-keeping system | | `docs/SETUP-LICENSE-SERVER.md` | Self-hosted server install runbook (DNS, Docker, nginx, TLS, backups) |