commit 843ff0e5d7ab11f12de073b0d6244c1e68e70259 Author: Keysat Date: Fri Jun 12 17:51:40 2026 -0500 Initial backup of root workspace files Glue files not covered by subproject repos: top-level docs, logo, keysat-design-system, and crosscheck tests. Subproject folders are gitignored (each has its own Gitea remote). diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0ec05a1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# Keep the Docker build context small even though workdir is the parent +# directory. The Keysat Dockerfile only COPYs from `licensing-service/`, so +# everything else in this folder is dead weight in the build context. + +# Sibling projects we don't need for the Keysat daemon build +activate-license-template/ +licensing-client-rust/ +licensing-client-ts/ +tests/ + +# Wrapper's own build output & heavy dirs (the wrapper is only needed at +# build time indirectly — start-cli handles it separately) +licensing-service-startos/node_modules/ +licensing-service-startos/javascript/ +licensing-service-startos/*.s9pk + +# Source-only directories we don't need inside the container +licensing-service/target/ +licensing-service/docs/ + +# Docs + assets at the root of Licensing/ +*.md +logo.png + +# Git + editor +.git/ +.DS_Store +*.bak diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f9444e --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Subprojects — each is its own git repo backed up to its own Gitea remote +/activate-license-template/ +/keysat-docs/ +/keysat-registry-landing/ +/keysat-xyz-landing/ +/licensing-client-go/ +/licensing-client-python/ +/licensing-client-rust/ +/licensing-client-ts/ +/licensing-service-startos/ +/plans/ + +# Local machine config / junk +/.claude/ +.DS_Store diff --git a/BUILDING.md b/BUILDING.md new file mode 100644 index 0000000..a3851ac --- /dev/null +++ b/BUILDING.md @@ -0,0 +1,99 @@ +# Building and sideloading Keysat + +This repo ships three components that you'll bundle into a single installable `.s9pk`: + +- `licensing-service/` — the Rust daemon (`keysat` binary). +- `licensing-service-startos/` — the StartOS 0.4.0.x wrapper (TypeScript manifest + actions + Dockerfile). +- `licensing-client-rust/` and `licensing-client-ts/` — SDKs for downstream software. Not bundled into the `.s9pk`; published separately when you cut a release. + +The Rust service's on-disk folder name is still `licensing-service/` for continuity, but the crate, binary, and operator-visible branding are all **Keysat** now. + +## Prerequisites + +1. A working **StartOS 0.4.0.x development environment** on a Linux or macOS workstation. Follow [docs.start9.com](https://docs.start9.com) if you haven't set this up yet. +2. `start-cli` installed on your PATH, with `~/.startos/developer.key.pem` initialised (`start-cli init`). +3. **Node 20+** (for the StartOS SDK, TypeScript). +4. **Docker + buildx** (for multi-arch image builds). +5. Network reachability from your workstation to your Start9 server (LAN or Tor — whatever you usually use to `start-cli install`). +6. A running **BTCPay Server** on the same Start9 server (Keysat depends on it). + +You do **not** need Rust locally — the Dockerfile pulls a pinned `rust:1.75-slim-bookworm` image. + +## One-time setup + +### 1. Fetch the shared build logic + +Every 0.4.0.x wrapper includes `s9pk.mk`, which is maintained by the Start9 team. Fetch the current copy once: + +```bash +cd licensing-service-startos +curl -o s9pk.mk https://raw.githubusercontent.com/Start9Labs/hello-world-startos/master/s9pk.mk +``` + +The `Makefile` `include s9pk.mk` line will error out until that file is in place. + +### 2. Install TypeScript deps + +```bash +cd licensing-service-startos +npm install +``` + +## Building + +From `licensing-service-startos/`: + +```bash +# Build for all supported architectures (x86_64 + aarch64) +make + +# Or just the architecture your Start9 box runs +make arm # Raspberry Pi / Apple Silicon +make x86 # x86_64 StartOS server +``` + +The resulting artifact lands in `licensing-service-startos/*.s9pk` — a single file you can sideload. + +## Sideloading + +```bash +cd licensing-service-startos +make install +``` + +This uses your `~/.startos/developer.key.pem` to push the package to the Start9 server it's registered against. First-run prompts for the Start9 dashboard password; subsequent installs reuse the cached session. + +If you prefer to upload the `.s9pk` through the StartOS dashboard UI, copy the file off your workstation and drag it into **Sideload package** from the dashboard. + +## First-boot checklist + +Once installed, open **Keysat** in your StartOS dashboard and run these actions in order: + +1. **Set operator name** — your display name, shown on the public homepage. +2. **Connect BTCPay** — opens a BTCPay consent page in your browser. Click Authorize; Keysat auto-detects your store and registers its inbound webhook. +3. **Check BTCPay connection** — confirms the authorize completed and the webhook is live. +4. **Create product** — one per thing you plan to sell. +5. **Create policy** — at least one per product, slugged `default`. Sets the shape of keys issued through the public purchase flow. + +At this point your buyers can `POST /v1/purchase` against your Keysat URL, pay via BTCPay, and poll back for their license key. + +## Testing it end-to-end + +Keysat ships with a cross-check test suite that independently signs license keys with a Python reference implementation and replays them through the Rust and TypeScript SDK parsers. See `tests/crosscheck/README.md`. + +The simplest smoke test against a live install: + +```bash +# On your workstation, once the service is running on StartOS: +curl https:///v1/pubkey +curl https:///healthz +``` + +The first returns the Ed25519 public key you'll bake into downstream software. The second should just return `{"ok":true}`. + +## Troubleshooting + +- **"KEYSAT_ADMIN_API_KEY must be at least 32 characters"** — shouldn't happen from StartOS (the wrapper generates the key), but if you're running locally, `openssl rand -hex 32` gives a valid value. +- **"BTCPay not yet configured — purchases will return 503"** — run **Connect BTCPay** from the StartOS dashboard. +- **Rebuilds don't reflect Rust source changes** — Docker layer caching is aggressive; `make clean` before `make` to force a full rebuild. +- **Existing installs using `LICENSING_*` env vars** — the daemon still reads those as a fallback for one release cycle. New installs set `KEYSAT_*` directly. diff --git a/HOW_IT_WORKS.md b/HOW_IT_WORKS.md new file mode 100644 index 0000000..09bcdf1 --- /dev/null +++ b/HOW_IT_WORKS.md @@ -0,0 +1,97 @@ +# How Keysat works + +This is a plain-English walkthrough of what Keysat actually does, written for people who do not want to read code. It follows two people, Alice and Bob. Alice has written a piece of software she wants to sell. Bob wants to buy it. + +## The cast + +Alice runs her software on her own Start9 server at home. She has two services there: her **BTCPay Server** (a self-hosted Bitcoin payment processor), and a new one she just installed called **licensing-service** — the thing this whole project is about. Her licensing-service is single-tenant: it sells *her* software only, not anyone else's. If a fellow developer wants the same functionality, they install their own copy on their own Start9, just like Alice did. + +Bob is a customer. He may or may not own a Start9. Whether he does affects the experience, not the mechanics — we'll get to that. + +## What a license actually is + +At the center of this system is a short string of characters. It looks like this: + +``` +LIC1-AEAW6RVE6YGS6SRIW2VD5D57N4UPA...-FV56FI7ZTB5GIFQHIPQ35QVVE5AO5... +``` + +That string is the whole license. There is nothing else — no account, no login, no subscription server to reach. Think of it as a cryptographically signed receipt. Anyone who has Alice's public signing key (which she publishes openly) can look at the string and tell, with mathematical certainty, three things: who signed it, what it licenses, and whether anyone has tampered with it. The underlying cryptography is Ed25519, the same family used by SSH, GitHub commits, and Tor. + +This matters because it means two things can happen without any server being online. First, Bob's computer can verify the key is real at every startup without phoning home — Alice does not need to run anything on the day Bob wants to use her software. Second, if Alice gets hit by a bus, every customer's copy keeps working forever. + +## Alice's day-1 setup + +Alice installs licensing-service on her Start9 from the marketplace. On first boot it generates its own signing keypair, and SQLite database, and gives her a random admin API key through a StartOS action called "Show admin credentials." She copies that API key into her password manager. + +Then she clicks "Connect BTCPay." This is the part everybody expected to be painful and isn't. Instead of asking her to go to BTCPay, generate an API key with the right permissions, register a webhook, copy a webhook secret, and paste all of those things back into licensing-service — which is the flow every BTCPay integration has historically demanded — the action returns a single URL. Alice opens it. BTCPay shows her a consent page listing exactly the permissions being requested. She clicks "Authorize." BTCPay sends the approval straight back to licensing-service, which automatically detects her store, picks a webhook secret out of a hat, and registers the webhook on her behalf. Zero pasting. This works because BTCPay's own authorize endpoint (`/api-keys/authorize`) was designed for OAuth-style handshakes and almost no integration uses it. + +Last step: Alice creates a product. She picks a slug, like `bitcoin-ticker-pro`, and a price in satoshis. Now she can sell. + +## Bob buys a license + +The buying experience depends on what Bob is running. + +### Bob has a Start9 and Alice's software is a Start9 package + +This is the nicest path. Bob installs Alice's package from whatever marketplace he uses. When it comes up, the package dashboard shows a "License" section with four actions: Buy license, Finish license purchase, Check license status, Deactivate license. These actions come from the drop-in template in `activate-license-template/`, which Alice added to her package when she built it. + +Bob clicks "Buy license." His Start9 talks to Alice's licensing-service, which opens a BTCPay invoice and returns a checkout URL. Bob opens that URL, pays with Lightning (or on-chain if he prefers), and closes the tab. A minute later he clicks "Finish license purchase." His Start9 polls Alice's service, sees the payment settled, pulls down the license key, verifies its signature locally, and stores it in his package's own database. Next time Alice's software boots on Bob's machine, it picks up the key from the local store and launches. + +Nothing was copied or pasted. Bob never saw the license string. The key got captured as a side effect of payment. + +### Bob is on a regular computer, and Alice's software is not a Start9 package + +This is the more common case for today's world. Bob goes to Alice's website, clicks "Buy," and is taken to a BTCPay checkout. He pays. BTCPay shows a success page that includes the license string — at that point it's the only way he could get it, short of an email. Bob copies the string and pastes it into Alice's app's settings dialog. Alice's app (which has embedded her public key at build time) verifies the signature and unlocks. From then on, no network is required. + +### Bob never paid — Alice gave him a comp + +Alice's licensing-service has an admin action called "Issue license manually," which hands her a signed license for free, with an optional note field ("comped for friend," "press reviewer," and so on). Useful for reviewers, test machines, friends, and early-access customers. + +## What stops Bob from sharing the key with a friend + +This is where the design has to be honest. No licensing system reliably prevents sharing. Determined people will always figure something out. What good licensing does is make casual sharing annoying enough that it doesn't happen often, while keeping the honest customer's experience painless. We have two tools. + +The first is **fingerprint binding**. When Alice's app first runs, it collects a stable identifier for the machine — something like the machine-id on Linux, or a hash of hardware specs on other platforms. It sends that fingerprint to Alice's licensing-service the first time it validates a key online. The service stores the fingerprint alongside the license row. Trust-on-first-use, like SSH. The key can't be moved to a second machine and work online — when the second machine checks in, the service notices a mismatch and returns "rejected." The second machine also can't pretend to be the first, because the fingerprint is hashed on-device and that hash gets baked into the signed part of the key at issuance. Forging it would require Alice's private signing key, which lives only on Alice's Start9. + +The gap in that story: if Bob's friend never goes online, nothing stops him. The signed key still verifies, and signature verification is all that runs at boot. A motivated offline-only user can share keys. In practice this is a small fraction of the market, and the design treats it as acceptable collateral for never holding legitimate customers hostage when their internet drops. + +The second tool is **revocation**. If Alice learns a specific key has been leaked — she sees it posted on a forum, or a customer asks for a refund — she clicks "Revoke license" on her admin dashboard and marks the key as dead. The next time anyone using that key does an online check, they get rejected. Offline users running on stolen keys are unaffected, but the key becomes unsellable going forward. + +These two tools, used together, make key sharing an unpleasant experience for the sharer (fingerprint mismatches), put a ceiling on the damage a leaked key can do (revocation), and leave legitimate users completely unaffected. That is the best the industry has, and it's what reputable commercial licensing systems like FastSpring's or Paddle's do too. + +## What if Alice's server is offline + +The offline path is the default path. When Bob's copy of Alice's app starts up, it runs the signature check against Alice's public key, which is embedded at build time, and if the signature is valid the app launches. The licensing-service does not need to be up. Alice's Start9 can be powered off for a year and no legitimate customer is inconvenienced. + +The online path is layered on top as an opportunistic enhancement: every so often, when the network is up, the app calls Alice's licensing-service's `/v1/validate` endpoint. That endpoint applies revocation and fingerprint binding. If the service is unreachable, nothing breaks — the app treats that as "can't tell" and continues on the last known offline-good status. + +This is the correct tradeoff for paid software that has already been paid for. If the licensing-service is a hard dependency, Alice's going offline for a weekend locks every customer out, which is the failure mode that gives online-licensing its bad reputation. + +## What if Bob wipes his machine + +Two cases. + +If Bob has a Start9, his Start9 backup includes his package's store, which includes his license key. Restoring the backup restores the license. He does not need to contact Alice. Keys are, in effect, Start9-backup-compatible. + +If Bob is on a regular computer, he kept the license key string somewhere — ideally in his password manager, since it is functionally a password. He pastes it back into Alice's app. Done. + +If Bob lost the key entirely, Alice can look him up in her licensing-service admin by invoice id or email and reissue (or he can buy again, depending on Alice's policy). The service keeps every license in its database indefinitely, so "I lost my key" is a five-second fix from Alice's end. + +## What prevents Alice from being ripped off at the payment layer + +BTCPay is a real non-custodial payment processor. When Bob pays, the bitcoin goes straight to Alice's wallet (an xpub or a Lightning node she controls), not to a middleman. BTCPay then notifies licensing-service via a signed webhook that a specific invoice settled. The signed-webhook part matters — without a signature, a malicious actor could fire fake "invoice settled" events and trick licensing-service into issuing free keys. The webhook secret, which was generated automatically during the authorize flow and is never exposed to Alice or any HTTP client, is the shared signing key. Every incoming webhook is rejected if its HMAC-SHA256 signature doesn't verify in constant time. + +Webhook deliveries are also not the only signal. A background task inside licensing-service, every 60 seconds, lists every invoice that is still in a pending state and polls BTCPay directly for its current status. If BTCPay says "settled" but the service didn't see a webhook for it (maybe BTCPay's webhook delivery dropped, or the service was down), the background task catches up and issues the license. This closes the window where a customer has paid but the licensing-service missed the event. + +## What this setup is *not* + +Not a subscription service. A license here is perpetual — one payment, one key, forever. If Alice wants subscriptions she would need to add expiry to the payload and a renewal flow; the payload format has a `flags` byte and a future version bump to support that, but it is not in scope for the current version. + +Not a DRM system. It does not prevent someone with a debugger from patching out the license check. Nothing running on the user's machine can, by construction — the user controls their CPU. This is a licensing system for reasonable people who want to pay. + +Not multi-tenant. Each Alice runs her own licensing-service. Two Alices cannot share one server to sell two different products. That simplification is deliberate — it keeps the data model small, makes disaster recovery obvious ("back up one SQLite file"), and keeps Alice in control of the signing key. If someone wants a SaaS version that hosts many sellers, that's a different product. + +## The bet + +The bet of this whole project is that once the rough edges above are smooth — one-click BTCPay, drop-in Start9 actions, offline verification by default, proper webhook signing, automatic reconciliation — a developer looking to monetize their Start9 package will choose to install this licensing-service instead of writing their own. Not because it's unique, but because it is finished. The competition is other developers writing their own from scratch, and the first-mover advantage is that every hour they spend building theirs is an hour Alice has already spent on her product. diff --git a/MASTER_KEYPAIR_PROCEDURE.md b/MASTER_KEYPAIR_PROCEDURE.md new file mode 100644 index 0000000..816d897 --- /dev/null +++ b/MASTER_KEYPAIR_PROCEDURE.md @@ -0,0 +1,221 @@ +# Keysat master keypair — generation, storage, and import + +This document covers the keypair that signs every Keysat self-license — the +"Keysat-licenses-Keysat" trust root. It is the single most security-critical +piece of cryptographic material in the entire project. Anyone with the +private key can mint Keysat licenses indefinitely. Treat it accordingly. + +## What this keypair is + +An Ed25519 keypair generated by Grant on his laptop. The **public** half is +embedded in the Keysat daemon source code at `licensing-service/src/license_self.rs` +in the `TRUST_ROOT_PUBKEY_PEM` constant. Any Keysat install with that source +embedded will accept license keys signed by the corresponding private half, +and reject everything else. + +The **private** half is held offline and only used to mint Keysat licenses +(eventually via a "master Keysat" instance that imports the keypair). + +## How it was generated + +``` +openssl genpkey -algorithm Ed25519 -out keysat-master-private.pem +openssl pkey -in keysat-master-private.pem -pubout -out keysat-master-public.pem +``` + +These are the same commands that ran. They produce two files: + +- `keysat-master-private.pem` — the private key, ~120 bytes +- `keysat-master-public.pem` — the public key, ~120 bytes + +## What's already done + +✅ Public key embedded in `licensing-service/src/license_self.rs` as `TRUST_ROOT_PUBKEY_PEM`. +The current value is: + +``` +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAgsromMy4osMJplX1rY0fd4ouS6wfkm/vfeY2gXEQHkA= +-----END PUBLIC KEY----- +``` + +If that block ever needs to change (e.g. if the private key leaks and you have +to rotate), edit `TRUST_ROOT_PUBKEY_PEM` and ship a new package version. Note +that all previously-issued licenses become unverifiable on the new package — +plan rotations carefully. + +## What you need to do + +### Right now: secure the private key + +The private-key file is sitting in your home folder (`~/keysat-master-private.pem`). +Do all of the following before anything else: + +1. **Make a paper backup.** Open the file in TextEdit, print it on paper (one + sheet, three lines of base64). Store the printout in a fireproof safe or + safe-deposit box. Test that the printed copy is legible — small fonts and + inkjet bleed are common gotchas. + +2. **Make a digital backup in something secure.** Recommended in rough order: + + - 1Password / Bitwarden secure note (attach the .pem file). Synced across + your devices, encrypted at rest. + - Encrypted USB drive (VeraCrypt or APFS-encrypted disk image), kept offline. + - GPG-encrypted backup uploaded to a personal cloud bucket. + +3. **Delete the plaintext file from your laptop's home folder** once backed up. + + ``` + srm ~/keysat-master-private.pem # macOS + shred -u ~/keysat-master-private.pem # Linux + ``` + + Don't just `rm` it — that leaves the bytes recoverable on most filesystems. + +### When you're ready: stand up a master Keysat instance + +The master Keysat is a separate Keysat install whose only job is to mint +licenses for the Keysat package itself. It runs on its own Start9 (or wherever) +and uses the private key you generated as its issuer key. + +The keypair is stored in the daemon's SQLite database (under the `server_keys` +table), not as a file on disk. To bootstrap the master instance, use the +admin-only `POST /v1/admin/import-issuer-key` endpoint, added in v0.1.0:15 +specifically for this scenario. + +Steps: + +1. Install the Keysat package on a fresh Start9 (or a separate StartOS account + on the same Start9, if possible). Standard install flow. The daemon will + generate a throwaway issuer keypair on first boot — that's expected; you'll + replace it in step 3. + +2. Grab the admin API key for this fresh instance: StartOS dashboard → Keysat + service → **Actions → Show admin API key**. Copy the 64-hex-character key. + +3. From your laptop (the one with `keysat-master-private.pem`), POST the PEM + to the master Keysat's import-issuer-key endpoint: + + ``` + ADMIN_KEY="paste-the-admin-api-key-here" + MASTER_KEYSAT="https://your-master-keysat.example" # or LAN URL like https://your-mdns.local:port + + curl -X POST \ + -H "Authorization: Bearer $ADMIN_KEY" \ + -H "Content-Type: application/x-pem-file" \ + --data-binary @/path/to/keysat-master-private.pem \ + "$MASTER_KEYSAT/v1/admin/import-issuer-key" + ``` + + Expected response: + + ```json + { + "ok": true, + "public_key_pem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n", + "restart_required": true, + "message": "Issuer key imported. Restart the Keysat service for the new key to take effect..." + } + ``` + + The endpoint refuses if any licenses have already been issued by this + Keysat — a safety guard against accidentally invalidating customer + keys. Since this is a freshly-installed master instance, it should + succeed. + +4. **Stop and start the Keysat service** from the StartOS dashboard. The + in-memory keypair gets re-loaded from the DB on startup, picking up + the imported key. Confirm in the logs that the public key now matches + your `keysat-master-public.pem`. + +5. In the master Keysat's admin UI, define a product called "Keysat" with your + intended pricing (e.g. 50,000 sats standard, 250,000 sats patron). + +6. Define a `default` policy on the Keysat product. Perpetual single-seat is a + reasonable default. Optionally add entitlements: `["patron"]` for a Patron + tier. + +7. The master instance is now ready. Self-redeem a free license for your own + non-master Keysat installs (use `free_license` discount codes for + bootstrapping). + +#### Alternative: SSH if you prefer file-system access + +If you'd rather not use the import endpoint, you can SSH into the Start9 host +and edit the SQLite database directly. Less recommended (more error-prone), but +documented for completeness: + +``` +# Enable SSH in StartOS Settings → SSH, add your laptop's pubkey, then: +ssh start9@.local +# find the keysat data volume +sudo find / -name "*.db" -path "*keysat*" 2>/dev/null +# edit server_keys row 1 with sqlite3 +``` + +This route requires you to UPSERT the right PEM strings into the +`server_keys` table manually. Stick with the curl-based import endpoint +unless you specifically need SSH access for some other reason. + +#### Why this isn't a StartOS button + +Putting an "Import master signing key" button in every Keysat operator's +StartOS Actions tab would clutter the UX for the 95% of operators who don't +need it (their auto-generated issuer key is exactly what they want). The +import-issuer-key endpoint is admin-only, invisible by default, and called +exactly once during master setup. + +### When 100 free first-user licenses are needed + +In the master instance's admin UI: + +1. Go to **Discount codes → Create new code**. +2. Set kind `free_license`, max uses 100, no expiry. +3. Pick a memorable code: `EARLY100`, `FOUNDERS100`, etc. +4. Distribute the code via your launch announcement / Twitter / Nostr / mailing + list. First 100 users redeem at `https://registry.keysat.xyz` (or wherever + the buy page is hosted by the master instance). + +## Threat model and rotation plan + +**If the private key leaks:** + +- An attacker can mint Keysat licenses indefinitely. +- Existing customers' licenses are unaffected — the leak doesn't retroactively + invalidate signatures. +- Mitigation: rotate. Generate a fresh keypair, edit `TRUST_ROOT_PUBKEY_PEM`, + ship a new package version, re-issue licenses to existing customers, deprecate + the leaked key in your customer comms. + +**If the private key is destroyed (and no backup):** + +- You can't issue any new licenses ever. +- Existing customer licenses keep working. +- Mitigation: rotation as above. Painful but recoverable. + +This is why the paper backup matters more than anything. Cloud sync is great +for daily use, but a paper copy in a safe is the resilience floor. + +## Future: rolling rotation (v0.3+) + +The current model is hard-cut: one public key, one private key, one rotation +event invalidates everything. v0.3 will land a rolling-rotation flow: + +- Daemon embeds *two* trust-root pubkeys — primary and standby. +- During rotation, the daemon accepts licenses signed by either. +- After all customers have re-licensed under the new key, the old pubkey is + retired in the next package version. + +This makes rotation graceful instead of catastrophic. Until that ships, +treat the private key as if losing it would mean re-licensing every customer +(because it would). + +## Quick reference + +| Action | Command | +|---|---| +| Generate keypair (already done) | `openssl genpkey -algorithm Ed25519 -out keysat-master-private.pem && openssl pkey -in keysat-master-private.pem -pubout -out keysat-master-public.pem` | +| Inspect public key | `openssl pkey -in keysat-master-public.pem -pubin -text -noout` | +| Inspect private key (don't normally do this) | `openssl pkey -in keysat-master-private.pem -text -noout` | +| Securely delete plaintext | macOS: `srm ` · Linux: `shred -u ` | +| Verify which key is embedded in daemon | `grep -A4 TRUST_ROOT_PUBKEY_PEM licensing-service-startos/licensing-service/src/license_self.rs` | diff --git a/PORTING_SDK_TO_NEW_LANGUAGES.md b/PORTING_SDK_TO_NEW_LANGUAGES.md new file mode 100644 index 0000000..7c8fd99 --- /dev/null +++ b/PORTING_SDK_TO_NEW_LANGUAGES.md @@ -0,0 +1,266 @@ +# Porting Keysat SDK to New Languages — v0.2 Roadmap + +This document is a working spec for adding first-class language support +beyond the v0.1 Rust + TypeScript SDKs. Use it as the contributor brief +when you (or someone you hire) sit down to write the Python SDK, the Go +SDK, etc. + +**Scope:** v0.1 ships with `licensing-client` (Rust crate) and +`@keysat/licensing-client` (npm). Anyone integrating Keysat into software +written in another language has to either write their own thin verifier +against the documented wire format, or wait for an official SDK. v0.2's +goal is to remove that friction for the languages where most paid +software actually ships. + +--- + +## Language priorities for v0.2 + +In rough order of how often each comes up in indie / small-team paid +software, and how complete the Ed25519 + base32 ecosystem is: + +| Priority | Language | Rationale | Crypto status | +|----------|-------------|------------------------------------------------------------------------|---------------| +| 1 | Python | Server-side software, CLIs, ML tools, scientific software. | `cryptography` mature, Ed25519 native | +| 2 | Go | Backend services, CLIs, infrastructure tools. | `crypto/ed25519` in stdlib | +| 3 | C# / .NET | Windows desktop apps, games (Unity), enterprise tools. | `NSec` or `BouncyCastle` | +| 4 | Swift | macOS + iOS apps. Largest paid-app market. | `CryptoKit` Ed25519 native (iOS 13+) | +| 5 | Java/Kotlin | Android apps, enterprise software. | `BouncyCastle` | +| 6 | C++ | Native games, audio/video tools. | `libsodium` | + +Picking just two as the first batch: **Python** (already has the reference +signer code, already proven to interoperate) and **Go** (most-requested +language for self-hosted server software). + +--- + +## Wire format — the single source of truth + +Every SDK must implement the exact byte layout already documented and +test-fixtured in this repo. Do not invent a new layout. + +**Authoritative source files.** Read all three before writing a line of +code: + +- `licensing-service/src/crypto/mod.rs` — Rust impl, comment-heavy. + Defines `LicensePayload`, encoding, decoding, and the v1↔v2 boundary. +- `licensing-client-ts/src/key.ts` — second independent impl in TS. +- `tests/crosscheck/reference_signer.py` — third independent impl in + Python. Uses `cryptography` rather than `pynacl` — different + underlying primitive impl, so agreement is meaningful. + +**Format summary** (read the source for byte-exact details): + +- Key string shape: `LIC1--` with + Crockford base32 alphabet (uppercase, no padding, with the standard + Crockford alphabet substitutions: I↔1, L↔1, O↔0). +- Signature: Ed25519 over the raw payload bytes. +- Payload: a fixed-prefix binary structure with `version | flags | + product_id (UUID, 16 bytes) | license_id (UUID, 16 bytes) | issued_at + (i64 unix seconds) | expires_at (i64 unix seconds, 0 means perpetual) + | fingerprint_hash (32 bytes, all zeros if unbound)`. v2 adds a + variable-length tail of UTF-8 entitlements. v1 (legacy) is fixed at + 74 bytes with no entitlements. +- Flags: bit 0 = `FINGERPRINT_BOUND`, bit 1 = `TRIAL`. Reserve all + other bits for future expansion; SDKs must preserve unknown flags + on parse rather than rejecting. +- Fingerprint: client-supplied raw string, hashed by the SDK to SHA-256 + before being sent to the server. The server stores only the hash. + +If the v0.1 Rust source and this doc ever disagree on a byte, the Rust +source wins and this doc gets fixed. + +--- + +## Functional parity matrix — what every SDK must do + +Ordered roughly by integration value: + +| Feature | Required | Notes | +|----------------------------------|----------|----------------------------------------------------------------| +| Parse a `LIC1-...-...` key | Required | Both v1 and v2 layouts. | +| Verify Ed25519 signature offline | Required | Given the issuer's PEM-encoded public key. | +| Detect expiry (`isExpiredAt`) | Required | Pure-function helper, no clock dep — caller supplies time. | +| Detect tamper / bad signature | Required | Distinct error type from "expired" or "wrong product". | +| Check entitlements | Required | Boolean helper given a key + entitlement slug. | +| Compute fingerprint hash | Required | SHA-256 hex; raw input never leaves the host (server-side hashed too). | +| HTTP `validate` call | Required | Returns reason on rejection (`revoked`, `fingerprint_mismatch`, `not_found`, `bad_signature`, `product_mismatch`). | +| HTTP `machines/heartbeat` | Recommended | Updates `last_heartbeat_at` on the bound machine. | +| HTTP `machines/activate` | Recommended | Explicit seat activation. | +| HTTP `machines/deactivate` | Recommended | Free a seat. | +| HTTP `purchase/start` + poll | Recommended | Drives the BTCPay flow from inside the app. Optional but very common. | +| HTTP `redeem` (free codes) | Recommended | One-shot redemption of `free_license` codes. | +| Public key from PEM | Required | Should accept the exact PEM string `GET /v1/pubkey` returns. | +| Public key from raw bytes | Optional | Convenience for compiled-in keys. | +| Browser/server agnostic | Where possible | TS SDK runs in both. Python & Go are server-only by nature; that's fine. | + +Two boolean rules of thumb: + +1. **Network failures must not propagate as license-invalid.** Every SDK's + online methods must distinguish "could not reach server" from "server + rejected the key." Callers will treat the first as "status unknown, + fall back to offline check" and the second as "actually invalid." +2. **Offline check must be the default code path.** All SDKs put the + offline verifier on the easy path. Online validation is opt-in. This + is what protects the seller's customers from the seller's downtime. + +--- + +## Cross-language test vectors + +`tests/crosscheck/vector.json` is the canonical fixture. Every new SDK +must pass every fixture in there before being released. The current +contents (read the file for the source-of-truth values): + +- `v1` — legacy fixed-74 layout, fingerprint-bound. Tests the legacy + parser branch. +- `v2` — trial key, fingerprint-bound, explicit expiry, two + entitlements. Tests the variable-length tail parser. +- `v2_perpetual_unbound` — the common case for a paid purchase. v2, no + expiry, no binding, no entitlements. + +For every new SDK, `tests/crosscheck/` should grow a new +`run_.` runner that loads `vector.json` and asserts: + +1. Each key parses cleanly and field-for-field matches the fixture. +2. Each key's signature verifies against the fixture's PEM public key. +3. Tampering with one byte of the key string produces a verification + error. +4. Fingerprint binding succeeds with the matching fingerprint and fails + with a different one. +5. Entitlement lookup returns true for declared entitlements and false + for absent ones. +6. `isExpiredAt` flips at the documented boundary. +7. `hashFingerprint("hello")` matches Python's + `hashlib.sha256("hello".encode()).hexdigest()`. + +If a runner can pass all seven, the SDK is wire-compatible. + +--- + +## Per-language porting checklist + +Apply this checklist when starting a new SDK in language `L`. It's +intentionally generic — adapt to L's conventions. + +### Phase 0 — read + +- [ ] Read this document end to end. +- [ ] Read `licensing-service/src/crypto/mod.rs` (the byte-exact source). +- [ ] Read `licensing-client-rust/src/lib.rs` and + `licensing-client-ts/src/key.ts` to see two existing impls. +- [ ] Read `tests/crosscheck/reference_signer.py` for a third. +- [ ] Run the existing TypeScript cross-check (`tests/crosscheck/run_ts.mjs`) + to confirm the fixtures in your local clone are valid. + +### Phase 1 — offline verifier (the bulk of the value) + +- [ ] Pick / vendor an Ed25519 verifier library for L. Use the most + mainstream option (stdlib if L has it, otherwise the most-used + package). Avoid hand-rolling crypto. +- [ ] Pick / vendor a Crockford base32 decoder. If none exists in L's + ecosystem, write ~30 lines (the alphabet is fixed; decoding is a + simple table lookup). +- [ ] Implement `PublicKey::from_pem` (or idiomatic equivalent) that + accepts the exact string `GET /v1/pubkey` returns. +- [ ] Implement `parseLicenseKey(string) -> LicenseKey` that handles + both v1 and v2 layouts, preserves unknown flag bits, and rejects + malformed inputs with a clear error type. +- [ ] Implement `Verifier::verify(key) -> VerifyOk | Error` that does + base32 decode → split sig from payload → ed25519 verify → return + parsed payload. +- [ ] Implement `isExpiredAt(key, time)` and `hasEntitlement(key, + slug)` as pure helpers. +- [ ] Implement `hashFingerprint(string) -> hex string`. +- [ ] Pass fixtures 1–6 in `vector.json` via a new + `tests/crosscheck/run_.` runner. + +### Phase 2 — online client (optional features) + +- [ ] Decide on an HTTP library that's idiomatic for L. Avoid pinning to + something exotic — most users will already have a default. +- [ ] Implement `Client::new(baseUrl)`. Strip trailing slashes + defensively. +- [ ] Implement `Client::baseUrl()` getter (we use this in v0.1 to + compute redirect URLs). +- [ ] Implement `Client::fetchPubkeyPem()`. +- [ ] Implement `Client::validate(key, productSlug, fingerprint)`. + Distinguish network errors from server-side rejections in the + return type. +- [ ] Implement `Client::heartbeat(key, fingerprint)`. +- [ ] Implement `Client::activate(key, fingerprint, opts)`. +- [ ] Implement `Client::deactivate(key, fingerprint, reason?)`. +- [ ] Implement `Client::startPurchase(productSlug, opts)` — opts + includes optional `code`, `buyerEmail`, `redirectUrl`. +- [ ] Implement `Client::pollPurchase(invoiceId)`. +- [ ] Implement `Client::waitForLicense(invoiceId, opts?)` — convenience + polling loop. Throw on terminal states (`expired`, `invalid`). +- [ ] Implement `Client::redeemFreeLicense(productSlug, code, opts?)` — + the no-BTCPay path for `free_license` codes. + +### Phase 3 — packaging + release + +- [ ] Pick a package name. Keep it close to `keysat-licensing-client` + modulo language conventions. +- [ ] Pin compatible language version. Document it (Python ≥ 3.10, Go + ≥ 1.21, etc.). +- [ ] Write a README mirroring the structure of + `licensing-client-ts/README.md` (5-line offline check + 10-line + online check + purchase flow + browser usage if applicable). +- [ ] Add an `examples/` directory with at least an offline-verify + example and an online-validate example. +- [ ] Hook the SDK's tests into CI in this monorepo so a v1 wire-format + change automatically breaks every SDK. +- [ ] Publish to the language registry (PyPI / pkg.go.dev / Maven / + SwiftPM / NuGet). + +--- + +## Distribution & naming + +| Language | Package name | Registry | +|-------------|-----------------------------|------------------| +| Rust | `licensing-client` | crates.io | +| TypeScript | `@keysat/licensing-client` | npm | +| Python | `keysat-licensing-client` | PyPI | +| Go | `github.com/keysat-xyz/keysat-go/client` | pkg.go.dev (Go modules use the import path) | +| Java/Kotlin | `xyz.keysat.licensing-client` | Maven Central | +| Swift | `KeysatLicensingClient` | Swift Package Manager (Git tag) | +| C# / .NET | `Keysat.LicensingClient` | NuGet | +| C++ | `keysat-licensing-client` | vcpkg / Conan | + +--- + +## Versioning + +Every SDK starts at `0.1.0` and tracks the wire-format version +independently of its own SemVer. Wire-format breaking changes (a new +required field in the payload, e.g.) bump every SDK's minor version +simultaneously. Wire-compatible additions (a new optional flag bit) bump +the patch version. + +A wire-format change requires: + +1. Update `licensing-service/src/crypto/mod.rs` first. +2. Add new fixtures to `tests/crosscheck/vector.json`. +3. Update every SDK to pass the new fixtures. +4. Release coordinated minor versions. + +This is a real coordination tax. The mitigation is to keep wire-format +changes infrequent and additive. + +--- + +## Maintenance burden — be honest about it + +Each SDK is a long-tail liability. Every breaking change in the wire +format is N times the work, where N is the number of SDKs. Every CVE in +a transitive HTTP or crypto dependency is a release per language. Be +realistic about how many SDKs the team can credibly maintain. Two or +three first-class is plausibly forever; six or seven becomes a +not-getting-around-to-it problem. + +The escape valve: keep the wire format documented well enough that an +abandoned SDK is straightforward to fork or replace. If the wire-format +section of this doc is ever harder to read than the code, we've +regressed and need to fix it. diff --git a/PRODUCT_BRANDING_DESIGN.md b/PRODUCT_BRANDING_DESIGN.md new file mode 100644 index 0000000..155e536 --- /dev/null +++ b/PRODUCT_BRANDING_DESIGN.md @@ -0,0 +1,156 @@ +# Product Branding & Custom Domains — Design (v0.3.0) + +**Status:** approved 2026-05-08 by Grant. Slotted as **v0.3.0:0** (branding tokens) and **v0.3.0:1** (custom-domain routing). Lands after recurring-subs admin UI ships in v0.2.x. + +## Problem + +Operators may sell multiple software products through one Keysat instance. Each product likely has its own marketing site and brand identity. Today, every product's `/buy/` page renders with Keysat branding (logo, colors, copy). Buyers landing on a Keysat purchase page from a product's marketing site experience a context break — the brand they came from disappears. + +## Goals + +1. Each product's `/buy/` page renders with that product's branding (logo, colors, support email, footer copy, links to terms/privacy). +2. Operators can route a custom-domain path (e.g. `product1.com/product1/buy`) to their Keysat instance via StartTunnel and have the page render branded for the right product. +3. The feature is **Pro-tier gated** — it joins recurring subs, multi-currency, and Lightning tipping as Pro features. Free tier sees Keysat default branding only. + +## Non-goals (v1) + +- **No custom CSS or HTML.** Theme tokens (colors, logo URL, copy strings) only. Custom layouts are a "wait until someone asks" problem. +- **Keysat does not host the marketing landing.** Operators host `product1.com` themselves (Vercel, Cloudflare Pages, whatever). Keysat only owns the `/buy/...` path that StartTunnel forwards. +- **No bare-domain rendering.** `product1.com` is not Keysat's. Only `product1.com/product1/buy` (or whatever path the operator chooses) maps to Keysat. +- **No per-product asset upload in v1.** Logos and favicons are URL references — operators host the assets themselves. Asset upload + CDN-style serving is a v0.3.x follow-up if asked for. + +## Routing model (revised 2026-05-08) + +Operator's setup, per product: +1. They run their marketing site at `product1.com` (NOT on Keysat). Keysat never serves the operator's apex. +2. They create a `licensing.product1.com` **subdomain** (or any subdomain pattern they prefer) and point a StartTunnel forward at their Keysat instance. One forward per product. All forwards land on the same Keysat instance — they're distinguished by the `Host` header. +3. The buy link on the marketing site reads `https://licensing.product1.com/product1/buy`. Clean, brand-consistent, and the URL itself signals "billing/checkout" to the buyer. + +### Why subdomains instead of path prefixes on the apex + +- Operator keeps `product1.com` fully under their own control (marketing CMS, A/B tests, blog, etc.). Only `licensing.product1.com` is delegated to the Keysat tunnel — much lower coordination cost, lower blast radius if the tunnel goes down. +- The URL is self-documenting. A buyer hovering over the link sees `licensing.product1.com`, which is recognisable as a billing/checkout subdomain — higher trust signal than a path on the apex. +- DNS / TLS lifecycle is independent of the marketing site. The operator can rotate, retire, or swap-Keysat-instance for the subdomain without touching their apex. + +### URL shape inside Keysat + +We keep the existing path: **`/buy/`**. The buy link from the operator's marketing site reads `https://licensing.product1.com/buy/product1` — same path Keysat already serves, just on a branded subdomain. No route alias, no reserved-slug churn, full back-compat with any marketing site that's already linking to `/buy/` on the bare Keysat onion. + +The branding tokens get applied at render time based on the product the path resolved to. Host header is not consulted for the v1 routing — the slug in the path carries the product identity. + +### Bare-host root: out of scope + +We do NOT render the product buy page at `https://licensing.product1.com/` (bare subdomain root). Confirmed 2026-05-08: operators always link to the explicit `/buy/` URL from their marketing site. Skipping bare-host means no Host-header middleware, no `licensing_subdomain` JSON field, no per-host product lookup table — meaningful complexity savings. + +A buyer who manually types just `licensing.product1.com` lands on the standard Keysat root response (operator-name banner, public-key endpoint pointer). Acceptable: this is a vanishingly rare case, and the operator's marketing site is the canonical entry point. + +## Data model + +Add one column to `products`: + +```sql +-- migration 0014_product_branding.sql (v0.3.0) +ALTER TABLE products ADD COLUMN branding TEXT; -- nullable JSON blob +``` + +Stored shape (validated server-side on write, sane defaults applied at render): + +```json +{ + "display_name": "ProductOne", // overrides products.name on the buy page only + "tagline": "...", // short subtitle + "logo_url": "https://.../logo.png", + "favicon_url": "https://.../favicon.ico", + "primary_color": "#1a73e8", // hex; used for buttons, accents + "accent_color": "#ff6b00", // hex; secondary highlights + "background_color": "#ffffff", // hex; page bg + "text_color": "#111827", // hex; main copy + "theme": "light", // "light" | "dark" | "auto" + "support_email": "support@product1.com", + "footer_text": "© 2026 ProductOne, LLC.", + "terms_url": "https://product1.com/terms", + "privacy_url": "https://product1.com/privacy", + "canonical_buy_url": "https://product1.com/product1/buy" // used in receipts/emails +} +``` + +All fields optional. Missing fields fall back to Keysat defaults. Unknown fields are silently dropped on write (forward-compat). + +## API surface + +**Public** (no auth — buyers, SDKs): +- `GET /v1/public/products/` already returns product metadata. **Extend** the response to include the `branding` object so SDKs can render branded purchase prompts (e.g. an in-app "Buy Pro" button styled to match the product). + +**Admin** (Bearer + admin key): +- `PATCH /v1/admin/products/` already exists for product mutation; add `branding` to the accepted body. Validation: hex colors regex, URL parsing on `*_url` fields, length caps on copy strings, theme enum check. +- Optional convenience: `PUT /v1/admin/products//branding` taking just the branding JSON. Adds nothing the PATCH doesn't, but cleaner separation in the admin UI. + +## Admin UI + +New "Branding" tab on the product editor (`admin/index.html`). Inputs: +- Display name override +- Tagline +- Logo URL + small preview +- Favicon URL +- Color pickers (primary, accent, background, text) with live preview swatches +- Theme dropdown (light / dark / auto) +- Support email +- Footer text (textarea, length-capped) +- Terms URL, Privacy URL, Canonical Buy URL +- "Preview" button — opens `/buy/?preview=1` in a new tab with the unsaved tokens applied via query params (so admins can preview before saving) + +Pro-tier gate: tab visible to all, but `Save` is disabled with a "Pro tier required" tooltip on Free. The check uses the same `self_tier` plumbing as recurring/multi-currency/tipping. + +## Render path + +`/buy/` template currently reads from a fixed `templates/buy.html`. v0.3.0 changes: +1. Load product → load `branding` JSON. +2. Pass branding tokens into the template context with Keysat fallbacks for any missing field. +3. Inject CSS variables in a `' : '') + + '' + xml + ''; + return save(new Blob([html], { type: 'text/html' }), 'html'); + } + + // PNG: the SVG's own width/height must be the output resolution — an + // -loaded SVG rasterizes at its intrinsic size, so sizing it at 1× + // and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the + // w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders + // the HTML at full resolution. + const px = 3; + const svg = '' + + (fontCss ? '' : '') + xml + ''; + const img = new Image(); + await new Promise((res, rej) => { + img.onload = res; img.onerror = () => rej(new Error('svg load failed')); + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); + }); + const cv = document.createElement('canvas'); + cv.width = w * px; cv.height = h * px; + cv.getContext('2d').drawImage(img, 0, 0); + cv.toBlob((blob) => save(blob, 'png'), 'image/png'); +} + +function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) { + const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props; + const id = rawId ?? rawLabel; + const ref = React.useRef(null); + const cardRef = React.useRef(null); + const menuRef = React.useRef(null); + const [menuOpen, setMenuOpen] = React.useState(false); + const [confirming, setConfirming] = React.useState(false); + + // ⋯ menu: close on any outside pointerdown. Two-click delete lives inside + // the menu — first click arms the row, second commits; closing disarms. + React.useEffect(() => { + if (!menuOpen) { setConfirming(false); return; } + const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); }; + document.addEventListener('pointerdown', off, true); + return () => document.removeEventListener('pointerdown', off, true); + }, [menuOpen]); + + const doExport = (kind) => { + setMenuOpen(false); + if (!cardRef.current) return; + const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_'); + dcExport(cardRef.current, width, height, name, kind) + .catch((e) => console.error('[design-canvas] export failed:', e)); + }; + + // Live drag-reorder: dragged card sticks to cursor; siblings slide into + // their would-be slots in real time via transforms. DOM order only + // changes on drop. + const onGripDown = (e) => { + e.preventDefault(); e.stopPropagation(); + const me = ref.current; + // translateX is applied in local (pre-scale) space but pointer deltas and + // getBoundingClientRect().left are screen-space — divide by the viewport's + // current scale so the dragged card tracks the cursor at any zoom level. + const scale = me.getBoundingClientRect().width / me.offsetWidth || 1; + const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`)); + const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left })); + const slotXs = homes.map((h) => h.x); + const startIdx = order.indexOf(id); + const startX = e.clientX; + let liveOrder = order.slice(); + me.classList.add('dc-dragging'); + + const layout = () => { + for (const h of homes) { + if (h.id === id) continue; + const slot = liveOrder.indexOf(h.id); + h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`; + } + }; + + const move = (ev) => { + const dx = ev.clientX - startX; + me.style.transform = `translateX(${dx / scale}px)`; + const cur = homes[startIdx].x + dx; + let nearest = 0, best = Infinity; + for (let i = 0; i < slotXs.length; i++) { + const d = Math.abs(slotXs[i] - cur); + if (d < best) { best = d; nearest = i; } + } + if (liveOrder.indexOf(id) !== nearest) { + liveOrder = order.filter((k) => k !== id); + liveOrder.splice(nearest, 0, id); + layout(); + } + }; + + const up = () => { + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', up); + const finalSlot = liveOrder.indexOf(id); + me.classList.remove('dc-dragging'); + me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`; + // After the settle transition, kill transitions + clear transforms + + // commit the reorder in the same frame so there's no visual snap-back. + setTimeout(() => { + for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; } + if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder); + requestAnimationFrame(() => requestAnimationFrame(() => { + for (const h of homes) h.el.style.transition = ''; + })); + }, 180); + }; + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', up); + }; + + return ( +
+
e.stopPropagation()}> +
+
+ +
+
+ e.stopPropagation()} + style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
+
+
+
+ + {menuOpen && ( +
e.stopPropagation()}> + + +
+ +
+ )} +
+ +
+
+
+ {children ||
{id}
} +
+
+ ); +} + +// Inline rename — commits on blur or Enter. +function DCEditable({ value, onChange, style, tag = 'span', onClick }) { + const T = tag; + return ( + e.stopPropagation()} + onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} + style={style}>{value} + ); +} + +// ───────────────────────────────────────────────────────────── +// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across +// sections, Esc or backdrop click to exit. +// ───────────────────────────────────────────────────────────── +function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) { + const ctx = React.useContext(DCCtx); + const { sectionId, artboard } = entry; + const sec = ctx.section(sectionId); + const meta = sectionMeta[sectionId]; + const peers = meta.slotIds; + const aid = artboard.props.id ?? artboard.props.label; + const idx = peers.indexOf(aid); + const secIdx = sectionOrder.indexOf(sectionId); + + const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); }; + const goSection = (d) => { + // Sections whose artboards are all deleted have slotIds:[] — step past + // them to the next non-empty section so ↑/↓ doesn't dead-end. + const n = sectionOrder.length; + for (let i = 1; i < n; i++) { + const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n]; + const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; + if (first) { ctx.setFocus(`${ns}/${first}`); return; } + } + }; + + React.useEffect(() => { + const k = (e) => { + if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); } + if (e.key === 'ArrowRight') { e.preventDefault(); go(1); } + if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); } + if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); } + }; + document.addEventListener('keydown', k); + return () => document.removeEventListener('keydown', k); + }); + + const { width = 260, height = 480, children } = artboard.props; + const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight }); + React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []); + const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2)); + + const [ddOpen, setDd] = React.useState(false); + const Arrow = ({ dir, onClick }) => ( + + ); + + // Portal to body so position:fixed is the real viewport regardless of any + // transform on DesignCanvas's ancestors (including the canvas zoom itself). + return ReactDOM.createPortal( +
ctx.setFocus(null)} + onWheel={(e) => e.preventDefault()} + style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)', + fontFamily: DC.font, color: '#fff' }}> + + {/* top bar: section dropdown (left) · close (right) */} +
e.stopPropagation()} + style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> +
+ + {ddOpen && ( +
+ {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => ( + + ))} +
+ )} +
+
+ +
+ + {/* card centered, label + index below — only the card itself stops + propagation so any backdrop click (including the margins around + the card) exits focus */} +
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> +
+ {children ||
{aid}
} +
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> + {(sec.labels || {})[aid] ?? artboard.props.label} + {idx + 1} / {peers.length} +
+
+ + go(-1)} /> + go(1)} /> + + {/* dots */} +
e.stopPropagation()} + style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> + {peers.map((p, i) => ( +
+
, + document.body, + ); +} + +// ───────────────────────────────────────────────────────────── +// Post-it — absolute-positioned sticky note +// ───────────────────────────────────────────────────────────── +function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { + return ( +
{children}
+ ); +} + +Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); + diff --git a/keysat-design-system/explorations/logo-directions-v2.html b/keysat-design-system/explorations/logo-directions-v2.html new file mode 100644 index 0000000..36ab2d3 --- /dev/null +++ b/keysat-design-system/explorations/logo-directions-v2.html @@ -0,0 +1,278 @@ + + + + +Keysat — Logo directions v2 + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/keysat-design-system/explorations/logo-directions.html b/keysat-design-system/explorations/logo-directions.html new file mode 100644 index 0000000..f06127f --- /dev/null +++ b/keysat-design-system/explorations/logo-directions.html @@ -0,0 +1,256 @@ + + + + +Keysat — Logo directions + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/keysat-design-system/explorations/type-directions.html b/keysat-design-system/explorations/type-directions.html new file mode 100644 index 0000000..85f42fc --- /dev/null +++ b/keysat-design-system/explorations/type-directions.html @@ -0,0 +1,173 @@ + + + + +Keysat — Type Exploration + + + + + + + + + + +
+ + + + + + + + + + + + diff --git a/keysat-design-system/preview/badges.html b/keysat-design-system/preview/badges.html new file mode 100644 index 0000000..289b801 --- /dev/null +++ b/keysat-design-system/preview/badges.html @@ -0,0 +1,42 @@ + + + + +Keysat — Badges & Tags + + + + +
status + Active + Trial + Expired + Pending + Draft +
+
accent + ★ Verified creator + Lifetime +
+
pillets + Rust + TypeScript + Python + Go (planned) + Swift (planned) +
+ + diff --git a/keysat-design-system/preview/brand-logo.html b/keysat-design-system/preview/brand-logo.html new file mode 100644 index 0000000..a289ab8 --- /dev/null +++ b/keysat-design-system/preview/brand-logo.html @@ -0,0 +1,27 @@ + + + + +Keysat — Logo Mark + + + + +
+ +
Logo · cream
+
+
+
KEYSAT
+
Wordmark · navy surface
+
+ + diff --git a/keysat-design-system/preview/buttons.html b/keysat-design-system/preview/buttons.html new file mode 100644 index 0000000..5155a59 --- /dev/null +++ b/keysat-design-system/preview/buttons.html @@ -0,0 +1,46 @@ + + + + +Keysat — Buttons + + + + +
primary + + + + +
+
secondary + + + + +
+
utility + + + + +
+ + diff --git a/keysat-design-system/preview/cards.html b/keysat-design-system/preview/cards.html new file mode 100644 index 0000000..593c3e9 --- /dev/null +++ b/keysat-design-system/preview/cards.html @@ -0,0 +1,45 @@ + + + + +Keysat — Cards + + + + +
+
+
Standard
+
Sundial 2.0
+
3 active licenses, 1 trial. Default policy is 1-year, single-seat.
+
50,000 sats12 sold
+
+ +
+
Dark surface
+
Sovereign by default
+
Backed up automatically by StartOS as part of your normal backup routine.
+
₿ 0.00214≈ $128.40
+
+
+ + diff --git a/keysat-design-system/preview/colors-cream-gold.html b/keysat-design-system/preview/colors-cream-gold.html new file mode 100644 index 0000000..8003da0 --- /dev/null +++ b/keysat-design-system/preview/colors-cream-gold.html @@ -0,0 +1,35 @@ + + + + +Keysat — Cream & Gold + + + + +
Cream — paper surfaces
+
+
50#FBF9F2
+
100 ★#F5F1E8
+
200#EDE7D7
+
300#E1D8C0
+
400#C9BC9A
+
+
Gold — accent (use sparingly)
+
+
700#8A6F3D
+
600#A88652
+
500 ★#BFA068
+
400#D4B985
+
300#E5CFA5
+
200#F0E2C5
+
+ + diff --git a/keysat-design-system/preview/colors-navy.html b/keysat-design-system/preview/colors-navy.html new file mode 100644 index 0000000..1f08637 --- /dev/null +++ b/keysat-design-system/preview/colors-navy.html @@ -0,0 +1,28 @@ + + + + +Keysat — Color Palette (Brand) + + + + +
Navy — primary brand
+
+
950#0E1F33
+
900#142A47
+
800 ★#1E3A5F
+
700#2A4A75
+
500#5074A1
+
300#A6B7CF
+
100#E4EAF1
+
+ + diff --git a/keysat-design-system/preview/colors-semantic.html b/keysat-design-system/preview/colors-semantic.html new file mode 100644 index 0000000..680fd9d --- /dev/null +++ b/keysat-design-system/preview/colors-semantic.html @@ -0,0 +1,37 @@ + + + + +Keysat — Semantic Colors + + + + +
+
+
Success
+
#2D7A5F
+
+
+
Warning
+
#B8861F
+
+
+
Danger
+
#B23A3A
+
+
+
Info
+
#2A4A75
+
+
+ + diff --git a/keysat-design-system/preview/forms.html b/keysat-design-system/preview/forms.html new file mode 100644 index 0000000..455a658 --- /dev/null +++ b/keysat-design-system/preview/forms.html @@ -0,0 +1,31 @@ + + + + +Keysat — Form Inputs + + + + +
+
Shown on receipts and the public purchase page.
+
+
+
+
Enter a valid email.
+ + + diff --git a/keysat-design-system/preview/license-keys.html b/keysat-design-system/preview/license-keys.html new file mode 100644 index 0000000..c108648 --- /dev/null +++ b/keysat-design-system/preview/license-keys.html @@ -0,0 +1,35 @@ + + + + +Keysat — License Key Display + + + + +
+
+
License key
+
KS-9F2A-7C41-XK22-6D8ECopy
+
+
+
Lifetime · gold stroke
+ +
+
+
Issuer public key
+
mz7q8r4t1v…h3k2pXq9wL · Ed25519
+
+
+ + diff --git a/keysat-design-system/preview/radii.html b/keysat-design-system/preview/radii.html new file mode 100644 index 0000000..70e73f6 --- /dev/null +++ b/keysat-design-system/preview/radii.html @@ -0,0 +1,26 @@ + + + + +Keysat — Radii + + + + +
+
xs3px
+
sm5px
+
md ★8px (buttons)
+
lg ★12px (cards)
+
xl18px
+
pilltags only
+
+ + diff --git a/keysat-design-system/preview/shadows.html b/keysat-design-system/preview/shadows.html new file mode 100644 index 0000000..c9c6850 --- /dev/null +++ b/keysat-design-system/preview/shadows.html @@ -0,0 +1,25 @@ + + + + +Keysat — Shadows + + + + +
+
shadow-xsresting
+
shadow-smcards
+
shadow-mdelevated
+
shadow-lgpopovers
+
shadow-xlmodals
+
+ + diff --git a/keysat-design-system/preview/spacing-scale.html b/keysat-design-system/preview/spacing-scale.html new file mode 100644 index 0000000..c3cf835 --- /dev/null +++ b/keysat-design-system/preview/spacing-scale.html @@ -0,0 +1,28 @@ + + + + +Keysat — Spacing + + + + +
--sp-14px
+
--sp-28px
+
--sp-312
+
--sp-416
+
--sp-520
+
--sp-624
+
--sp-732
+
--sp-840
+
--sp-956
+
--sp-1072
+
--sp-1196
+ + diff --git a/keysat-design-system/preview/type-body-mono.html b/keysat-design-system/preview/type-body-mono.html new file mode 100644 index 0000000..f048c7f --- /dev/null +++ b/keysat-design-system/preview/type-body-mono.html @@ -0,0 +1,26 @@ + + + + +Keysat — Body & Mono Type + + + + +
+
Buyers pay in Bitcoin via your own BTCPay. Your software verifies signed keys offline.
Inter · 400 · lead · 18/27
+
A complete sell-your-software stack, sovereign end-to-end. No SaaS, no middleman.
Inter · 400 · body · 15/23
+
Backed up automatically by StartOS as part of your normal backup routine.
Inter · 400 · small · 13.5/20
+
KS-9F2A-7C41-XK22-6D8E
JetBrains Mono · 500 · license key · 13
+
+ + diff --git a/keysat-design-system/preview/type-display.html b/keysat-design-system/preview/type-display.html new file mode 100644 index 0000000..474f86f --- /dev/null +++ b/keysat-design-system/preview/type-display.html @@ -0,0 +1,34 @@ + + + + +Keysat — Display Type + + + + +
+
+
Display
+
Sovereign by default.
+
Archivo · 800 · 56/58 · -2.5%
+
+
+
Self-hosted licensing for indie creators
+
Archivo · 800 · h1 · 44/48
+
+
+
You own the signing key
+
Archivo · 700 · h2 · 32/37
+
+
+ + diff --git a/keysat-design-system/ui_kits/dashboard/README.md b/keysat-design-system/ui_kits/dashboard/README.md new file mode 100644 index 0000000..adfee81 --- /dev/null +++ b/keysat-design-system/ui_kits/dashboard/README.md @@ -0,0 +1,29 @@ +# Keysat — Dashboard UI Kit + +The creator's admin panel. Runs on the creator's own Start9. Manages products, policies, license keys, customers, discount codes, and the audit log. + +## Files + +- `index.html` — entry view (Overview / dashboard home). +- `licenses.html` — license list, with row-level actions. +- `license-detail.html` — single license: certificate-style header, customer info, audit timeline, revoke action. +- `new-product.html` — create-product flow (product details + policy + price). +- `signin.html` — admin sign-in (paste admin API key). + +## Components (inline in each page) + +- **Sidebar** — wordmark, primary nav (Overview, Products, Licenses, Customers, Discounts, Audit, Settings), BTCPay connection status footer. +- **Topbar** — page title, breadcrumb, primary action button, search. +- **Stat cards** — KPI tiles (active licenses, sales 30d, sats earned). +- **Table** — license list, customer list. 52px rows, mono key column, status badge column. +- **Drawer / detail header** — certificate motif borrowed from the marketing hero. +- **Empty state** — gold-bordered cream card with a single Lucide icon. + +## Iconography + +Lucide via CDN. Stroke 1.75px, 18px in nav, 16px inline. + +## Disclaimers + +- The "Settings" tab from the user's brief was scoped down to **operator settings** (operator name, public key, BTCPay connection). Payouts removed because BTCPay handles money. +- All data is fake. diff --git a/keysat-design-system/ui_kits/dashboard/dash.css b/keysat-design-system/ui_kits/dashboard/dash.css new file mode 100644 index 0000000..f5f8ec3 --- /dev/null +++ b/keysat-design-system/ui_kits/dashboard/dash.css @@ -0,0 +1,206 @@ +/* Shared dashboard chrome — links to ../../colors_and_type.css for tokens */ + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: var(--font-body); + font-size: 14px; + color: var(--ink-900); + background: var(--cream-100); + background-image: + radial-gradient(rgba(14,31,51,0.022) 1px, transparent 1px), + radial-gradient(rgba(138,111,61,0.020) 1px, transparent 1px); + background-size: 3px 3px, 7px 7px; + -webkit-font-smoothing: antialiased; +} +a { color: var(--navy-800); text-decoration: none; } + +/* ---------- Layout ---------- */ +.app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; } + +/* ---------- Sidebar ---------- */ +.sidebar { + background: var(--navy-950); + color: var(--cream-200); + padding: 24px 14px; + display: flex; flex-direction: column; + border-right: 1px solid var(--navy-900); +} +.sidebar .brand { + display: flex; align-items: center; gap: 10px; + font-family: var(--font-display); font-weight: 500; font-size: 14px; letter-spacing: 0.28em; text-transform: uppercase; + color: var(--cream-50); + padding: 0 8px 22px; + border-bottom: 1px solid rgba(245,241,232,0.10); + margin-bottom: 14px; + letter-spacing: -0.01em; +} +.sidebar .brand img { width: 26px; height: 26px; } +.sidebar .group-label { + font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; + color: var(--gold-400); padding: 16px 10px 8px; +} +.sidebar a.nav { + display: flex; align-items: center; gap: 10px; + padding: 9px 10px; border-radius: 6px; + font-size: 13.5px; color: rgba(245,241,232,0.72); + transition: all 120ms; +} +.sidebar a.nav:hover { background: rgba(245,241,232,0.06); color: var(--cream-50); } +.sidebar a.nav.active { background: var(--navy-800); color: var(--cream-50); } +.sidebar a.nav [data-lucide] { width: 16px; height: 16px; } +.sidebar a.nav .count { + margin-left: auto; font-family: var(--font-mono); font-size: 11px; + background: rgba(245,241,232,0.10); color: var(--cream-200); + padding: 1px 7px; border-radius: 999px; +} +.sidebar a.nav.active .count { background: var(--gold-500); color: var(--navy-950); } +.sidebar .footer { + margin-top: auto; padding: 14px 10px; border-top: 1px solid rgba(245,241,232,0.10); + font-size: 12px; color: rgba(245,241,232,0.55); display: flex; gap: 10px; align-items: center; +} +.sidebar .footer .dot { width: 7px; height: 7px; border-radius: 50%; background: #2D7A5F; box-shadow: 0 0 0 3px rgba(45,122,95,0.25); } + +/* ---------- Main ---------- */ +.main { display: flex; flex-direction: column; min-width: 0; } +.topbar { + display: flex; align-items: center; gap: 16px; + padding: 18px 32px; border-bottom: 1px solid var(--border-1); + background: rgba(251,249,242,0.92); + backdrop-filter: blur(8px); + position: sticky; top: 0; z-index: 5; +} +.topbar .crumb { font-size: 12.5px; color: var(--ink-500); } +.topbar h1 { + font-family: var(--font-display); font-weight: 700; font-size: 22px; + letter-spacing: -0.015em; margin: 2px 0 0; color: var(--navy-950); +} +.topbar .search { + margin-left: auto; + position: relative; width: 280px; +} +.topbar .search input { + width: 100%; padding: 8px 12px 8px 32px; + font-family: var(--font-body); font-size: 13px; + border: 1px solid var(--border-1); + border-radius: 8px; background: var(--cream-50); + color: var(--ink-900); +} +.topbar .search input:focus { outline: none; border-color: var(--navy-700); box-shadow: 0 0 0 3px rgba(30,58,95,0.15); } +.topbar .search [data-lucide] { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; color: var(--ink-400); } +.topbar .topbar-actions { display: flex; gap: 8px; align-items: center; } + +.content { padding: 28px 32px 64px; max-width: 1280px; } + +/* ---------- Buttons ---------- */ +.btn { + display: inline-flex; align-items: center; gap: 7px; + font-family: var(--font-body); font-weight: 600; font-size: 13px; + padding: 8px 14px; border-radius: 7px; border: 1px solid transparent; + cursor: pointer; transition: all 120ms; line-height: 1; + white-space: nowrap; +} +.btn [data-lucide] { width: 14px; height: 14px; } +.btn.lg { font-size: 14px; padding: 11px 18px; } +.btn.sm { font-size: 12px; padding: 6px 10px; } +.btn.primary { background: var(--navy-800); color: var(--cream-50); border-color: var(--navy-800); } +.btn.primary:hover { background: var(--navy-900); border-color: var(--navy-900); } +.btn.secondary { background: var(--cream-50); color: var(--navy-900); border-color: var(--border-2); } +.btn.secondary:hover { background: var(--cream-200); } +.btn.ghost { background: transparent; color: var(--navy-900); } +.btn.ghost:hover { background: rgba(14,31,51,0.06); } +.btn.danger { color: var(--danger); border-color: rgba(178,58,58,0.3); background: transparent; } +.btn.danger:hover { background: var(--danger-bg); } + +/* ---------- Cards ---------- */ +.card { + background: var(--cream-50); + border: 1px solid var(--border-1); + border-radius: 10px; + box-shadow: var(--shadow-xs); +} +.card .card-head { + padding: 14px 18px; border-bottom: 1px solid var(--border-1); + display: flex; align-items: center; justify-content: space-between; +} +.card .card-head h3 { + font-family: var(--font-display); font-weight: 700; font-size: 15px; + margin: 0; letter-spacing: -0.01em; color: var(--navy-950); +} + +/* ---------- Stats ---------- */ +.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 24px; } +.stat { + background: var(--cream-50); border: 1px solid var(--border-1); + border-radius: 10px; padding: 18px 18px 16px; + position: relative; overflow: hidden; +} +.stat::before { + content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 2px; + background: var(--gold-500); opacity: 0; +} +.stat.featured::before { opacity: 1; } +.stat .label { + font-size: 11px; font-weight: 700; letter-spacing: 0.14em; + text-transform: uppercase; color: var(--ink-500); margin-bottom: 8px; +} +.stat .value { + font-family: var(--font-display); font-weight: 500; font-size: 30px; + color: var(--navy-950); letter-spacing: -0.022em; line-height: 1; +} +.stat .value .unit { font-family: var(--font-body); font-size: 13px; font-weight: 600; color: var(--ink-500); margin-left: 6px; } +.stat .delta { font-size: 12px; color: var(--success); margin-top: 8px; font-weight: 600; } +.stat .delta.down { color: var(--danger); } +.stat .sub { font-size: 12px; color: var(--ink-500); margin-top: 6px; } + +/* ---------- Table ---------- */ +table.t { + width: 100%; border-collapse: separate; border-spacing: 0; + background: var(--cream-50); border: 1px solid var(--border-1); + border-radius: 10px; overflow: hidden; +} +table.t thead th { + text-align: left; font-size: 11px; font-weight: 700; + letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-500); + padding: 12px 16px; background: var(--cream-100); + border-bottom: 1px solid var(--border-1); +} +table.t tbody td { + padding: 14px 16px; border-bottom: 1px solid var(--border-1); + font-size: 13.5px; color: var(--ink-700); vertical-align: middle; +} +table.t tbody tr:last-child td { border-bottom: 0; } +table.t tbody tr:hover { background: var(--cream-100); cursor: pointer; } +table.t .key { font-family: var(--font-mono); font-size: 12.5px; color: var(--navy-900); font-weight: 600; } +table.t .product { font-weight: 600; color: var(--navy-950); } +table.t .meta { color: var(--ink-500); font-size: 12px; } + +/* ---------- Badges ---------- */ +.badge { + display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; + padding: 2px 9px; border-radius: 999px; line-height: 1.5; border: 1px solid transparent; +} +.b-success { background: var(--success-bg); color: #205c47; border-color: rgba(45,122,95,0.25); } +.b-warning { background: var(--warning-bg); color: #7a5814; border-color: rgba(184,134,31,0.3); } +.b-danger { background: var(--danger-bg); color: #8a2828; border-color: rgba(178,58,58,0.25); } +.b-info { background: var(--navy-100); color: var(--navy-800); border-color: rgba(30,58,95,0.20); } +.b-neutral { background: var(--cream-200); color: var(--ink-700); border-color: var(--border-1); } +.b-gold { background: transparent; color: var(--gold-700); border-color: var(--gold-500); } +.dot { width: 6px; height: 6px; border-radius: 50%; } + +/* ---------- Forms ---------- */ +.field { margin-bottom: 14px; } +.field label.lbl { display: block; font-size: 12.5px; font-weight: 600; color: var(--ink-700); margin-bottom: 6px; } +.field .hint { font-size: 12px; color: var(--ink-500); margin-top: 5px; } +.input, .select { + width: 100%; padding: 9px 12px; font-family: var(--font-body); font-size: 13.5px; + border: 1px solid var(--border-2); border-radius: 7px; background: #FFFFFF; + color: var(--ink-900); transition: all 120ms; +} +.input:focus, .select:focus { outline: none; border-color: var(--navy-700); box-shadow: 0 0 0 3px rgba(30,58,95,0.18); } +.input.mono { font-family: var(--font-mono); font-size: 13px; } + +.eyebrow { + font-size: 10.5px; font-weight: 700; letter-spacing: 0.18em; + text-transform: uppercase; color: var(--gold-700); +} diff --git a/keysat-design-system/ui_kits/dashboard/index.html b/keysat-design-system/ui_kits/dashboard/index.html new file mode 100644 index 0000000..c239bfa --- /dev/null +++ b/keysat-design-system/ui_kits/dashboard/index.html @@ -0,0 +1,115 @@ + + + + +Keysat — Overview + + + + +
+ + +
+
+
+
Workspace · aurora-software
+

Overview

+
+ +
+ + +
+
+ +
+
+ +
+
Sales · 30 days
+
12 sales
+
+33% vs prev
+
+
+
Sats earned · 30d
+
412,500
+
≈ $247.32
+
+
+
Conversion
+
8.4 %
+
−1.2% vs prev
+
+
+ +
+
+

Recent licenses

View all
+ + + + + + + + + +
KeyProductCustomerStatusIssued
KS-9F2A-7C41-XK22-6D8ESundial 2.0nina@dial.studioActive2 hours ago
KS-A14C-PT09-LM31-R7Q4Sundial Prom@labry.devActiveYesterday
KS-T2X8-6K43-QQ91-WE0MSundial 2.0jo@kestrel.fmTrial2d ago
KS-BX9D-MM21-NU45-7F3RSundial 2.0ari@northpath.ioActive3d ago
KS-PW45-VR82-XA61-9K0LSundial Protom@workhorse.appRevoked5d ago
+
+ +
+
+

Top products

+
+
+
Sundial 2.0
28 active · 50,000 sats
+
1.4M sats
+
+
+
Sundial Pro
11 active · 200,000 sats
+
2.2M sats
+
+
+
Aurora Plugin
3 active · 75,000 sats
+
225k sats
+
+
+
+ +
+
+
Tip
+
Embed your public key
+

Paste this into your app's source. Verifies signatures offline.

+
+ mz7q8r4t1v…h3k2pXq9wL + +
+
+
+
+
+
+
+
+ + + + diff --git a/keysat-design-system/ui_kits/dashboard/license-detail.html b/keysat-design-system/ui_kits/dashboard/license-detail.html new file mode 100644 index 0000000..ee6b590 --- /dev/null +++ b/keysat-design-system/ui_kits/dashboard/license-detail.html @@ -0,0 +1,106 @@ + + + + +Keysat — License KS-9F2A-7C41-XK22-6D8E + + + + + +
+ +
+
+
Licenses · KS-9F2A-7C41-XK22-6D8E

License detail

+
+ + + +
+
+
+
+
— Certificate of License · Active —
+
KS-9F2A-7C41-XK22-6D8E
+
Sundial 2.0 · default policy
+
+
Issued
2026-04-22
+
Expires
2027-04-22
+
Seats
1 of 1
+
Trial
No
+
+
+ +
+
+

Audit timeline

+
+
License issued
2026-04-22 · 14:32 · BTCPay invoice INV-9F2A
Signed with issuer key mz7q8r4t1v…h3k2pXq9wL.
+
Payment confirmed
2026-04-22 · 14:31 · 50,000 sats · Lightning
Settled in 1 confirmation. Funds routed to wallet "aurora".
+
Invoice created
2026-04-22 · 14:29
Buyer landed on purchase URL from /pricing.
+
First verification
2026-04-22 · 15:11 · 10.0.4.118
Sundial v2.0.3 verified key offline at startup.
+
+
+ +
+
+

Customer

+
Emailnina@dial.studio
+
npubnpub1aw…q4t8
+
First seen2 hours ago
+
Other licenses1 (Aurora Plugin)
+
+
+

Policy

+
Slugdefault
+
Duration1 year
+
Seats1
+
Entitlementscore, sync, export
+
+
+
+
+
+
+ + + + diff --git a/keysat-design-system/ui_kits/dashboard/licenses.html b/keysat-design-system/ui_kits/dashboard/licenses.html new file mode 100644 index 0000000..8ed7dbc --- /dev/null +++ b/keysat-design-system/ui_kits/dashboard/licenses.html @@ -0,0 +1,69 @@ + + + + +Keysat — Licenses + + + + + +
+ +
+
+
Workspace · aurora-software

Licenses

+ +
+ + +
+
+
+
+ All42 + Active35 + Trial4 + Expired2 + Revoked1 +
+ + +
+ + + + + + + + + + + +
License keyProductCustomerStatusExpiresIssued
KS-9F2A-7C41-XK22-6D8ESundial 2.0nina@dial.studioActiveApr 20272 hours ago
KS-A14C-PT09-LM31-R7Q4Sundial Prom@labry.devActiveLifetimeYesterday
KS-T2X8-6K43-QQ91-WE0MSundial 2.0jo@kestrel.fmTrialin 12 days2d ago
KS-BX9D-MM21-NU45-7F3RSundial 2.0ari@northpath.ioActiveApr 20273d ago
KS-PW45-VR82-XA61-9K0LSundial Protom@workhorse.appRevoked5d ago
KS-MN23-LP08-RR54-VV01Aurora Pluginkate@kate.codesActiveMar 20271w ago
KS-DD12-XK77-AA98-PQ45Sundial 2.0raj@spinwheel.appActiveMar 20271w ago
+
+
+
+ + + + diff --git a/keysat-design-system/ui_kits/dashboard/new-product.html b/keysat-design-system/ui_kits/dashboard/new-product.html new file mode 100644 index 0000000..33db0e6 --- /dev/null +++ b/keysat-design-system/ui_kits/dashboard/new-product.html @@ -0,0 +1,123 @@ + + + + +Keysat — New product + + + + + +
+ +
+
+
Products · New

New product

+
+ + + +
+
+
+
+
+
+

Product

+
+
+
Shown on receipts and the public purchase page.
+
Used in your purchase URL.
+
+
+
+
+
+ +
+

Default policy

Drives the public purchase URL
+
+
+
+
+ +
+
+
Maximum machines per key.
+
+
+
+
+
+
Comma-separated. Embedded in the signed key.
+
+
+
+ +
+

Price

+
+
+
+
+
+
+
+
≈ $30.00 USD at current rate. Updated every 30s from your BTCPay store.
+
+
+
+ +
+
Preview
+
+
— Certificate of License —
+
Sundial 2.0
+
default · 1 year · single seat
+
License key
+
KS-XXXX-XXXX-XXXX-XXXX
+
+
Price
50,000 sats
+
Trial
14 days
+
+
+
+
+ Your public purchase URL will be
aurora.keysat.local/buy/sundial-2
+
+
+
+
+
+
+
+ + + + diff --git a/keysat-design-system/ui_kits/dashboard/signin.html b/keysat-design-system/ui_kits/dashboard/signin.html new file mode 100644 index 0000000..586016f --- /dev/null +++ b/keysat-design-system/ui_kits/dashboard/signin.html @@ -0,0 +1,36 @@ + + + + +Keysat — Sign in + + + + +
+
+

Keysat admin

+
Paste the admin API key from your StartOS service page.
+ + +
Find this in StartOS → Keysat → Properties → adminApiKey.
+ +
Connected to aurora.keysat.local
+
+ + diff --git a/keysat-design-system/ui_kits/docs/README.md b/keysat-design-system/ui_kits/docs/README.md new file mode 100644 index 0000000..eaa19b6 --- /dev/null +++ b/keysat-design-system/ui_kits/docs/README.md @@ -0,0 +1,11 @@ +# Keysat — Docs UI Kit + +Developer reference layout. Three-column shell: sidebar (sections), content (markdown), right rail (on-this-page). + +## Files +- `index.html` — Integration guide landing page with sidebar, prose, code samples, callouts. + +## Components inline +- Docs sidebar — grouped section nav, search. +- Prose — h1/h2/h3, lists, callouts, inline code, code blocks. +- Right rail — on-this-page jumplinks. diff --git a/keysat-design-system/ui_kits/docs/index.html b/keysat-design-system/ui_kits/docs/index.html new file mode 100644 index 0000000..2baa826 --- /dev/null +++ b/keysat-design-system/ui_kits/docs/index.html @@ -0,0 +1,161 @@ + + + + +Keysat Docs — Integration guide + + + + +
+ Keysat + Docs + + +
+ +
+ + +
+
Get started · Integration guide
+

Integration guide

+

Wire Keysat licenses into your software in under an afternoon. The verifier is pure-function, offline, and ships in five lines.

+ +

Prerequisites

+

Before you start, you should have:

+
    +
  • A Keysat installation running on your Start9 — see Installation.
  • +
  • BTCPay Server connected — see Connect BTCPay.
  • +
  • At least one product defined in the admin UI.
  • +
+ +

Install the SDK

+

Pick the SDK for your language. All three are wire-compatible — a license issued by your Keysat verifies identically in any of them.

+
# TypeScript
+npm install @keysat/licensing-client
+
+# Rust
+cargo add licensing-client
+
+# Python
+pip install keysat-licensing-client
+ +

Embed your public key

+

Copy your issuer public key from Settings → Issuer key in the admin UI. Paste it into your application's source code as a compile-time constant.

+
const ISSUER_PEM = `-----BEGIN PUBLIC KEY-----
+MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wL
+-----END PUBLIC KEY-----`;
+ +
+ +

Embed it. Don't fetch it. The whole point of offline verification is that your software can't be tricked by a network-level attacker. If you fetch the public key at runtime, you're back to trusting a server.

+
+ +

Verify a license

+

Read the user's license key from wherever you store it (a file, the keychain, an env var) and verify it at startup.

+
import { Verifier, PublicKey } from '@keysat/licensing-client'
+
+const verifier = new Verifier(PublicKey.fromPem(ISSUER_PEM))
+const ok = verifier.verify(licenseKeyFromUser)
+
+if (!ok.valid) exitUnlicensed()
+if (!ok.entitlements.has('export')) disableExport()
+ +

Renewals & revocation

+

Keysat licenses are signed at issue time and do not phone home. If a license is revoked in the admin UI, the existing key continues to verify — that's the trade-off for offline. To support revocation, ship a thin online check that runs on a cadence (e.g. once a week) against your Keysat's public revocation feed.

+ +
+ +

You decide the policy. Many indie developers don't ship revocation at all — once a key is sold, it stays valid. That's perfectly reasonable.

+
+
+ + +
+ + + + diff --git a/keysat-design-system/ui_kits/marketing/README.md b/keysat-design-system/ui_kits/marketing/README.md new file mode 100644 index 0000000..5860096 --- /dev/null +++ b/keysat-design-system/ui_kits/marketing/README.md @@ -0,0 +1,28 @@ +# Keysat — Marketing UI Kit + +A redesign of the single-page marketing site in the Keysat visual system: navy + cream, paper texture, classical type. Same content as the draft (`assets/keysat-draft-site.html`), new visual language. + +## Files + +- `index.html` — the full landing page (single-page marketing site). + +## Sections + +1. **Sticky header** — wordmark + nav + "Install" CTA. +2. **Hero** — eyebrow → display headline with a gold-underlined "self-hosted" → lede → CTA row → trust strip ("Runs on Start9 · Pays via BTCPay · Verifies offline"). +3. **Value props** — 6-card grid; Lucide icons; gold underline on titles. +4. **How it works** — numbered 5-step flow with gold serif numerals on cream paper cards. +5. **Integration code** — language tabs (Rust / TypeScript / Python) over a navy code block. +6. **Sovereign by default** — two-column "what you keep / what's outside the box". +7. **Install** — marketplace + sideload, gold-bordered command card. +8. **Footer** — navy surface, wordmark, links. + +## Iconography + +Lucide via `unpkg.com/lucide@latest`. Replaces the draft site's emoji 1:1: +- ⚡ → `zap` +- 🔐 → `key-round` +- 📡 → `wifi-off` +- 🎫 → `ticket` +- 🏷️ → `tag` +- 🛠️ → `wrench` diff --git a/keysat-design-system/ui_kits/marketing/index.html b/keysat-design-system/ui_kits/marketing/index.html new file mode 100644 index 0000000..6197054 --- /dev/null +++ b/keysat-design-system/ui_kits/marketing/index.html @@ -0,0 +1,714 @@ + + + + + +Keysat — Bitcoin-paid software licensing, self-hosted on Start9 + + + + + +
+ +
+ +
+
+
+
Software licensing for Bitcoin creators
+

Bitcoin-paid software licensing, self-hosted on Start9.

+

+ Buyers pay in Bitcoin via your own BTCPay. Your software verifies signed keys offline. You own the signing key, the customer list, and the payment rails — no SaaS, no middleman, no platform risk. +

+ +
+ Runs on Start9 + + Pays via BTCPay + + Verifies offline +
+
+
+ +
+
+
+ +
+
+
+ What this enables +

A complete sell-your-software stack, sovereign end-to-end.

+

Keysat handles the licensing layer. BTCPay handles payments. Your hardware holds the keys. No third party can mint, revoke, or read your sales records.

+
+
+
+
+

Bitcoin payments, your store

+

BTCPay Server on your own Start9 takes the payment. Lightning settles in seconds. Funds go straight to your wallet — no intermediary holds them.

+
+
+
+

You own the signing key

+

The Ed25519 keypair lives on your hardware. Every license is signed by it. There's no third party who could mint or revoke licenses.

+
+
+
+

Offline verification

+

Your software verifies licenses against an embedded public key. No network call. Customer apps work even if your Keysat goes offline.

+
+
+
+

Trials, expiries, seats, entitlements

+

Per-product policies for time-limited licenses, multi-seat caps, trial flags, feature entitlements baked into the key.

+
+
+
+

Discount & referral codes

+

Percent-off, fixed-sats-off, or free-license codes (no payment). Run launch promos, comp keys for press, track partner campaigns.

+
+
+
+

SDKs in your language

+

Rust, TypeScript, Python — wire-compatible offline verifiers. Five lines of code in your app and you're verifying real signatures.

+
+
+
+
+ +
+
+
+ How it works +

Five steps, end to end.

+

From sideload to first sale in an afternoon. No cloud account to create, no API keys to copy.

+
+
    +
  1. 01

    Install on your Start9

    Sideload the .s9pk, or install from registry.keysat.xyz. BTCPay comes bundled as a dependency.

  2. +
  3. 02

    Connect BTCPay

    One click in the StartOS Actions tab. Authorize once on BTCPay's consent page; Keysat registers a webhook automatically.

  4. +
  5. 03

    Define products + policies

    Declare a product, set its price in sats, define a policy (duration, seat cap, trial, entitlements).

  6. +
  7. 04

    Embed your public key

    Copy your Keysat public key into your app. Add the SDK. Five lines of code verifies a signature at startup.

  8. +
  9. 05

    Share your purchase URL

    Buyers hit your public URL, pay in Bitcoin, get a signed license. Their copy of your software boots up licensed.

  10. +
+
+
+ +
+
+
+
+ For developers +

Five lines, in the language you already write.

+

Keysat licenses are Ed25519-signed and base32-encoded. Verification is pure-function — no network, no daemon, no shared state.

+
    +
  • Wire-compatible across SDKs
  • +
  • Public key embedded at compile time
  • +
  • Returns product, policy, expiry, entitlements
  • +
  • Source-available, easy to port
  • +
+
+
+
+ + + + npm install @keysat/licensing-client +
+
import { Verifier, PublicKey } from '@keysat/licensing-client'
+
+const verifier = new Verifier(
+  PublicKey.fromPem(ISSUER_PEM)
+)
+
+const ok = verifier.verify(licenseKeyFromUser)
+console.log('licensed:', ok.productId, ok.expires)
+ + +
+
+
+
+ +
+
+
+ Sovereign by default +

Everything stays on your hardware.

+

If you migrate Start9 boxes, all of Keysat goes with you. If Keysat the project disappears, your existing licenses keep verifying — the public key is embedded in your software, the private key is on your machine.

+
+
+
+

What you keep

+
On your Start9, in your normal backups.
+
    +
  • Signing keypair
  • +
  • Customer email · npub list
  • +
  • Sale records
  • +
  • Audit log
  • +
  • BTCPay invoice history
  • +
  • Webhook subscribers
  • +
  • Bitcoin (your wallet)
  • +
+

Backed up automatically by StartOS as part of your normal backup routine.

+
+
+

What's outside the box

+
Things you don't have to deal with.
+
    +
  • Stripe
  • +
  • Gumroad
  • +
  • Paddle
  • +
  • Cryptlex
  • +
  • Keygen
  • +
  • LicenseSpring
  • +
  • SaaS subscription fees
  • +
  • Platform decisions about who you sell to
  • +
+

Source-available license · one-time payment in sats · ships with you when you migrate hardware.

+
+
+
+
+ +
+
+
+ Install +

From the marketplace, or sideload directly.

+
+
+ +
+ Alternative +

Sideload

+

If you'd rather not add the marketplace:

+
    +
  1. Download keysat_x86_64.s9pk from GitHub releases.
  2. +
  3. StartOS dashboard → Sideload → drag the file in.
  4. +
  5. Click Install.
  6. +
+
+
+
+
+ + + + + + + diff --git a/keysat-design-system/uploads/index.html b/keysat-design-system/uploads/index.html new file mode 100644 index 0000000..b6827a1 --- /dev/null +++ b/keysat-design-system/uploads/index.html @@ -0,0 +1,506 @@ + + + + + +Keysat — Bitcoin-paid software licensing, self-hosted on Start9 + + + + + + + + + + + + + + +
+ +
+ +
+
+ +

Bitcoin-paid software licensing,
self-hosted on Start9.

+

+ Keysat is the licensing server you run on your own Start9. Buyers pay in Bitcoin via your own BTCPay. Your software verifies signed keys offline. You own the signing key, the customer list, and the payment rails — no SaaS, no middleman, no platform risk. +

+ +
+
+ +
+
+

What this enables

+

A complete sell-your-software stack, sovereign end-to-end.

+
+
+ +

Bitcoin payments, your store

+

BTCPay Server on your own Start9 takes the payment. Lightning settles in seconds. Funds go straight to your wallet — no intermediary holds them.

+
+
+ 🔐 +

You own the signing key

+

The Ed25519 keypair lives on your hardware. Every license you issue is signed by it. There's no third party who could mint or revoke licenses.

+
+
+ 📡 +

Offline verification

+

Your software verifies licenses against an embedded public key. No network call. Your customers' apps work even if your Keysat goes offline.

+
+
+ 🎫 +

Trials, expiries, seats, entitlements

+

Per-product policies for time-limited licenses, multi-seat caps, trial flags, feature entitlements baked into the key.

+
+
+ 🏷️ +

Discount & referral codes

+

Percent-off, fixed-sats-off, or free-license codes (no payment). Run launch promos, comp keys for press, track partner campaigns.

+
+
+ 🛠️ +

SDKs in your language

+

Rust, TypeScript, Python — wire-compatible offline verifiers. Five lines of code in your app and you're verifying real signatures.

+
+
+
+
+ +
+
+

How it works

+

Five steps, end to end.

+
    +
  1. +

    Install Keysat on your Start9

    +

    Sideload the .s9pk, or install from registry.keysat.xyz. Keysat declares BTCPay Server as a dependency, so you'll have BTCPay running too.

    +
  2. +
  3. +

    Connect BTCPay in one click

    +

    Click "Connect BTCPay" in the StartOS Actions tab. You authorize once on your BTCPay's consent page; Keysat auto-detects your store and registers a webhook. No API keys to copy.

    +
  4. +
  5. +

    Define your products + policies

    +

    In the Keysat web UI: declare your product, set its price in sats, define a policy (duration, seat cap, trial flag, entitlements). The policy slugged default drives your public purchase flow.

    +
  6. +
  7. +

    Embed your public key in your software

    +

    Copy your Keysat public key into your app's source. Add the SDK (pip install, cargo add, npm install). Five lines of integration code verifies a license at startup.

    +
  8. +
  9. +

    Share your purchase URL

    +

    Your buyers hit your public Keysat URL, pay in Bitcoin, get a signed license key delivered. Their copy of your software boots up licensed. You see the sale in your audit log.

    +
  10. +
+
+
+ +
+
+

Wiring it into your app

+

A working offline check is five lines.

+ +
+
+

Python

+
pip install keysat-licensing-client
+
+from keysat_licensing_client import Verifier, PublicKey
+
+verifier = Verifier(PublicKey.from_pem(ISSUER_PEM))
+ok = verifier.verify(license_key_from_user)
+print("licensed for", ok.product_id)
+
+
+

Rust

+
[dependencies]
+licensing-client = "0.1"
+
+use licensing_client::{Verifier, PublicKeyPem};
+let pk = PublicKeyPem::from_str(ISSUER_PEM)?;
+let verifier = Verifier::new(pk);
+let ok = verifier.verify(&license_key)?;
+println!("licensed: {}", ok.product_id);
+
+
+

TypeScript / JavaScript

+
npm install @keysat/licensing-client
+
+import { Verifier, PublicKey } from '@keysat/licensing-client'
+
+const verifier = new Verifier(PublicKey.fromPem(ISSUER_PEM))
+const ok = verifier.verify(licenseKeyFromUser)
+console.log('licensed:', ok.productId)
+
+
+

Other languages

+

Any language with Ed25519 + base32 (Go, Java, Swift, C#, C++, …) can verify Keysat keys. The wire format is fully documented; thin SDKs can be ported in a few hours. Go and Java/Swift SDKs are on the roadmap.

+
    +
  • Go (planned)
  • +
  • Java/Kotlin (planned)
  • +
  • Swift (planned)
  • +
  • C#/.NET (planned)
  • +
  • C++ (planned)
  • +
+
+
+
+
+ +
+
+

Install

+

From the marketplace, or sideload directly.

+ +
+
+

From the marketplace

+

Add the Keysat marketplace to your Start9:

+
https://registry.keysat.xyz
+

StartOS dashboard → Marketplace → Add → paste the URL above. Keysat will appear; click Install.

+
+
+

Sideload

+

If you'd rather not add the marketplace:

+
    +
  1. Download the latest keysat_x86_64.s9pk from GitHub releases.
  2. +
  3. StartOS dashboard → Sideload → drag the file in.
  4. +
  5. Click Install.
  6. +
+
+
+ +

Then once installed

+
    +
  1. +

    Run "Connect BTCPay"

    +

    One click; Keysat handles the rest.

    +
  2. +
  3. +

    Set your operator name

    +

    What buyers see on receipts and the public homepage.

    +
  4. +
  5. +

    Open the admin web UI

    +

    Click "Launch UI" on the Keysat service in StartOS. Paste your admin API key. Create your first product, policy, and discount code from there.

    +
  6. +
+
+
+ +
+
+

Sovereign by default

+
+
+

What you keep

+
    +
  • Signing keypair
  • +
  • Customer email / npub list
  • +
  • Sale records
  • +
  • Audit log
  • +
  • BTCPay invoice history
  • +
  • Webhook subscribers
  • +
  • Bitcoin (your wallet)
  • +
+

Backed up automatically by StartOS as part of your normal backup routine.

+
+
+

What's outside the box

+
    +
  • No Stripe
  • +
  • No Gumroad
  • +
  • No Paddle
  • +
  • No Cryptlex / Keygen / LicenseSpring
  • +
  • No SaaS subscription fees
  • +
  • No platform decisions about who you can sell to
  • +
+

Source-available license; one-time payment in sats; everything ships with you when you migrate Start9 hardware.

+
+
+
+
+ + + + + diff --git a/keysat-design-system/uploads/keysat-thumbnail.png b/keysat-design-system/uploads/keysat-thumbnail.png new file mode 100644 index 0000000..542c743 Binary files /dev/null and b/keysat-design-system/uploads/keysat-thumbnail.png differ diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..542c743 Binary files /dev/null and b/logo.png differ diff --git a/tests/crosscheck/README.md b/tests/crosscheck/README.md new file mode 100644 index 0000000..a44363d --- /dev/null +++ b/tests/crosscheck/README.md @@ -0,0 +1,70 @@ +# Format cross-check + +Verifies that the three implementations of the `LIC1-...-...` key format +(licensing-service in Rust, licensing-client-rust SDK, licensing-client-ts SDK) +all agree, byte for byte, on **both** the legacy v1 payload layout and the +current v2 layout. + +## Approach + +1. `reference_signer.py` builds three license keys from scratch using the exact + byte layouts documented in `licensing-service/src/crypto/mod.rs`. It uses + Python's `cryptography` for Ed25519 signing — an independent implementation + from the Rust one, so agreement here is strong evidence the format is + correct, not just that the Rust side agrees with itself. + + The three keys exercise every branch of the parser: + + - **`v1`** — legacy fixed-74 payload, fingerprint-bound. New keys aren't + issued in v1 anymore, but SDKs must still accept them indefinitely so + old keys in the wild keep working. + - **`v2`** — trial key, fingerprint-bound, with explicit expiry and two + entitlements. Stresses the variable-length tail parser. + - **`v2_perpetual_unbound`** — the "happy path" for a normal paid purchase: + v2, no expiry, no fingerprint binding, no entitlements. + +2. `run_ts.mjs` imports the built TypeScript SDK (`dist/index.js`) and runs + each of the three fixtures through: field-by-field parse, signature + verification, fingerprint binding (positive + negative), entitlement + lookup, `isExpiredAt` boundaries, and tamper detection. Also spot-checks + `hashFingerprint` against Python's `hashlib.sha256` and public-key loading. + +## Rust SDK coverage + +The Rust SDK uses the same crates as the service (`data_encoding::BASE32_NOPAD`, +`ed25519_dalek`) with identical byte offsets (`licensing-client-rust/src/key.rs` +mirrors `licensing-service/src/crypto/mod.rs`), so round-trip compatibility is +guaranteed by construction. If you change the layout, update the service's +`from_bytes`, the Rust SDK's `from_bytes`, and `reference_signer.py` together. + +The Rust SDK's own unit tests (`cargo test -p licensing-client`) round-trip +every flag + version combination. + +## Running + +```bash +# Build the TS SDK first. +cd licensing-client-ts +npm install +npm run build + +cd ../tests/crosscheck +python3 reference_signer.py > vector.json +node --experimental-vm-modules run_ts.mjs +``` + +## Current test vectors + +Both vectors share a deterministic signing key seeded with `bytes(range(32))`, +so the license strings in `vector.json` are stable across regenerations (a +regression in encoding will produce a git diff). + +| fixture | version | expires_at | entitlements | flags | +|------------------------|---------|-------------|------------------|-----------------------------------------| +| `v1` | 1 | (n/a) | (n/a) | `FINGERPRINT_BOUND` | +| `v2` | 2 | 1900000000 | `["pro","multi-device"]` | `FINGERPRINT_BOUND \| TRIAL` | +| `v2_perpetual_unbound` | 2 | 0 | `[]` | `0` | + +Product UUID: `6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0`, fingerprint raw string +`"test-machine-fingerprint"` (SHA-256 +`d34461ff63170de633b5aa2512ce2e15ac120b1c325acdada67c02e594ba3b3d`). diff --git a/tests/crosscheck/reference_signer.py b/tests/crosscheck/reference_signer.py new file mode 100644 index 0000000..9c92ff4 --- /dev/null +++ b/tests/crosscheck/reference_signer.py @@ -0,0 +1,207 @@ +"""Produce LIC1-...-... keys the service would accept — used to verify the +SDK parsers round-trip correctly. + +Emits two fixtures now: v1 (legacy fixed-74 payload) and v2 (variable-length +payload with expires_at + entitlements). SDKs must accept both. + +Byte layout (matches licensing-service/src/crypto/mod.rs exactly): + +v1 (74 bytes, fixed): + [0] version = 1 + [1] flags + [2..18] product_id (UUID BE, 16 bytes) + [18..34] license_id (UUID BE, 16 bytes) + [34..42] issued_at (u64 BE, unix seconds) + [42..74] fingerprint_hash (SHA-256, zero if unbound) + +v2 (83 bytes + variable entitlements): + [0] version = 2 + [1] flags + [2..18] product_id + [18..34] license_id + [34..42] issued_at (u64 BE) + [42..50] expires_at (u64 BE, 0 = perpetual) + [50..82] fingerprint_hash (SHA-256, zero if unbound) + [82] num_entitlements (u8) + [83..] for each: [u8 len][len bytes of UTF-8 slug] + +Signature: 64 bytes over the raw payload bytes. +Encoding: LIC1-- + RFC 4648 base32, uppercase, no padding. +""" + +import base64 +import hashlib +import json +import sys +import uuid + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import ( + Encoding, PrivateFormat, PublicFormat, NoEncryption, +) + +KEY_VERSION_V1 = 1 +KEY_VERSION_V2 = 2 + +FLAG_FINGERPRINT_BOUND = 0b0000_0001 +FLAG_TRIAL = 0b0000_0010 + + +def b32nopad(b: bytes) -> str: + return base64.b32encode(b).decode("ascii").rstrip("=") + + +def make_payload_v1( + flags: int, + product_id: bytes, license_id: bytes, + issued_at: int, fp_hash: bytes, +) -> bytes: + assert len(product_id) == 16 + assert len(license_id) == 16 + assert len(fp_hash) == 32 + payload = ( + bytes([KEY_VERSION_V1, flags]) + + product_id + + license_id + + issued_at.to_bytes(8, "big") + + fp_hash + ) + assert len(payload) == 74, f"v1 payload is {len(payload)} bytes, expected 74" + return payload + + +def make_payload_v2( + flags: int, + product_id: bytes, license_id: bytes, + issued_at: int, expires_at: int, + fp_hash: bytes, entitlements: list[str], +) -> bytes: + assert len(product_id) == 16 + assert len(license_id) == 16 + assert len(fp_hash) == 32 + assert 0 <= len(entitlements) <= 255 + tail = bytearray() + for slug in entitlements: + encoded = slug.encode("utf-8") + assert len(encoded) <= 255, f"entitlement '{slug}' too long" + tail.append(len(encoded)) + tail.extend(encoded) + head = ( + bytes([KEY_VERSION_V2, flags]) + + product_id + + license_id + + issued_at.to_bytes(8, "big") + + expires_at.to_bytes(8, "big") + + fp_hash + + bytes([len(entitlements)]) + ) + assert len(head) == 83 + return head + bytes(tail) + + +def sign_and_encode(sk: Ed25519PrivateKey, payload: bytes) -> str: + sig = sk.sign(payload) + assert len(sig) == 64 + return f"LIC1-{b32nopad(payload)}-{b32nopad(sig)}" + + +def main(): + # Deterministic test vector — fixed seeds so the output is stable. + sk = Ed25519PrivateKey.from_private_bytes(bytes(range(32))) + pk = sk.public_key() + pub_pem = pk.public_bytes( + Encoding.PEM, PublicFormat.SubjectPublicKeyInfo + ).decode() + + product_id = uuid.UUID("6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0").bytes + license_id_v1 = uuid.UUID("11111111-2222-3333-4444-555555555555").bytes + license_id_v2 = uuid.UUID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").bytes + issued_at = 1_700_000_000 + expires_at_v2 = 1_900_000_000 # ~2030 + fingerprint_raw = "test-machine-fingerprint" + fp_hash = hashlib.sha256(fingerprint_raw.encode()).digest() + entitlements_v2 = ["pro", "multi-device"] + + # v1: legacy fingerprint-bound key. + v1_payload = make_payload_v1( + FLAG_FINGERPRINT_BOUND, + product_id, license_id_v1, issued_at, fp_hash, + ) + v1_key = sign_and_encode(sk, v1_payload) + + # v2: trial + fingerprint-bound, with entitlements and expiry. + v2_flags = FLAG_FINGERPRINT_BOUND | FLAG_TRIAL + v2_payload = make_payload_v2( + v2_flags, + product_id, license_id_v2, issued_at, expires_at_v2, + fp_hash, entitlements_v2, + ) + v2_key = sign_and_encode(sk, v2_payload) + + # v2 perpetual, unbound, no entitlements — the "happy path" for a normal + # permanent license purchase. + v2_plain_payload = make_payload_v2( + 0, + product_id, license_id_v2, issued_at, 0, + bytes(32), [], + ) + v2_plain_key = sign_and_encode(sk, v2_plain_payload) + + out = { + "publicKeyPem": pub_pem, + "v1": { + "licenseKey": v1_key, + "expected": { + "version": 1, + "productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0", + "licenseUuid": "11111111-2222-3333-4444-555555555555", + "issuedAt": issued_at, + "expiresAt": 0, + "flags": FLAG_FINGERPRINT_BOUND, + "isFingerprintBound": True, + "isTrial": False, + "entitlements": [], + "fingerprintRaw": fingerprint_raw, + "fingerprintHashHex": fp_hash.hex(), + }, + }, + "v2": { + "licenseKey": v2_key, + "expected": { + "version": 2, + "productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0", + "licenseUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "issuedAt": issued_at, + "expiresAt": expires_at_v2, + "flags": v2_flags, + "isFingerprintBound": True, + "isTrial": True, + "entitlements": entitlements_v2, + "fingerprintRaw": fingerprint_raw, + "fingerprintHashHex": fp_hash.hex(), + }, + }, + "v2_perpetual_unbound": { + "licenseKey": v2_plain_key, + "expected": { + "version": 2, + "productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0", + "licenseUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "issuedAt": issued_at, + "expiresAt": 0, + "flags": 0, + "isFingerprintBound": False, + "isTrial": False, + "entitlements": [], + "fingerprintRaw": None, + "fingerprintHashHex": "00" * 32, + }, + }, + } + json.dump(out, sys.stdout, indent=2) + print() + + +if __name__ == "__main__": + main() diff --git a/tests/crosscheck/run_ts.mjs b/tests/crosscheck/run_ts.mjs new file mode 100644 index 0000000..d6030c1 --- /dev/null +++ b/tests/crosscheck/run_ts.mjs @@ -0,0 +1,138 @@ +import { readFileSync } from 'node:fs' +import { + Verifier, + PublicKey, + hashFingerprint, + parseLicenseKey, + isExpiredAt, + hasEntitlement, +} from '/sessions/hopeful-determined-edison/ts-sdk-build/dist/index.js' + +const vector = JSON.parse(readFileSync(new URL('./vector.json', import.meta.url), 'utf8')) + +let failures = 0 +function check(name, cond, extra = '') { + if (cond) { + console.log(` OK ${name}`) + } else { + console.log(` FAIL ${name}${extra ? ': ' + extra : ''}`) + failures++ + } +} + +const toHex = (b) => + Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join('') + +const verifier = new Verifier(PublicKey.fromPem(vector.publicKeyPem)) + +function runCase(label, caseData) { + console.log(`\n== ${label}: parseLicenseKey + verify ==`) + const expected = caseData.expected + const parsed = parseLicenseKey(caseData.licenseKey) + + check(`${label}.version`, parsed.payload.version === expected.version, + `got ${parsed.payload.version}`) + check(`${label}.productUuid`, parsed.payload.productUuid === expected.productUuid, + `got ${parsed.payload.productUuid}`) + check(`${label}.licenseUuid`, parsed.payload.licenseUuid === expected.licenseUuid, + `got ${parsed.payload.licenseUuid}`) + check(`${label}.issuedAt`, parsed.payload.issuedAt === expected.issuedAt) + check(`${label}.expiresAt`, parsed.payload.expiresAt === expected.expiresAt, + `got ${parsed.payload.expiresAt}`) + check(`${label}.flags`, parsed.payload.flags === expected.flags, + `got ${parsed.payload.flags}`) + check(`${label}.isFingerprintBound`, + parsed.payload.isFingerprintBound === expected.isFingerprintBound) + check(`${label}.isTrial`, parsed.payload.isTrial === expected.isTrial) + check( + `${label}.entitlements`, + JSON.stringify(parsed.payload.entitlements) === JSON.stringify(expected.entitlements), + `got ${JSON.stringify(parsed.payload.entitlements)}`, + ) + check( + `${label}.fingerprintHash`, + toHex(parsed.payload.fingerprintHash) === expected.fingerprintHashHex, + ) + + // Signed bytes length depends on version + entitlements. We just assert + // that it round-trips the signature check. + check(`${label}.signature size`, parsed.signature.length === 64) + + try { + const v = verifier.verify(caseData.licenseKey) + check(`${label}.verify()`, true) + check(`${label}.verify productId`, v.productId === expected.productUuid) + check(`${label}.verify licenseId`, v.licenseId === expected.licenseUuid) + } catch (e) { + check(`${label}.verify()`, false, String(e)) + } + + if (expected.isFingerprintBound) { + try { + verifier.verifyWithFingerprint(caseData.licenseKey, expected.fingerprintRaw) + check(`${label}.verifyWithFingerprint correct`, true) + } catch (e) { + check(`${label}.verifyWithFingerprint correct`, false, String(e)) + } + let rejectedWrong = false + try { + verifier.verifyWithFingerprint(caseData.licenseKey, 'wrong-fingerprint') + } catch (e) { + rejectedWrong = String(e).toLowerCase().includes('fingerprint') + } + check(`${label}.verifyWithFingerprint wrong`, rejectedWrong) + } + + // Entitlement helper. + for (const slug of expected.entitlements) { + check(`${label}.hasEntitlement('${slug}')`, hasEntitlement(parsed.payload, slug)) + } + check(`${label}.hasEntitlement('nonexistent')`, + !hasEntitlement(parsed.payload, 'definitely-not-a-real-slug')) + + // Expiry helper. + if (expected.expiresAt > 0) { + check(`${label}.isExpiredAt(before)`, !isExpiredAt(parsed.payload, expected.expiresAt - 1)) + check(`${label}.isExpiredAt(at)`, isExpiredAt(parsed.payload, expected.expiresAt)) + check(`${label}.isExpiredAt(after)`, isExpiredAt(parsed.payload, expected.expiresAt + 1)) + } else { + check(`${label}.perpetual never expires`, + !isExpiredAt(parsed.payload, 2_000_000_000)) + } + + // Tamper check — flip the last base32 character of the signature. + const orig = caseData.licenseKey + const lastChar = orig.slice(-1) + const flipped = lastChar === 'A' ? 'B' : 'A' + const tampered = orig.slice(0, -1) + flipped + let tamperRejected = false + try { + verifier.verify(tampered) + } catch { + tamperRejected = true + } + check(`${label}.tampered key rejected`, tamperRejected) +} + +runCase('v1', vector.v1) +runCase('v2', vector.v2) +runCase('v2_perpetual_unbound', vector.v2_perpetual_unbound) + +console.log('\n== hashFingerprint === Python SHA-256 ==') +check( + 'sha256 matches', + toHex(hashFingerprint(vector.v1.expected.fingerprintRaw)) + === vector.v1.expected.fingerprintHashHex, +) + +console.log('\n== pubkey loaded has correct length ==') +check( + 'public key raw is 32 bytes', + PublicKey.fromPem(vector.publicKeyPem).raw.length === 32, +) + +if (failures > 0) { + console.log(`\nFAIL: ${failures} check(s) failed`) + process.exit(1) +} +console.log('\nALL CHECKS PASSED') diff --git a/tests/crosscheck/vector.json b/tests/crosscheck/vector.json new file mode 100644 index 0000000..31af379 --- /dev/null +++ b/tests/crosscheck/vector.json @@ -0,0 +1,54 @@ +{ + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=\n-----END PUBLIC KEY-----\n", + "v1": { + "licenseKey": "LIC1-AEAW6RVE6YGS6SRIW2VD5D57N4UPAEIRCEISEIRTGNCEIVKVKVKVKVIAAAAAAZKT6EANGRDB75RRODPGGO22UJISZYXBLLASBMODEWWNVWTHYAXFSS5DWPI-FV56FI7ZTB5GIFQHIPQ35QVVE5AO5FQGVQS45UJ5F632MLXS7VHMHYVLZWGE64FJOEXD2PVIFNE5XGRMTNOUOVEKDTW736743W25MAY", + "expected": { + "version": 1, + "productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0", + "licenseUuid": "11111111-2222-3333-4444-555555555555", + "issuedAt": 1700000000, + "expiresAt": 0, + "flags": 1, + "isFingerprintBound": true, + "isTrial": false, + "entitlements": [], + "fingerprintRaw": "test-machine-fingerprint", + "fingerprintHashHex": "d34461ff63170de633b5aa2512ce2e15ac120b1c325acdada67c02e594ba3b3d" + } + }, + "v2": { + "licenseKey": "LIC1-AIBW6RVE6YGS6SRIW2VD5D57N4UPBKVKVKVLXO6MZTO533XO53XO53QAAAAAAZKT6EAAAAAAABYT7MYA2NCGD73DC4G6MM5VVISRFTROCWWBECY4GJNM3LNGPQBOLFF2HM6QEA3QOJXQY3LVNR2GSLLEMV3GSY3F-QPSJIDYL6Y5TFCKXQ2SN43EDJIZIRJZCEROM2I4MJHODT6KO4KDPW6AJ3HMYJERYPD34CF2Z46PXPYFKSRZS7BDZKVKWE57UBJSTEBI", + "expected": { + "version": 2, + "productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0", + "licenseUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "issuedAt": 1700000000, + "expiresAt": 1900000000, + "flags": 3, + "isFingerprintBound": true, + "isTrial": true, + "entitlements": [ + "pro", + "multi-device" + ], + "fingerprintRaw": "test-machine-fingerprint", + "fingerprintHashHex": "d34461ff63170de633b5aa2512ce2e15ac120b1c325acdada67c02e594ba3b3d" + } + }, + "v2_perpetual_unbound": { + "licenseKey": "LIC1-AIAG6RVE6YGS6SRIW2VD5D57N4UPBKVKVKVLXO6MZTO533XO53XO53QAAAAAAZKT6EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-E4IT25ES5NBBQAXVAMZPLBDB5P2ILZL4RGKYUWEWLME5ZVGM7HGBG5CP3XHWBQ5FCYPEC6YGKBHCTQ7M7RZP7OR7NAYAMNAJAWW4QDQ", + "expected": { + "version": 2, + "productUuid": "6f46a4f6-0d2f-4a28-b6aa-3e8fbf6f28f0", + "licenseUuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "issuedAt": 1700000000, + "expiresAt": 0, + "flags": 0, + "isFingerprintBound": false, + "isTrial": false, + "entitlements": [], + "fingerprintRaw": null, + "fingerprintHashHex": "0000000000000000000000000000000000000000000000000000000000000000" + } + } +}