diff --git a/install.html b/install.html
index 02f7885..af7e4a4 100644
--- a/install.html
+++ b/install.html
@@ -132,21 +132,24 @@ payment_methods: [BTC-OnChain, BTC-LightningNetwork]Slug — lowercase, hyphens, will appear in the public URL. e.g. bitcoin-ticker-pro.
50000 for ~$30 USD at current rates.50000). For USD/EUR, enter the amount in dollars/euros — Keysat converts to BTC at invoice creation and the buyer pays the locked-in BTC amount.The product is created with no policies attached. Next:
-Go to Policies → Create a new policy. Pick the product, then:
+Go to Policies → Create a new policy. Pick the product, then fill in:
default. This is the policy consumed by the public purchase flow; other slugs are reserved for manual issuance.0 (perpetual), 31536000 (1 year), 2592000 (30 days for trials).1 for single-seat licenses or 0 for unlimited.basic, pro, annual). Not "special" in any way; the buy page renders a tier picker when a product has two or more public policies, with the initial tier chosen by ?policy=<slug> in the URL, then by the policy you mark "most popular", then by cheapest.1 for single-seat, 0 for unlimited.If you're selling a multi-tier product (e.g. Basic / Pro / Max), repeat this step for each tier. Drag the cards in the Policies grid to set the order shown to buyers.
+Your public purchase URL is now live at:
diff --git a/integrate.html b/integrate.html index 39fe979..ccbcace 100644 --- a/integrate.html +++ b/integrate.html @@ -58,12 +58,13 @@Three official SDKs ship today. They are wire-compatible — a license issued by your Keysat verifies identically in any of them.
+Four official SDKs ship today. They are wire-compatible — a license issued by your Keysat verifies identically in any of them. Cross-check fixtures in the daemon repo prove each SDK accepts the same bytes the daemon mints.
# npm @@ -79,6 +80,10 @@ pip install keysat-licensing-client # or with poetry poetry add keysat-licensing-client+
If your language isn’t covered, see Wire format. The format is small and porting takes about an afternoon.
@@ -235,7 +240,7 @@ result = verifier.verify(license_key_from_user)POST/v1/admin/productsPOST/v1/admin/policiesPOST/v1/admin/discount-codesGET/v1/admin/licenses/searchGET/v1/admin/licensesPOST/v1/admin/licenses/<id>/revokePOST/v1/admin/webhook-endpointsGET/v1/admin/auditThe Keysat backup payload is intentionally tiny. It contains:
/data/issuer-key.pem)./data/keysat.db)./data/keysat.db), which holds the Ed25519 signing keypair in the server_keys table along with all products, policies, licenses, invoices, audit log, webhook subscribers, and operator settings./data/keysat-license.txt, if you've activated a paid Keysat tier.That’s it. No log files (those rotate locally), no caches.
@@ -83,19 +83,19 @@The signing keypair restores along with the database, so all previously-issued licenses verify identically against the same public key. You don’t need to re-distribute the public key to your customers.
You generally don’t want to rotate the signing key — doing so invalidates every license you’ve ever issued. v0.1 doesn’t support rotation; the key is generated once at first start and never changed.
+You generally don’t want to rotate the signing key — doing so invalidates every license you’ve ever issued. There is no admin-UI affordance for rotation today; the key is generated once on first start (and persisted to the server_keys SQLite table) and stays there for the life of the instance.
If you absolutely need to rotate (e.g. you suspect the keypair has leaked off the box):
/data/issuer-key.pem aside.server_keys table (or move the database aside entirely if you also want to start clean).The cleaner path, for v0.2 onward, will be to support a rolling rotation where both keys verify for a transition period.
+A future release may support rolling rotation (two keys verifying during a transition window). It's not on the v0.2 / v0.3 roadmap.
BTCPay rejects the invoice request because the store has no configured wallet. Open BTCPay, find your store, and configure either an on-chain wallet or a Lightning node before retrying.
Check the audit log in the admin UI — failed deliveries land there with the response status. Common causes:
+In the admin UI go to Webhooks — failed deliveries past the 10-attempt retry budget land in the "Failed" filter (the DLQ), with the response status and an inline "Retry" button. The audit log is a secondary source. Common causes:
curl from your laptop to confirm.recurring_billing entitlement (auto-renewing
+ subscriptions) and will unlock zaprite_payments (card payments
+ via Zaprite) when that lands in v0.3. Patron differs from Pro by being a
+ one-time perpetual license rather than an annual subscription, plus direct
+ one-on-one support — not a feature gate, a different ownership model.
Keysat works without any license at all — you'll see "Unlicensed" in the - sidebar and get the same caps as a Creator-tier operator (5/5/5). The - license exists primarily so operators get a real "I bought it" experience - and so we can offer the upgrade path to Pro. Hobbyists can run Keysat - indefinitely without paying us a sat. + sidebar and get the same caps as a Creator-tier operator + (5 products / 5 policies per product / 10 active discount codes). The + Creator tier is free either way; the self-license flow exists primarily so + operators get a real "I bought it" experience for the paid tiers and so we + can offer the upgrade path to Pro. Hobbyists can run Keysat indefinitely + without paying us a sat.
diff --git a/wire-format.html b/wire-format.html index ac97747..eda35ca 100644 --- a/wire-format.html +++ b/wire-format.html @@ -50,68 +50,59 @@The bytes-over-the-wire spec for a Keysat license. Stable across SDKs and across language ports. About 90 lines of pseudocode to implement in a new language.
A Keysat license key looks like this on a receipt:
+A Keysat license key on a receipt looks like this:
-KS-9F2A-7C41-XK22-6D8E-LM77-PQ91+
LIC1-<base32 payload>-<base32 signature>-
Strip the KS- prefix and the dashes, and you have a Crockford base32-encoded blob. Base32-decode that blob, and you get the binary license envelope: a fixed-layout struct followed by an Ed25519 signature.
Three parts, separated by single dashes:
+LIC1 — literal envelope tag. Future format revisions get a new tag (LIC2 etc.). Parsers MUST reject unknown tags.<base32 payload> — the signed payload bytes, RFC 4648 base32 without padding (case-insensitive on decode). Variable length depending on payload version and number of entitlements.<base32 signature> — the 64-byte Ed25519 signature over the raw payload bytes, base32-encoded the same way.To verify: split on -, validate the tag is LIC1, base32-decode both chunks (case-fold to upper), parse the payload, and verify the signature bytes against the raw payload bytes using the issuer’s Ed25519 public key.
All multi-byte integers are big-endian.
+Keysat ships two payload versions today. v2 is the current default that the daemon issues; v1 verifiers stay in the SDKs forever so legacy keys keep verifying.
+Issued by the very early daemon builds. No expiry, no entitlements — perpetual only, fingerprint binding optional. Still accepted on parse so old customer keys don’t break.
| Offset | Length | Field | Notes |
|---|---|---|---|
0 | 4 | Magic | ASCII KSAT (0x4B 0x53 0x41 0x54). |
4 | 1 | Version | Currently 0x01. Decoders MUST reject unknown versions. |
5 | 1 | Flags | Bit 0: TRIAL. Bit 1: PERPETUAL. Bits 2–7 reserved. |
6 | 16 | License ID | UUIDv4 binary form. |
22 | 16 | Issuer fingerprint | SHA-256 of the issuer public key, truncated to 16 bytes. |
38 | 8 | Issued-at | Unix seconds, signed. |
46 | 8 | Expires-at | Unix seconds, signed. 0 if PERPETUAL flag is set. |
54 | 2 | Seats | Max machines. 0 = unlimited. |
56 | 2 | Payload length | Length L of the variable-size payload that follows. |
58 | L | Payload | UTF-8 JSON: { "product": "...", "policy": "...", "entitlements": [...] }. |
58 + L | 64 | Signature | Ed25519 signature over bytes 0 .. (58 + L). |
0 | 1 | version | 0x01 |
1 | 1 | flags | Bit 0: fingerprint-bound. Other bits reserved. |
2 | 16 | product_id | UUID, big-endian bytes. |
18 | 16 | license_id | UUID, big-endian bytes. |
34 | 8 | issued_at | Unix seconds, u64 big-endian. |
42 | 32 | fingerprint_hash | SHA-256 of the machine fingerprint; all zeros if not bound. |
Keysat uses Crockford’s base32 alphabet (0123456789ABCDEFGHJKMNPQRSTVWXYZ) without checksum, without padding, and case-insensitive on decode.
The reason for Crockford over standard base32: human-friendly. I, L, O, U are excluded from the alphabet to avoid ambiguity when typing keys off a printed receipt.
For display, keys are upper-cased, then grouped into 4-character chunks separated by dashes, and prefixed with KS-:
// raw base32, length depends on payload size -9F2A7C41XK226D8ELM77PQ91RR54VV01 - -// grouped + prefixed for display -KS-9F2A-7C41-XK22-6D8E-LM77-PQ91-RR54-VV01- -
Decoders MUST strip the KS- prefix (case-insensitive), strip whitespace and dashes, and case-fold to upper before base32-decoding.
83-byte fixed head + variable-length entitlements table. v2 adds expiry, trial flag, and entitlements — all signed so offline verifiers can gate features without contacting the server (a stripped entitlement or pushed-back expiry would have to match a valid signature, which the attacker can’t produce).
+| Offset | Length | Field | Notes |
|---|---|---|---|
0 | 1 | version | 0x02 |
1 | 1 | flags | Bit 0: fingerprint-bound. Bit 1: trial (best-effort hint for clients). |
2 | 16 | product_id | UUID, big-endian bytes. |
18 | 16 | license_id | UUID, big-endian bytes. |
34 | 8 | issued_at | Unix seconds, u64 big-endian. |
42 | 8 | expires_at | Unix seconds, u64 big-endian. 0 means perpetual. |
50 | 32 | fingerprint_hash | SHA-256 of the machine fingerprint; all zeros if not bound. |
82 | 1 | entitlements_count | N, 0–255. |
83.. | variable | entitlements | N entries, each <len: u8><ascii bytes>. Each entitlement string is ≤255 bytes. |
The signature covers the entire envelope from offset 0 through the end of the payload — that is, all bytes before the 64-byte signature itself.
The signature is computed over the raw payload bytes — the binary head plus any entitlements table, without the version tag, without base32 encoding, without dashes. The two base32 chunks in the wire format are encoded independently; concatenating them and base32-decoding the whole would be wrong.
+Verify with the issuer’s Ed25519 public key (PEM-encoded, SubjectPublicKeyInfo). The SDKs ship the public key bundled in your app at build time; they don’t fetch it at runtime. (The whole point of offline verification is that a network-level attacker can’t hand your software a different key.)
-Verify with the issuer’s Ed25519 public key. The fingerprint at offset 22 lets the verifier confirm that the key it has matches the key the license was signed with: SHA-256 the public key bytes, truncate to 16 bytes, compare. If it doesn’t match, the verifier MUST reject before attempting signature check — this gives a clear "wrong issuer" error rather than a generic "bad signature".
- -Test vector for the Python SDK’s cross-check tests (issuer fingerprint 0xfeed face cafe babe..., single-seat perpetual license):
# Hex dump of the binary envelope -00000000 4B 53 41 54 01 02 9F 2A 7C 41 XK 22 6D 8E LM 77 |KSAT...*|A.."m..w| -00000010 PQ 91 RR 54 VV 01 FE ED FA CE CA FE BA BE 00 00 |...T....|........| -00000020 00 00 00 00 65 4F 12 34 00 00 00 00 00 00 00 00 |....eO.4|........| -00000030 00 01 00 24 7B 22 70 72 6F 64 75 63 74 22 3A 22 |...${"product":"| -00000040 73 75 6E 64 69 61 6C 22 2C 22 70 6F 6C 69 63 79 |sundial","policy| -00000050 22 3A 22 64 65 66 61 75 6C 74 22 7D ...sig... |":"default"}.....| - -# As displayed -KS-9F2A-7C41-XK22-6D8E-LM77-PQ91-…- -
The full vector lives in licensing-client-python/tests/fixtures/canonical.json and is what every official SDK is tested against.
Standard RFC 4648 base32 (alphabet A–Z, 2–7), no padding, case-insensitive on decode. The daemon emits uppercase. Decoders MUST strip whitespace and case-fold to upper before decoding.
Why not Crockford / hex / base58: standard base32 has wide library support, encodes 5 bytes per 8 characters (slightly tighter than hex), is case-insensitive for type-on-receipt scenarios, and avoids the I/O/0/1 ambiguity of base58.
Public keys are exchanged in PEM format, SubjectPublicKeyInfo encoded:
@@ -132,33 +123,32 @@ MCowBQYDK2VwAyEAmz7q8r4t1v…h3k2pXq9wLThe wire format is small enough to port in an afternoon. The order is:
licensing-service/tests/crosscheck/. Vectors cover v1 legacy, v2 trial-with-entitlements, and v2 perpetual-unbound fixtures.See PORTING_SDK_TO_NEW_LANGUAGES.md in the repo for the full contributor guide.
+The four official SDKs (Rust, TypeScript, Python, Go) all sit on top of these same fixtures and the daemon’s test suite asserts each implementation round-trips them identically before a release ships.
The version byte at offset 4 is a hard gate. Decoders MUST reject any version they don’t implement. We commit to:
+The version byte at payload offset 0 is a hard gate. Decoders MUST reject any version they don’t implement (no graceful skip-over). We commit to:
tests/fixtures/ in the canonical SDK.LIC1-…) bumps only on a breaking envelope change — new payload versions live inside the same envelope tag as long as the split-on-dash structure stays the same.tests/crosscheck/ in the daemon repo. All five implementations (daemon, Rust SDK, TypeScript SDK, Python SDK, Go SDK) are required to round-trip the same vectors byte-for-byte before a release ships.